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) + } + } +}