diff --git a/internal/services/gateway/extra_test.go b/internal/services/gateway/extra_test.go index 985d9de..9cb4614 100644 --- a/internal/services/gateway/extra_test.go +++ b/internal/services/gateway/extra_test.go @@ -876,6 +876,44 @@ func TestOriginChecker(t *testing.T) { } } +func TestOriginCheckerSameHost(t *testing.T) { + t.Parallel() + allowed := map[string]bool{"http://ok.example": true} + checker := originChecker(allowed, false) + + // Same host (exact) — production mode serving embedded frontend on the gateway port. + req := httptest.NewRequest(http.MethodGet, "/ws/chat", nil) + req.Host = "localhost:10942" + req.Header.Set("Origin", "http://localhost:10942") + if !checker(req) { + t.Fatal("same host origin should be allowed") + } + + // Loopback alias: host is 127.0.0.1 but client origin uses localhost. + req2 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil) + req2.Host = "127.0.0.1:10942" + req2.Header.Set("Origin", "http://localhost:10942") + if !checker(req2) { + t.Fatal("localhost<->127.0.0.1 loopback alias should be allowed") + } + + // Different host must still be rejected (not a same-origin, not whitelisted). + req3 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil) + req3.Host = "evil.example:10942" + req3.Header.Set("Origin", "http://localhost:10942") + if checker(req3) { + t.Fatal("cross-host origin should be rejected") + } + + // Same host, different port: still same-origin (same physical host), so allow. + req4 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil) + req4.Host = "localhost:8080" + req4.Header.Set("Origin", "http://localhost:10942") + if !checker(req4) { + t.Fatal("same host (different port) should be allowed — port is not a cross-origin boundary") + } +} + func TestWSTokenExtraction(t *testing.T) { t.Parallel() if got := wsToken(reqWithQuery("token", "abc")); got != "abc" { diff --git a/internal/services/gateway/ws_hub.go b/internal/services/gateway/ws_hub.go index eb41bd3..86c45f8 100644 --- a/internal/services/gateway/ws_hub.go +++ b/internal/services/gateway/ws_hub.go @@ -367,8 +367,14 @@ func rawData(d json.RawMessage) any { return d } -// originChecker allows same-origin (empty) and whitelisted WebSocket origins, -// mirroring the CORS allow list. A "*" default allows all. +// originChecker allows same-origin, same-host, and whitelisted WebSocket origins, +// mirroring the CORS allow list. A "*" allows all. +// +// Same-host relaxation: when cyrene serves its own embedded frontend (production +// mode), the page origin is exactly the gateway's Host (e.g. http://localhost:10942), +// which is not in the dev-phase ALLOWED_ORIGINS list. We must let that through so a +// user who changes PORT does not need to edit .env for the WS to connect. Only truly +// cross-origin clients (external IM / dashboards on another origin) hit the allow-list. func originChecker(allowed map[string]bool, allowAll bool) func(*http.Request) bool { return func(r *http.Request) bool { origin := r.Header.Get("Origin") @@ -378,6 +384,52 @@ func originChecker(allowed map[string]bool, allowAll bool) func(*http.Request) b if allowAll { return true } - return allowed[origin] + if allowed[origin] { + return true + } + // Same-host: the Origin matches this request's Host (scheme://host:port == host[:port]). + return sameOriginAsHost(origin, r) } } + +// sameOriginAsHost reports whether origin (a full URL origin string such as +// "http://localhost:10942") corresponds to this request's Host header. It compares +// the host[:port] portion with loopback equivalence: "localhost" and "127.0.0.1" +// (and the IPv6 form) are treated as the same host, so serving the embedded frontend +// on 127.0.0.1 while a client connects via localhost (or vice versa) still passes. +func sameOriginAsHost(origin string, r *http.Request) bool { + if origin == "" || r == nil { + return false + } + after := origin + if i := strings.Index(after, "://"); i >= 0 { + after = after[i+3:] + } + if i := strings.IndexByte(after, '/'); i >= 0 { + after = after[:i] + } + return hostEquivalent(after, r.Host) +} + +// hostEquivalent compares two host[:port] strings, treating empty-port variants, +// case, and loopback aliases as equal. It handles "localhost" vs "127.0.0.1" and +// bracketed IPv6 loopback "[::1]". +func hostEquivalent(a, b string) bool { + na := normalizeHost(a) + nb := normalizeHost(b) + if na == nb { + return true + } + // Loopback aliases: localhost, 127.0.0.1, [::1] + loop := map[string]bool{"localhost": true, "127.0.0.1": true, "[::1]": true, "::1": true, "127.0.0.2": true} + return loop[na] && loop[nb] +} + +// normalizeHost strips the port (if present), lowercases, and trims brackets. +func normalizeHost(h string) string { + h = strings.ToLower(strings.TrimSpace(h)) + if i := strings.LastIndex(h, ":"); i >= 0 && !strings.Contains(h[i:], "]") { + h = h[:i] + } + return strings.TrimPrefix(strings.TrimSuffix(h, "]"), "[") +}