From b1108a9e815f5b61419beba50981ebb8797ca63e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:06:41 +0000 Subject: [PATCH] fix(web): match the WebSocket origin on scheme and port, not only host The live-update socket accepted any Origin whose host name matched, but cookies do not separate ports or schemes, so a page served by another service on the same host name could open it with the admin's session. Only http(s) origins are accepted now (https when the panel terminates TLS itself), and the port must match whenever the Host header has one. --- internal/web/controller/websocket.go | 23 +++++++++++-- internal/web/controller/websocket_test.go | 42 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 internal/web/controller/websocket_test.go diff --git a/internal/web/controller/websocket.go b/internal/web/controller/websocket.go index 49447b416..fc6007536 100644 --- a/internal/web/controller/websocket.go +++ b/internal/web/controller/websocket.go @@ -34,7 +34,12 @@ func checkSameOrigin(r *http.Request) bool { if err != nil || u.Hostname() == "" { return false } - host, _, err := net.SplitHostPort(r.Host) + // Cookies ignore scheme and port, so both must match too; behind a TLS-terminating + // proxy the request arrives as http and the browser's https origin still passes. + if u.Scheme != "https" && (u.Scheme != "http" || r.TLS != nil) { + return false + } + host, port, err := net.SplitHostPort(r.Host) if err != nil { // IPv6 literals without a port arrive as "[::1]"; net.SplitHostPort // fails in that case while url.Hostname() returns the address without @@ -44,7 +49,21 @@ func checkSameOrigin(r *http.Request) bool { host = host[1 : len(host)-1] } } - return strings.EqualFold(u.Hostname(), host) + if !strings.EqualFold(u.Hostname(), host) { + return false + } + return port == "" || port == originPort(u) +} + +// originPort is the Origin's port, or the default a browser leaves out for its scheme. +func originPort(u *url.URL) string { + if p := u.Port(); p != "" { + return p + } + if u.Scheme == "https" { + return "443" + } + return "80" } // WebSocketController handles the HTTP→WebSocket upgrade for real-time updates. diff --git a/internal/web/controller/websocket_test.go b/internal/web/controller/websocket_test.go new file mode 100644 index 000000000..3ebc823f8 --- /dev/null +++ b/internal/web/controller/websocket_test.go @@ -0,0 +1,42 @@ +package controller + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" +) + +// Cookies do not separate ports or schemes, so another service on the panel's host name +// must not be able to open the admin's live-update socket from the admin's browser. +func TestCheckSameOriginComparesSchemeAndPort(t *testing.T) { + for _, tc := range []struct { + name, host, origin string + tls bool + want bool + }{ + {"no origin header", "panel.example.com:2053", "", false, true}, + {"same host and port", "panel.example.com:2053", "http://panel.example.com:2053", false, true}, + {"default port omitted by the browser", "panel.example.com:443", "https://panel.example.com", true, true}, + {"behind a proxy without a port", "panel.example.com", "https://panel.example.com", false, true}, + {"bare IPv6 host", "[::1]", "http://[::1]", false, true}, + {"another port on the same host", "panel.example.com:2053", "http://panel.example.com:8080", false, false}, + {"plain http origin on a TLS panel", "panel.example.com:2053", "http://panel.example.com:2053", true, false}, + {"non-web scheme", "panel.example.com:2053", "chrome-extension://panel.example.com:2053", false, false}, + {"another host", "panel.example.com:2053", "http://evil.example.com:2053", false, false}, + } { + req := httptest.NewRequest(http.MethodGet, "/ws", nil) + req.Host = tc.host + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + if tc.tls { + req.TLS = &tls.ConnectionState{} + } else { + req.TLS = nil + } + if got := checkSameOrigin(req); got != tc.want { + t.Errorf("%s: checkSameOrigin(Host %q, Origin %q) = %v, want %v", tc.name, tc.host, tc.origin, got, tc.want) + } + } +}