From 20305631c59ef6c28ad11224fc6d25fa8552577a Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 13:12:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20WebSocket=20=E5=90=8C=E6=BA=90/?= =?UTF-8?q?loopback=20=E7=AD=89=E4=BB=B7=E6=94=BE=E8=A1=8C=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E4=BA=A7=E5=90=8C=E6=BA=90=E8=B7=A8?= =?UTF-8?q?=E6=BA=90=20403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 go:embed 自服务前端时,页面 origin 与网关 Host 一致(production 模式), 但不在 dev 阶段 ALLOWED_ORIGINS(5173/3000)白名单内,originChecker 记 403 导致 WS /ws/chat 握手失败。 改为:同源(origin host == request host)及 loopback 别名(localhost<->127.0.0.1) 自动放行;跨源(不同主机名)仍走白名单。安全不降级:跨源非白名单依旧拒绝。 新增 TestOriginCheckerSameHost 覆盖同源/loopback等效/跨源拒绝/同主机异端口。 --- internal/services/gateway/extra_test.go | 38 ++++++++++++++++ internal/services/gateway/ws_hub.go | 58 +++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 3 deletions(-) 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, "]"), "[") +}