From 5e389d5e7c9233ec91026ab2fea299ebaf3249f6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 12 Mar 2026 14:52:24 -0400 Subject: [PATCH] Gateway/ws: clear unbound scopes for shared-token auth (#44306) * Gateway/ws: clear unbound shared-auth scopes * Gateway/auth: cover shared-token scope stripping * Changelog: add shared-token scope stripping entry * Gateway/ws: preserve allowed control-ui scopes * Gateway/auth: assert control-ui admin scopes survive allowed device-less auth * Gateway/auth: cover shared-password scope stripping --- CHANGELOG.md | 1 + .../server.auth.compat-baseline.test.ts | 37 +++++++++++++++++++ src/gateway/server.auth.control-ui.suite.ts | 9 +++++ .../server/ws-connection/message-handler.ts | 8 ++-- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4590f8c08..bb897b043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible `\u{...}` escapes instead of spoofing the reviewed command. (`GHSA-pcqg-f7rg-xfvv`)(#43687) Thanks @EkiXu and @vincentkoc. - Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (`GHSA-9r3v-37xh-2cf6`)(#44091) Thanks @wooluo and @vincentkoc. - Security/exec allowlist: preserve POSIX case sensitivity and keep `?` within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (`GHSA-f8r2-vg7x-gh8m`)(#43798) Thanks @zpbrent and @vincentkoc. +- Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (`GHSA-rqpp-rjj8-7wv8`)(#44306) Thanks @LUOYEcode and @vincentkoc. - Security/browser.request: block persistent browser profile create/delete routes from write-scoped `browser.request` so callers can no longer persist admin-only browser profile changes through the browser control surface. (`GHSA-vmhq-cqm9-6p7q`)(#43800) Thanks @tdjackey and @vincentkoc. - Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external `agent` callers can no longer override the gateway workspace boundary. (`GHSA-2rqg-gjgv-84jm`)(#43801) Thanks @tdjackey and @vincentkoc. - Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`. (`GHSA-wcxr-59v9-rxr8`)(#43754) Thanks @tdjackey and @vincentkoc. diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index d63b62b8b..8c6ea0697 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -6,6 +6,7 @@ import { getFreePort, openWs, originForPort, + rpcReq, restoreGatewayToken, startGatewayServer, testState, @@ -62,6 +63,24 @@ describe("gateway auth compatibility baseline", () => { } }); + test("clears client-declared scopes for shared-token operator connects", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + token: "secret", + scopes: ["operator.admin"], + device: null, + }); + expect(res.ok).toBe(true); + + const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(adminRes.ok).toBe(false); + expect(adminRes.error?.message).toBe("missing scope: operator.admin"); + } finally { + ws.close(); + } + }); + test("returns stable token-missing details for control ui without token", async () => { const ws = await openWs(port, { origin: originForPort(port) }); try { @@ -163,6 +182,24 @@ describe("gateway auth compatibility baseline", () => { ws.close(); } }); + + test("clears client-declared scopes for shared-password operator connects", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + password: "secret", + scopes: ["operator.admin"], + device: null, + }); + expect(res.ok).toBe(true); + + const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(adminRes.ok).toBe(false); + expect(adminRes.error?.message).toBe("missing scope: operator.admin"); + } finally { + ws.close(); + } + }); }); describe("none mode", () => { diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 12698faf3..44863f61f 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -91,6 +91,11 @@ export function registerControlUiAndPairingSuite(): void { expect(health.ok).toBe(true); }; + const expectAdminRpcOk = async (ws: WebSocket) => { + const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(admin.ok).toBe(true); + }; + const connectControlUiWithoutDeviceAndExpectOk = async (params: { ws: WebSocket; token?: string; @@ -104,6 +109,7 @@ export function registerControlUiAndPairingSuite(): void { }); expect(res.ok).toBe(true); await expectStatusAndHealthOk(params.ws); + await expectAdminRpcOk(params.ws); }; const createOperatorIdentityFixture = async (identityPrefix: string) => { @@ -217,6 +223,9 @@ export function registerControlUiAndPairingSuite(): void { } if (tc.expectStatusChecks) { await expectStatusAndHealthOk(ws); + if (tc.role === "operator") { + await expectAdminRpcOk(ws); + } } ws.close(); }); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 7cd7e6450..0c71ee9df 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -643,15 +643,12 @@ export function attachGatewayWsMessageHandler(params: { close(1008, truncateCloseReason(authMessage)); }; const clearUnboundScopes = () => { - if (scopes.length > 0 && !controlUiAuthPolicy.allowBypass && !sharedAuthOk) { + if (scopes.length > 0) { scopes = []; connectParams.scopes = scopes; } }; const handleMissingDeviceIdentity = (): boolean => { - if (!device) { - clearUnboundScopes(); - } const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({ isControlUi, role, @@ -670,6 +667,9 @@ export function attachGatewayWsMessageHandler(params: { hasSharedAuth, isLocalClient, }); + if (!device && (!isControlUi || decision.kind !== "allow")) { + clearUnboundScopes(); + } if (decision.kind === "allow") { return true; }