fix(gateway): WebSocket 同源/loopback 等价放行,修复生产同源跨源 403

后端 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等效/跨源拒绝/同主机异端口。
This commit is contained in:
Aska committed 2026-09-06 13:12:22 +08:00
1 parent 13c7c52860
commit 20305631c5
2 files changed
+93 -3

No files matched your search

+38
View File
@@ -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" {
+55 -3
View File
@@ -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, "]"), "[")
}