From 2b779ef61a275cff2218900937fc2952e9b82f76 Mon Sep 17 00:00:00 2001 From: Agung Subastian Date: Wed, 8 Jul 2026 18:22:54 +0700 Subject: [PATCH] fix(security): reject cross-origin requests in TrustedLocal TrustedLocal only checked that the TCP peer was loopback, so any web page open in the user's browser could fetch() the local /api and be treated as trusted -- a drive-by CSRF into RCE via /api/agent/exec and every other TrustedLocal-gated endpoint. Origin cannot be forged by a browser, so reject a request whose Origin host differs from its own Host; a request with no Origin header (curl, enx, scripts) is unaffected. --- server/middleware/dashboard.go | 28 ++++++++- server/middleware/dashboard_test.go | 92 +++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 server/middleware/dashboard_test.go diff --git a/server/middleware/dashboard.go b/server/middleware/dashboard.go index 8d3024f..d42f272 100644 --- a/server/middleware/dashboard.go +++ b/server/middleware/dashboard.go @@ -7,6 +7,8 @@ import ( "encoding/json" "net" "net/http" + "net/url" + "strings" "sync" "time" @@ -242,9 +244,29 @@ func looksForwarded(r *http.Request) bool { h.Get("Cf-Ray") != "" } +// originTrusted reports whether a request's Origin header (if any) matches its +// own Host. A browser cannot forge Origin, so a present-but-mismatched Origin +// means the request was sent cross-site by a page running in the user's +// browser (drive-by CSRF) even though the TCP connection itself is loopback. +// Non-browser callers (curl, the enx CLI, local scripts) never send an Origin +// header at all, so their requests are unaffected. +func originTrusted(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + u, err := url.Parse(origin) + if err != nil { + return false + } + return strings.EqualFold(u.Host, r.Host) +} + // TrustedLocal reports whether the request genuinely originates on this machine: -// a loopback TCP peer that did NOT arrive via the tunnel/proxy. Remote requests -// (including anything through cloudflared) return false and must authenticate. +// a loopback TCP peer that did NOT arrive via the tunnel/proxy, and — if the +// request carries an Origin header at all — one that matches this request's +// own Host. Remote requests (including anything through cloudflared) and +// cross-site browser requests both return false and must authenticate. func TrustedLocal(r *http.Request) bool { - return peerIsLoopback(r) && !looksForwarded(r) + return peerIsLoopback(r) && !looksForwarded(r) && originTrusted(r) } diff --git a/server/middleware/dashboard_test.go b/server/middleware/dashboard_test.go new file mode 100644 index 0000000..7c8052a --- /dev/null +++ b/server/middleware/dashboard_test.go @@ -0,0 +1,92 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestTrustedLocal(t *testing.T) { + tests := []struct { + name string + remoteAddr string + host string + origin string + forwarded bool + want bool + }{ + { + name: "loopback, no origin header", + remoteAddr: "127.0.0.1:54321", + host: "127.0.0.1:1430", + want: true, + }, + { + name: "loopback, origin matches host", + remoteAddr: "127.0.0.1:54321", + host: "127.0.0.1:1430", + origin: "http://127.0.0.1:1430", + want: true, + }, + { + name: "loopback, origin host mismatch", + remoteAddr: "127.0.0.1:54321", + host: "127.0.0.1:1430", + origin: "http://evil.example.com", + want: false, + }, + { + name: "loopback, origin port mismatch", + remoteAddr: "127.0.0.1:54321", + host: "127.0.0.1:1430", + origin: "http://127.0.0.1:9999", + want: false, + }, + { + name: "loopback, origin is the literal null", + remoteAddr: "127.0.0.1:54321", + host: "127.0.0.1:1430", + origin: "null", + want: false, + }, + { + name: "loopback, malformed origin", + remoteAddr: "127.0.0.1:54321", + host: "127.0.0.1:1430", + origin: "http://[::1]:notaport", + want: false, + }, + { + name: "loopback, origin matches host but request is forwarded", + remoteAddr: "127.0.0.1:54321", + host: "127.0.0.1:1430", + origin: "http://127.0.0.1:1430", + forwarded: true, + want: false, + }, + { + name: "non-loopback peer", + remoteAddr: "203.0.113.7:54321", + host: "127.0.0.1:1430", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "http://"+tt.host+"/api/agent/exec", nil) + r.RemoteAddr = tt.remoteAddr + r.Host = tt.host + if tt.origin != "" { + r.Header.Set("Origin", tt.origin) + } + if tt.forwarded { + r.Header.Set("X-Forwarded-For", "198.51.100.9") + } + + if got := TrustedLocal(r); got != tt.want { + t.Errorf("TrustedLocal() = %v, want %v", got, tt.want) + } + }) + } +}