diff --git a/VERSION b/VERSION index 7b0e8b7..9dd1529 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.122.19 +0.122.20 diff --git a/core/pkg/gateway/handlers/pubsub/ws_client.go b/core/pkg/gateway/handlers/pubsub/ws_client.go index 6101ffd..48900cd 100644 --- a/core/pkg/gateway/handlers/pubsub/ws_client.go +++ b/core/pkg/gateway/handlers/pubsub/ws_client.go @@ -21,12 +21,25 @@ var wsUpgrader = websocket.Upgrader{ // checkWSOrigin validates WebSocket origins against the request's Host header. // Non-browser clients (no Origin) are allowed. Browser clients must match the host. +// +// Bug #240/#249: when running on a NAMESPACE gateway, the request has been +// proxied via `handleNamespaceGatewayRequest` which rewrites r.Host to the +// backend target IP. The original public host is preserved in +// X-Forwarded-Host. Without this fix, RN-iOS / browser clients (which always +// send Origin) are rejected 403 because their Origin's public hostname will +// never match the proxied IP. Curl tests without Origin slip through, +// masking the bug. See namespace gateway log: +// E routes WebSocket upgrade failed +// {"error": "websocket: request origin not allowed by Upgrader.CheckOrigin"} func checkWSOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return true } - host := r.Host + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } if host == "" { return false } diff --git a/core/pkg/gateway/handlers/serverless/ws_handler.go b/core/pkg/gateway/handlers/serverless/ws_handler.go index c0bc668..8986bb6 100644 --- a/core/pkg/gateway/handlers/serverless/ws_handler.go +++ b/core/pkg/gateway/handlers/serverless/ws_handler.go @@ -16,12 +16,29 @@ import ( // checkWSOrigin validates WebSocket origins against the request's Host header. // Non-browser clients (no Origin) are allowed. Browser clients must match the host. +// +// Bug #240/#249 root cause: when this handler runs on a NAMESPACE gateway, +// the request has been proxied through `handleNamespaceGatewayRequest` +// which REWRITES `r.Host` to the backend target's IP:port (e.g. +// "10.0.0.6:10004") before forwarding. The original public host (e.g. +// "ns-anchat-test.orama-devnet.network") is preserved in the +// `X-Forwarded-Host` header. If we only compare the Origin against +// `r.Host`, browser/RN-iOS clients (which always send Origin) are +// rejected with 403 because their Origin's `ns-anchat-test.orama-devnet.network` +// will never match the proxied `10.0.0.6` target. Curl tests that don't +// send Origin slip through, masking the bug. +// +// Prefer X-Forwarded-Host (the original public host) when present, +// falling back to r.Host for direct (non-proxied) connections. func checkWSOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return true } - host := r.Host + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } if host == "" { return false } diff --git a/core/pkg/gateway/handlers/serverless/ws_origin_test.go b/core/pkg/gateway/handlers/serverless/ws_origin_test.go new file mode 100644 index 0000000..71f1edd --- /dev/null +++ b/core/pkg/gateway/handlers/serverless/ws_origin_test.go @@ -0,0 +1,96 @@ +package serverless + +import ( + "net/http/httptest" + "testing" +) + +// TestCheckWSOrigin_ProxyHopRewritesHost is the regression guard for bugs +// #240 / #249. The namespace-gateway proxy hop in +// pkg/gateway/middleware.go::handleNamespaceGatewayRequest REWRITES r.Host +// to the backend target's IP:port (e.g. "10.0.0.6:10004") before +// forwarding. The original public host (e.g. +// "ns-anchat-test.orama-devnet.network") is preserved in +// X-Forwarded-Host. If checkWSOrigin only consults r.Host, every +// browser / RN-iOS WebSocket upgrade is rejected 403 because the +// client's Origin (`https://ns-anchat-test.orama-devnet.network`) will +// never match the proxied `10.0.0.6` r.Host. +// +// AnChat hit this for ~24h with their iPhone WS retests producing +// `code=1006 reason="Received bad response code from server: 403"`, +// while curl probes succeeded because curl doesn't send Origin and so +// the check returns true unconditionally — masking the bug. +// +// Fix: prefer X-Forwarded-Host when present. +func TestCheckWSOrigin_ProxyHopRewritesHost(t *testing.T) { + r := httptest.NewRequest("GET", "/v1/functions/rpc-router/ws", nil) + // Simulate what the namespace gateway sees AFTER the proxy hop in + // handleNamespaceGatewayRequest: r.Host has been overwritten to the + // backend IP, but X-Forwarded-Host carries the original public host. + r.Host = "10.0.0.6:10004" + r.Header.Set("X-Forwarded-Host", "ns-anchat-test.orama-devnet.network") + r.Header.Set("Origin", "https://ns-anchat-test.orama-devnet.network") + + if !checkWSOrigin(r) { + t.Fatal("checkWSOrigin must accept Origin matching X-Forwarded-Host (proxy-hop scenario); rejecting will reproduce bugs #240/#249 — every iOS / browser WS client gets 403") + } +} + +// TestCheckWSOrigin_NoOriginAllowed confirms the historical curl-friendly +// path still works. Non-browser clients (curl, native libs without Origin) +// pass through unconditionally. +func TestCheckWSOrigin_NoOriginAllowed(t *testing.T) { + r := httptest.NewRequest("GET", "/v1/functions/rpc-router/ws", nil) + r.Host = "10.0.0.6:10004" + if !checkWSOrigin(r) { + t.Fatal("requests without Origin must always be allowed (curl, native CLIs)") + } +} + +// TestCheckWSOrigin_DirectMatch covers the non-proxied case (direct +// connection to the gateway, no X-Forwarded-Host). r.Host IS the public +// host in that scenario. +func TestCheckWSOrigin_DirectMatch(t *testing.T) { + r := httptest.NewRequest("GET", "/v1/functions/rpc-router/ws", nil) + r.Host = "ns-anchat-test.orama-devnet.network" + r.Header.Set("Origin", "https://ns-anchat-test.orama-devnet.network") + if !checkWSOrigin(r) { + t.Fatal("direct-connection Origin == r.Host must be allowed") + } +} + +// TestCheckWSOrigin_SubdomainMatch covers the documented "subdomain of +// host" allowance (HasSuffix("." + host)). +func TestCheckWSOrigin_SubdomainMatch(t *testing.T) { + r := httptest.NewRequest("GET", "/v1/functions/rpc-router/ws", nil) + r.Header.Set("X-Forwarded-Host", "orama-devnet.network") + r.Header.Set("Origin", "https://app.orama-devnet.network") + if !checkWSOrigin(r) { + t.Fatal("subdomain of X-Forwarded-Host must be allowed") + } +} + +// TestCheckWSOrigin_CrossDomainRejected is the negative case — a request +// from a totally unrelated origin should still be rejected even after +// the X-Forwarded-Host fix. Defense-in-depth against CSRF. +func TestCheckWSOrigin_CrossDomainRejected(t *testing.T) { + r := httptest.NewRequest("GET", "/v1/functions/rpc-router/ws", nil) + r.Host = "10.0.0.6:10004" + r.Header.Set("X-Forwarded-Host", "ns-anchat-test.orama-devnet.network") + r.Header.Set("Origin", "https://evil.example.com") + if checkWSOrigin(r) { + t.Fatal("cross-origin request must be rejected; this is the CSRF guard") + } +} + +// TestCheckWSOrigin_NoHostAndNoForwardedHostRejected — defensive: if both +// r.Host and X-Forwarded-Host are empty, the check has no comparison +// target and should reject (the historical behavior). +func TestCheckWSOrigin_NoHostAndNoForwardedHostRejected(t *testing.T) { + r := httptest.NewRequest("GET", "/v1/functions/rpc-router/ws", nil) + r.Host = "" + r.Header.Set("Origin", "https://anywhere.example.com") + if checkWSOrigin(r) { + t.Fatal("missing both r.Host and X-Forwarded-Host must reject — no comparison target") + } +} diff --git a/sdk/package.json b/sdk/package.json index 542b0a1..87745d2 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@debros/orama", - "version": "0.122.19", + "version": "0.122.20", "description": "TypeScript SDK for Orama Network - Database, PubSub, Cache, Storage, Vault, and more", "type": "module", "main": "./dist/index.js",