mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-06-16 22:54:12 +00:00
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:
parent
872c553d1c
commit
a0a1decd06
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
96
core/pkg/gateway/handlers/serverless/ws_origin_test.go
Normal file
96
core/pkg/gateway/handlers/serverless/ws_origin_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user