fix(gateway): clear trusted-proxy control ui scopes

This commit is contained in:
Peter Steinberger
2026-03-17 10:07:42 -07:00
parent 6d9bf6de93
commit ccf16cd889
3 changed files with 47 additions and 13 deletions

View File

@@ -104,6 +104,7 @@ Docs: https://docs.openclaw.ai
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.

View File

@@ -36,14 +36,12 @@ export function registerControlUiAndPairingSuite(): void {
expectedOk: boolean;
expectedErrorSubstring?: string;
expectedErrorCode?: string;
expectStatusChecks: boolean;
}> = [
{
name: "allows trusted-proxy control ui operator without device identity",
role: "operator",
withUnpairedNodeDevice: false,
expectedOk: true,
expectStatusChecks: true,
},
{
name: "rejects trusted-proxy control ui node role without device identity",
@@ -52,7 +50,6 @@ export function registerControlUiAndPairingSuite(): void {
expectedOk: false,
expectedErrorSubstring: "control ui requires device identity",
expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
expectStatusChecks: false,
},
{
name: "requires pairing for trusted-proxy control ui node role with unpaired device",
@@ -61,7 +58,6 @@ export function registerControlUiAndPairingSuite(): void {
expectedOk: false,
expectedErrorSubstring: "pairing required",
expectedErrorCode: ConnectErrorDetailCodes.PAIRING_REQUIRED,
expectStatusChecks: false,
},
];
@@ -96,6 +92,26 @@ export function registerControlUiAndPairingSuite(): void {
expect(admin.ok).toBe(true);
};
const expectStatusMissingScopeButHealthOk = async (ws: WebSocket) => {
const status = await rpcReq(ws, "status");
expect(status.ok).toBe(false);
expect(status.error?.message ?? "").toContain("missing scope");
const health = await rpcReq(ws, "health");
expect(health.ok).toBe(true);
};
const expectAdminRpcDenied = async (ws: WebSocket) => {
const admin = await rpcReq(ws, "set-heartbeats", { enabled: false });
expect(admin.ok).toBe(false);
expect(admin.error?.message).toBe("missing scope: operator.admin");
};
const expectTalkSecretsDenied = async (ws: WebSocket) => {
const talk = await rpcReq(ws, "talk.config", { includeSecrets: true });
expect(talk.ok).toBe(false);
expect(talk.error?.message).toBe("missing scope: operator.read");
};
const connectControlUiWithoutDeviceAndExpectOk = async (params: {
ws: WebSocket;
token?: string;
@@ -221,17 +237,34 @@ export function registerControlUiAndPairingSuite(): void {
ws.close();
return;
}
if (tc.expectStatusChecks) {
await expectStatusAndHealthOk(ws);
if (tc.role === "operator") {
await expectAdminRpcOk(ws);
}
}
ws.close();
});
});
}
test("clears self-declared scopes for trusted-proxy control ui without device identity", async () => {
await configureTrustedProxyControlUiAuth();
await withGatewayServer(async ({ port }) => {
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
try {
const res = await connectReq(ws, {
skipDefaultAuth: true,
scopes: ["operator.admin"],
device: null,
client: { ...CONTROL_UI_CLIENT },
});
expect(res.ok).toBe(true);
expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined();
await expectStatusMissingScopeButHealthOk(ws);
await expectAdminRpcDenied(ws);
await expectTalkSecretsDenied(ws);
} finally {
ws.close();
}
});
});
test("allows localhost control ui without device identity when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };
const { server, ws, prevToken } = await startServerWithClient("secret", {

View File

@@ -532,9 +532,9 @@ export function attachGatewayWsMessageHandler(params: {
isLocalClient,
});
// Shared token/password auth can bypass pairing for trusted operators, but
// device-less backend clients must not self-declare scopes. Control UI
// keeps its explicitly allowed device-less scopes on the allow path.
if (!device && (!isControlUi || decision.kind !== "allow")) {
// device-less clients must not keep self-declared scopes unless the
// operator explicitly chose a local break-glass Control UI mode.
if (!device && (!isControlUi || decision.kind !== "allow" || trustedProxyAuthOk)) {
clearUnboundScopes();
}
if (decision.kind === "allow") {