From ebed3bbde1a72a1aaa9b87b63b91e7c04a50036b Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Thu, 12 Mar 2026 01:15:59 +0100 Subject: [PATCH] fix(gateway): enforce browser origin check regardless of proxy headers In trusted-proxy mode, enforceOriginCheckForAnyClient was set to false whenever proxy headers were present. This allowed browser-originated WebSocket connections from untrusted origins to bypass origin validation entirely, as the check only ran for control-ui and webchat client types. An attacker serving a page from an untrusted origin could connect through a trusted reverse proxy, inherit proxy-injected identity, and obtain operator.admin access via the sharedAuthOk / roleCanSkipDeviceIdentity path without any origin restriction. Remove the hasProxyHeaders exemption so origin validation runs for all browser-originated connections regardless of how the request arrived. Fixes GHSA-5wcw-8jjv-m286 --- CHANGELOG.md | 4 + .../server.auth.browser-hardening.test.ts | 123 ++++++++++++++++++ .../server/ws-connection/message-handler.ts | 2 +- 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb573d52a1..58ff8ba7495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai ## Unreleased +### Security + +- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286) + ### Changes - Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky. diff --git a/src/gateway/server.auth.browser-hardening.test.ts b/src/gateway/server.auth.browser-hardening.test.ts index e9550a8b1aa..c4060716bd4 100644 --- a/src/gateway/server.auth.browser-hardening.test.ts +++ b/src/gateway/server.auth.browser-hardening.test.ts @@ -12,6 +12,7 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-cha import { buildDeviceAuthPayload } from "./device-auth.js"; import { connectReq, + connectOk, installGatewayTestHooks, readConnectChallengeNonce, testState, @@ -27,6 +28,7 @@ const TEST_OPERATOR_CLIENT = { platform: "test", mode: GATEWAY_CLIENT_MODES.TEST, }; +const ALLOWED_BROWSER_ORIGIN = "https://control.example.com"; const originForPort = (port: number) => `http://127.0.0.1:${port}`; @@ -73,6 +75,127 @@ async function createSignedDevice(params: { } describe("gateway auth browser hardening", () => { + test("rejects trusted-proxy browser connects from origins outside the allowlist", async () => { + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + }, + }, + trustedProxies: ["127.0.0.1"], + controlUi: { + allowedOrigins: [ALLOWED_BROWSER_ORIGIN], + }, + }, + }); + + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { + origin: "https://evil.example", + "x-forwarded-for": "203.0.113.50", + "x-forwarded-proto": "https", + "x-forwarded-user": "operator@example.com", + }); + try { + const res = await connectReq(ws, { + client: TEST_OPERATOR_CLIENT, + device: null, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("origin not allowed"); + } finally { + ws.close(); + } + }); + }); + + test("accepts trusted-proxy browser connects from allowed origins", async () => { + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + }, + }, + trustedProxies: ["127.0.0.1"], + controlUi: { + allowedOrigins: [ALLOWED_BROWSER_ORIGIN], + }, + }, + }); + + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { + origin: ALLOWED_BROWSER_ORIGIN, + "x-forwarded-for": "203.0.113.50", + "x-forwarded-proto": "https", + "x-forwarded-user": "operator@example.com", + }); + try { + const payload = await connectOk(ws, { + client: TEST_OPERATOR_CLIENT, + device: null, + }); + expect(payload.type).toBe("hello-ok"); + } finally { + ws.close(); + } + }); + }); + + test.each([ + { + name: "rejects disallowed origins", + origin: "https://evil.example", + ok: false, + expectedMessage: "origin not allowed", + }, + { + name: "accepts allowed origins", + origin: ALLOWED_BROWSER_ORIGIN, + ok: true, + }, + ])( + "keeps non-proxy browser-origin behavior unchanged: $name", + async ({ origin, ok, expectedMessage }) => { + const { writeConfigFile } = await import("../config/config.js"); + testState.gatewayAuth = { mode: "token", token: "secret" }; + await writeConfigFile({ + gateway: { + controlUi: { + allowedOrigins: [ALLOWED_BROWSER_ORIGIN], + }, + }, + }); + + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { origin }); + try { + const res = await connectReq(ws, { + token: "secret", + client: TEST_OPERATOR_CLIENT, + device: null, + }); + expect(res.ok).toBe(ok); + if (ok) { + expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok"); + } else { + expect(res.error?.message ?? "").toContain(expectedMessage ?? ""); + } + } finally { + ws.close(); + } + }); + }, + ); + test("rejects non-local browser origins for non-control-ui clients", async () => { testState.gatewayAuth = { mode: "token", token: "secret" }; await withGatewayServer(async ({ port }) => { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 83d1b5f12a3..0897b51e937 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -114,7 +114,7 @@ function resolveHandshakeBrowserSecurityContext(params: { ); return { hasBrowserOriginHeader, - enforceOriginCheckForAnyClient: hasBrowserOriginHeader && !params.hasProxyHeaders, + enforceOriginCheckForAnyClient: hasBrowserOriginHeader, rateLimitClientIp: hasBrowserOriginHeader && isLoopbackAddress(params.clientIp) ? BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP