fix(ws): prefer X-Forwarded-Host in Origin check — root cause #240/#249

handleNamespaceGatewayRequest rewrites r.Host to the backend target
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. checkWSOrigin in both pubsub/ws_client.go and
serverless/ws_handler.go was comparing the client's Origin against
the proxied r.Host only — so every browser / RN-iOS WS upgrade was
rejected 403 because their Origin's public hostname can never match
10.0.0.6.

curl probes don't send Origin, so curl returned true unconditionally
and the bug was invisible to operator smoke tests. AnChat's iPhone
WS clients hit `code=1006 reason="Received bad response code from
server: 403"` for ~24h.

Fix: prefer X-Forwarded-Host (the original public host) when present,
fall back to r.Host for direct (non-proxied) connections. Applied
identically to both WS handlers. Regression test in
serverless/ws_origin_test.go covers the proxy-hop case, no-Origin
case, and direct-connection case.

This is the real fix; v0.122.19 only closed a separate silent-forward
auth hole that produced opaque 401s on a different code path.

VERSION bumped to 0.122.20.
This commit is contained in:
anonpenguin23 2026-05-15 07:03:28 +03:00
parent 872c553d1c
commit a0a1decd06
5 changed files with 130 additions and 4 deletions

View File

@ -1 +1 @@
0.122.19
0.122.20

View File

@ -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
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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",