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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user