From 3391c5b050dcf5dc3048100bf620b8ed63b688b2 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Fri, 8 May 2026 23:15:19 -0500 Subject: [PATCH] fix(gateway): preserve trusted-proxy Control UI scopes (#79643) * fix(gateway): preserve trusted proxy control ui scopes * docs: add trusted proxy control ui changelog --- CHANGELOG.md | 1 + src/gateway/server.auth.control-ui.suite.ts | 75 ++++++++++++++++++- .../ws-connection/connect-policy.test.ts | 2 +- .../server/ws-connection/connect-policy.ts | 4 +- .../server/ws-connection/message-handler.ts | 33 +++++++- 5 files changed, 106 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3879eb129b..e62866a41c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -442,6 +442,7 @@ Docs: https://docs.openclaw.ai - Discord: clear stale startup probe bot/application status when the async bot probe throws, not just when it returns a degraded probe result. Thanks @vincentkoc. - Discord: start the gateway monitor without waiting for the startup bot/application probe, so WSL2 hosts with a slow `/users/@me` REST path still bring the channel online while status enrichment finishes asynchronously. Fixes #77103. Thanks @Suited78. - Discord/Gateway startup: retry Discord READY waits with backoff, defer startup `sessions.list` and native approval readiness failures until sidecars recover, and preserve component-only Discord payloads when final reply scrubbing removes all text. (#77478) Thanks @NikolaFC. +- Control UI/Gateway: preserve verified trusted-proxy operator scopes for browser WebSocket sessions so nginx/Authelia deployments can load chat history, models, sessions, nodes, and logs instead of failing with missing operator.read. Fixes #78508. (#79643) Thanks @joshavant. - Webhooks/Gmail/Windows: resolve `gcloud`, `gog`, and `tailscale` PATH/PATHEXT shims before setup and watcher spawns, using the Windows-safe `.cmd` wrapper for long-lived `gog serve` processes. (#74881, fixes #54470) Thanks @Angfr95. - Infra/Windows: skip the POSIX `/tmp/openclaw` preferred path on Windows in `resolvePreferredOpenClawTmpDir` so log files, TTS temp files, and other writes land in `%TEMP%\openclaw-` instead of `C:\tmp\openclaw`. Fixes #60713. Thanks @juan-flores077. - Media/Windows: open saved attachment temp files read/write before fsync so Windows WebChat and `chat.send` media offloads no longer fail with EPERM during durability flush. (#76593) Thanks @qq230849622-a11y. diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 2420e978ce7..4e89a9ec9d2 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -315,7 +315,7 @@ export function registerControlUiAndPairingSuite(): void { }); }); - test("clamps trusted-proxy control ui scopes for unpaired device identity", async () => { + test("preserves trusted-proxy control ui scopes for unpaired device identity", async () => { const { replaceConfigFile } = await import("../config/config.js"); testState.gatewayAuth = undefined; testState.gatewayControlUi = { @@ -348,14 +348,14 @@ export function registerControlUiAndPairingSuite(): void { const { device } = await createSignedDevice({ token: null, role: "operator", - scopes: ["operator.admin"], + scopes: ["operator.admin", "operator.read"], clientId: CONTROL_UI_CLIENT.id, clientMode: CONTROL_UI_CLIENT.mode, nonce: challengeNonce, }); const res = await connectReq(ws, { skipDefaultAuth: true, - scopes: ["operator.admin"], + scopes: ["operator.admin", "operator.read"], device, client: { ...CONTROL_UI_CLIENT }, }); @@ -365,12 +365,79 @@ export function registerControlUiAndPairingSuite(): void { auth?: { scopes?: string[]; deviceToken?: string }; } | undefined; - expect(payload?.auth?.scopes).toEqual([]); + expect(payload?.auth?.scopes).toEqual(["operator.admin", "operator.read"]); + expect(payload?.auth?.deviceToken).toBeUndefined(); + + const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(admin.ok).toBe(true); + } finally { + ws.close(); + } + }); + }); + + test("bounds trusted-proxy control ui scopes to proxy-declared scope header", async () => { + const { replaceConfigFile } = await import("../config/config.js"); + testState.gatewayAuth = undefined; + testState.gatewayControlUi = { + ...testState.gatewayControlUi, + allowedOrigins: ["https://localhost"], + }; + await replaceConfigFile({ + nextConfig: { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + allowLoopback: true, + }, + }, + trustedProxies: ["127.0.0.1"], + controlUi: { + allowedOrigins: ["https://localhost"], + }, + }, + }, + afterWrite: { mode: "auto" }, + }); + await withControlUiGatewayServer(async ({ port }) => { + const ws = await openWs(port, { + ...TRUSTED_PROXY_CONTROL_UI_HEADERS, + "x-openclaw-scopes": "operator.read", + }); + try { + const challengeNonce = await readConnectChallengeNonce(ws); + const { device } = await createSignedDevice({ + token: null, + role: "operator", + scopes: ["operator.admin", "operator.read"], + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + nonce: challengeNonce, + }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + scopes: ["operator.admin", "operator.read"], + device, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(true); + const payload = res.payload as + | { + auth?: { scopes?: string[]; deviceToken?: string }; + } + | undefined; + expect(payload?.auth?.scopes).toEqual(["operator.read"]); expect(payload?.auth?.deviceToken).toBeUndefined(); const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); expect(admin.ok).toBe(false); expect(admin.error?.message ?? "").toContain("missing scope"); + + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); } finally { ws.close(); } diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index fb3a2def5e2..816955491fa 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -420,7 +420,7 @@ describe("ws connect policy", () => { authMethod: undefined, trustedProxyAuthOk: true, }), - ).toBe(true); + ).toBe(false); expect( shouldClearUnboundScopesForMissingDeviceIdentity({ diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index dfb06ab0ece..ec22be4df75 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -96,12 +96,12 @@ export function shouldClearUnboundScopesForMissingDeviceIdentity(params: { params.decision.kind !== "allow" || (!params.controlUiAuthPolicy.allowBypass && !params.preserveInsecureLocalControlUiScopes && + params.trustedProxyAuthOk !== true && // trusted-proxy auth can bypass pairing for some clients, but those // self-declared scopes are still unbound without device identity. (params.authMethod === "token" || params.authMethod === "password" || - params.authMethod === "trusted-proxy" || - params.trustedProxyAuthOk === true)) + params.authMethod === "trusted-proxy")) ); } diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index ca3bcb3fbfc..be6147187a9 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -149,6 +149,30 @@ export type WsOriginCheckMetrics = { hostHeaderFallbackAccepted: number; }; +function firstHeaderValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +function resolveTrustedProxyControlUiScopes(params: { + requestedScopes: string[]; + upgradeReq: IncomingMessage; +}): string[] { + const rawHeader = firstHeaderValue(params.upgradeReq.headers["x-openclaw-scopes"]); + if (rawHeader === undefined) { + return params.requestedScopes; + } + const declaredScopes = new Set( + rawHeader + .split(",") + .map((scope) => scope.trim()) + .filter((scope) => scope.length > 0), + ); + if (declaredScopes.size === 0) { + return []; + } + return params.requestedScopes.filter((scope) => declaredScopes.has(scope)); +} + function resolvePinnedClientMetadata(params: { claimedPlatform?: string; claimedDeviceFamily?: string; @@ -875,6 +899,13 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar authOk, authMethod, }); + if (trustedProxyAuthOk) { + scopes = resolveTrustedProxyControlUiScopes({ + requestedScopes: scopes, + upgradeReq, + }); + connectParams.scopes = scopes; + } const skipControlUiPairingForDevice = shouldSkipControlUiPairing( controlUiAuthPolicy, role, @@ -1143,8 +1174,6 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar return; } hasServerApprovedDeviceTokenBaseline = true; - } else if (trustedProxyAuthOk) { - clearUnboundScopes(); } else if ( skipControlUiPairingForDevice || (skipLocalBackendSelfPairing && authMethod !== "device-token")