Skip to content

Commit ccf16cd

Browse files
committed
fix(gateway): clear trusted-proxy control ui scopes
1 parent 6d9bf6d commit ccf16cd

File tree

3 files changed

+47
-13
lines changed

3 files changed

+47
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ Docs: https://docs.openclaw.ai
104104
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
105105
- Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path.
106106
- Docs/security audit: spell out that `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy and should be avoided outside tightly controlled local testing.
107+
- Gateway/auth: clear self-declared scopes for device-less trusted-proxy Control UI sessions so proxy-authenticated connects cannot claim admin or secrets scopes without a bound device identity.
107108
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
108109
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46515) Fixes #46411. Thanks @ademczuk.
109110
- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc.

src/gateway/server.auth.control-ui.suite.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,12 @@ export function registerControlUiAndPairingSuite(): void {
3636
expectedOk: boolean;
3737
expectedErrorSubstring?: string;
3838
expectedErrorCode?: string;
39-
expectStatusChecks: boolean;
4039
}> = [
4140
{
4241
name: "allows trusted-proxy control ui operator without device identity",
4342
role: "operator",
4443
withUnpairedNodeDevice: false,
4544
expectedOk: true,
46-
expectStatusChecks: true,
4745
},
4846
{
4947
name: "rejects trusted-proxy control ui node role without device identity",
@@ -52,7 +50,6 @@ export function registerControlUiAndPairingSuite(): void {
5250
expectedOk: false,
5351
expectedErrorSubstring: "control ui requires device identity",
5452
expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
55-
expectStatusChecks: false,
5653
},
5754
{
5855
name: "requires pairing for trusted-proxy control ui node role with unpaired device",
@@ -61,7 +58,6 @@ export function registerControlUiAndPairingSuite(): void {
6158
expectedOk: false,
6259
expectedErrorSubstring: "pairing required",
6360
expectedErrorCode: ConnectErrorDetailCodes.PAIRING_REQUIRED,
64-
expectStatusChecks: false,
6561
},
6662
];
6763

@@ -96,6 +92,26 @@ export function registerControlUiAndPairingSuite(): void {
9692
expect(admin.ok).toBe(true);
9793
};
9894

95+
const expectStatusMissingScopeButHealthOk = async (ws: WebSocket) => {
96+
const status = await rpcReq(ws, "status");
97+
expect(status.ok).toBe(false);
98+
expect(status.error?.message ?? "").toContain("missing scope");
99+
const health = await rpcReq(ws, "health");
100+
expect(health.ok).toBe(true);
101+
};
102+
103+
const expectAdminRpcDenied = async (ws: WebSocket) => {
104+
const admin = await rpcReq(ws, "set-heartbeats", { enabled: false });
105+
expect(admin.ok).toBe(false);
106+
expect(admin.error?.message).toBe("missing scope: operator.admin");
107+
};
108+
109+
const expectTalkSecretsDenied = async (ws: WebSocket) => {
110+
const talk = await rpcReq(ws, "talk.config", { includeSecrets: true });
111+
expect(talk.ok).toBe(false);
112+
expect(talk.error?.message).toBe("missing scope: operator.read");
113+
};
114+
99115
const connectControlUiWithoutDeviceAndExpectOk = async (params: {
100116
ws: WebSocket;
101117
token?: string;
@@ -221,17 +237,34 @@ export function registerControlUiAndPairingSuite(): void {
221237
ws.close();
222238
return;
223239
}
224-
if (tc.expectStatusChecks) {
225-
await expectStatusAndHealthOk(ws);
226-
if (tc.role === "operator") {
227-
await expectAdminRpcOk(ws);
228-
}
229-
}
230240
ws.close();
231241
});
232242
});
233243
}
234244

245+
test("clears self-declared scopes for trusted-proxy control ui without device identity", async () => {
246+
await configureTrustedProxyControlUiAuth();
247+
await withGatewayServer(async ({ port }) => {
248+
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
249+
try {
250+
const res = await connectReq(ws, {
251+
skipDefaultAuth: true,
252+
scopes: ["operator.admin"],
253+
device: null,
254+
client: { ...CONTROL_UI_CLIENT },
255+
});
256+
expect(res.ok).toBe(true);
257+
expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined();
258+
259+
await expectStatusMissingScopeButHealthOk(ws);
260+
await expectAdminRpcDenied(ws);
261+
await expectTalkSecretsDenied(ws);
262+
} finally {
263+
ws.close();
264+
}
265+
});
266+
});
267+
235268
test("allows localhost control ui without device identity when insecure auth is enabled", async () => {
236269
testState.gatewayControlUi = { allowInsecureAuth: true };
237270
const { server, ws, prevToken } = await startServerWithClient("secret", {

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -532,9 +532,9 @@ export function attachGatewayWsMessageHandler(params: {
532532
isLocalClient,
533533
});
534534
// Shared token/password auth can bypass pairing for trusted operators, but
535-
// device-less backend clients must not self-declare scopes. Control UI
536-
// keeps its explicitly allowed device-less scopes on the allow path.
537-
if (!device && (!isControlUi || decision.kind !== "allow")) {
535+
// device-less clients must not keep self-declared scopes unless the
536+
// operator explicitly chose a local break-glass Control UI mode.
537+
if (!device && (!isControlUi || decision.kind !== "allow" || trustedProxyAuthOk)) {
538538
clearUnboundScopes();
539539
}
540540
if (decision.kind === "allow") {

0 commit comments

Comments
 (0)