Skip to content

Commit dafd61b

Browse files
committed
fix(gateway): enforce caller-scope subsetting in device.token.rotate
device.token.rotate accepted attacker-controlled scopes and forwarded them to rotateDeviceToken without verifying the caller held those scopes. A pairing-scoped token could rotate up to operator.admin on any already-paired device whose approvedScopes included admin. Add a caller-scope subsetting check before rotateDeviceToken: the requested scopes must be a subset of client.connect.scopes via the existing roleScopesAllow helper. Reject with missing scope: <scope> if not. Also add server.device-token-rotate-authz.test.ts covering both the priv-esc path and the admin-to-node-invoke chain. Fixes GHSA-4jpw-hj22-2xmc
1 parent 04e103d commit dafd61b

File tree

3 files changed

+330
-1
lines changed

3 files changed

+330
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ Docs: https://docs.openclaw.ai
161161
- Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.
162162
- Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.
163163
- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.
164+
- Security/Gateway: block `device.token.rotate` from minting operator scopes broader than the caller session already holds, closing the critical paired-device token privilege escalation reported as GHSA-4jpw-hj22-2xmc.
164165
- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.
165166
- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.
166167
- Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau.

src/gateway/server-methods/devices.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
approveDevicePairing,
3+
getPairedDevice,
34
listDevicePairing,
45
removePairedDevice,
56
type DeviceAuthToken,
@@ -8,6 +9,8 @@ import {
89
rotateDeviceToken,
910
summarizeDeviceTokens,
1011
} from "../../infra/device-pairing.js";
12+
import { normalizeDeviceAuthScopes } from "../../shared/device-auth.js";
13+
import { roleScopesAllow } from "../../shared/operator-scope-compat.js";
1114
import {
1215
ErrorCodes,
1316
errorShape,
@@ -31,6 +34,25 @@ function redactPairedDevice(
3134
};
3235
}
3336

37+
function resolveMissingRequestedScope(params: {
38+
role: string;
39+
requestedScopes: readonly string[];
40+
callerScopes: readonly string[];
41+
}): string | null {
42+
for (const scope of params.requestedScopes) {
43+
if (
44+
!roleScopesAllow({
45+
role: params.role,
46+
requestedScopes: [scope],
47+
allowedScopes: params.callerScopes,
48+
})
49+
) {
50+
return scope;
51+
}
52+
}
53+
return null;
54+
}
55+
3456
export const deviceHandlers: GatewayRequestHandlers = {
3557
"device.pair.list": async ({ params, respond }) => {
3658
if (!validateDevicePairListParams(params)) {
@@ -146,7 +168,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
146168
context.logGateway.info(`device pairing removed device=${removed.deviceId}`);
147169
respond(true, removed, undefined);
148170
},
149-
"device.token.rotate": async ({ params, respond, context }) => {
171+
"device.token.rotate": async ({ params, respond, context, client }) => {
150172
if (!validateDeviceTokenRotateParams(params)) {
151173
respond(
152174
false,
@@ -165,6 +187,28 @@ export const deviceHandlers: GatewayRequestHandlers = {
165187
role: string;
166188
scopes?: string[];
167189
};
190+
const pairedDevice = await getPairedDevice(deviceId);
191+
if (!pairedDevice) {
192+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
193+
return;
194+
}
195+
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
196+
const requestedScopes = normalizeDeviceAuthScopes(
197+
scopes ?? pairedDevice.tokens?.[role.trim()]?.scopes ?? pairedDevice.scopes,
198+
);
199+
const missingScope = resolveMissingRequestedScope({
200+
role,
201+
requestedScopes,
202+
callerScopes,
203+
});
204+
if (missingScope) {
205+
respond(
206+
false,
207+
undefined,
208+
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${missingScope}`),
209+
);
210+
return;
211+
}
168212
const entry = await rotateDeviceToken({ deviceId, role, scopes });
169213
if (!entry) {
170214
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import os from "node:os";
2+
import path from "node:path";
3+
import { describe, expect, test } from "vitest";
4+
import { WebSocket } from "ws";
5+
import {
6+
loadOrCreateDeviceIdentity,
7+
publicKeyRawBase64UrlFromPem,
8+
type DeviceIdentity,
9+
} from "../infra/device-identity.js";
10+
import {
11+
approveDevicePairing,
12+
getPairedDevice,
13+
requestDevicePairing,
14+
rotateDeviceToken,
15+
} from "../infra/device-pairing.js";
16+
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
17+
import { GatewayClient } from "./client.js";
18+
import {
19+
connectOk,
20+
installGatewayTestHooks,
21+
rpcReq,
22+
startServerWithClient,
23+
trackConnectChallengeNonce,
24+
} from "./test-helpers.js";
25+
26+
installGatewayTestHooks({ scope: "suite" });
27+
28+
function resolveDeviceIdentityPath(name: string): string {
29+
const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
30+
return path.join(root, "test-device-identities", `${name}.json`);
31+
}
32+
33+
function loadDeviceIdentity(name: string): {
34+
identityPath: string;
35+
identity: DeviceIdentity;
36+
publicKey: string;
37+
} {
38+
const identityPath = resolveDeviceIdentityPath(name);
39+
const identity = loadOrCreateDeviceIdentity(identityPath);
40+
return {
41+
identityPath,
42+
identity,
43+
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
44+
};
45+
}
46+
47+
async function pairDevice(params: {
48+
name: string;
49+
role: "node" | "operator";
50+
scopes: string[];
51+
clientId?: string;
52+
clientMode?: string;
53+
}): Promise<{
54+
identityPath: string;
55+
identity: DeviceIdentity;
56+
}> {
57+
const loaded = loadDeviceIdentity(params.name);
58+
const request = await requestDevicePairing({
59+
deviceId: loaded.identity.deviceId,
60+
publicKey: loaded.publicKey,
61+
role: params.role,
62+
scopes: params.scopes,
63+
clientId: params.clientId,
64+
clientMode: params.clientMode,
65+
});
66+
await approveDevicePairing(request.request.requestId);
67+
return {
68+
identityPath: loaded.identityPath,
69+
identity: loaded.identity,
70+
};
71+
}
72+
73+
async function issuePairingScopedTokenForAdminApprovedDevice(name: string): Promise<{
74+
deviceId: string;
75+
identityPath: string;
76+
pairingToken: string;
77+
}> {
78+
const paired = await pairDevice({
79+
name,
80+
role: "operator",
81+
scopes: ["operator.admin"],
82+
clientId: GATEWAY_CLIENT_NAMES.TEST,
83+
clientMode: GATEWAY_CLIENT_MODES.TEST,
84+
});
85+
const rotated = await rotateDeviceToken({
86+
deviceId: paired.identity.deviceId,
87+
role: "operator",
88+
scopes: ["operator.pairing"],
89+
});
90+
expect(rotated?.token).toBeTruthy();
91+
return {
92+
deviceId: paired.identity.deviceId,
93+
identityPath: paired.identityPath,
94+
pairingToken: String(rotated?.token ?? ""),
95+
};
96+
}
97+
98+
async function openTrackedWs(port: number): Promise<WebSocket> {
99+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
100+
trackConnectChallengeNonce(ws);
101+
await new Promise<void>((resolve, reject) => {
102+
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);
103+
ws.once("open", () => {
104+
clearTimeout(timer);
105+
resolve();
106+
});
107+
ws.once("error", (error) => {
108+
clearTimeout(timer);
109+
reject(error);
110+
});
111+
});
112+
return ws;
113+
}
114+
115+
async function connectPairingScopedOperator(params: {
116+
port: number;
117+
identityPath: string;
118+
deviceToken: string;
119+
}): Promise<WebSocket> {
120+
const ws = await openTrackedWs(params.port);
121+
await connectOk(ws, {
122+
skipDefaultAuth: true,
123+
deviceToken: params.deviceToken,
124+
deviceIdentityPath: params.identityPath,
125+
scopes: ["operator.pairing"],
126+
});
127+
return ws;
128+
}
129+
130+
async function connectApprovedNode(params: {
131+
port: number;
132+
name: string;
133+
onInvoke: (payload: unknown) => void;
134+
}): Promise<GatewayClient> {
135+
const paired = await pairDevice({
136+
name: params.name,
137+
role: "node",
138+
scopes: [],
139+
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
140+
clientMode: GATEWAY_CLIENT_MODES.NODE,
141+
});
142+
143+
let readyResolve: (() => void) | null = null;
144+
const ready = new Promise<void>((resolve) => {
145+
readyResolve = resolve;
146+
});
147+
148+
const client = new GatewayClient({
149+
url: `ws://127.0.0.1:${params.port}`,
150+
connectDelayMs: 2_000,
151+
token: "secret",
152+
role: "node",
153+
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
154+
clientVersion: "1.0.0",
155+
platform: "linux",
156+
mode: GATEWAY_CLIENT_MODES.NODE,
157+
scopes: [],
158+
commands: ["system.run"],
159+
deviceIdentity: paired.identity,
160+
onHelloOk: () => readyResolve?.(),
161+
onEvent: (event) => {
162+
if (event.event !== "node.invoke.request") {
163+
return;
164+
}
165+
params.onInvoke(event.payload);
166+
const payload = event.payload as { id?: string; nodeId?: string };
167+
if (!payload.id || !payload.nodeId) {
168+
return;
169+
}
170+
void client.request("node.invoke.result", {
171+
id: payload.id,
172+
nodeId: payload.nodeId,
173+
ok: true,
174+
payloadJSON: JSON.stringify({ ok: true }),
175+
});
176+
},
177+
});
178+
client.start();
179+
await Promise.race([
180+
ready,
181+
new Promise<never>((_, reject) => {
182+
setTimeout(() => reject(new Error("timeout waiting for node hello")), 5_000);
183+
}),
184+
]);
185+
return client;
186+
}
187+
188+
async function getConnectedNodeId(ws: WebSocket): Promise<string> {
189+
const nodes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>(
190+
ws,
191+
"node.list",
192+
{},
193+
);
194+
expect(nodes.ok).toBe(true);
195+
const nodeId = nodes.payload?.nodes?.find((node) => node.connected)?.nodeId ?? "";
196+
expect(nodeId).toBeTruthy();
197+
return nodeId;
198+
}
199+
200+
async function waitForMacrotasks(): Promise<void> {
201+
await new Promise<void>((resolve) => setImmediate(resolve));
202+
await new Promise<void>((resolve) => setImmediate(resolve));
203+
}
204+
205+
describe("gateway device.token.rotate caller scope guard", () => {
206+
test("rejects rotating an admin-approved device token above the caller session scopes", async () => {
207+
const started = await startServerWithClient("secret");
208+
const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-attacker");
209+
210+
let pairingWs: WebSocket | undefined;
211+
try {
212+
pairingWs = await connectPairingScopedOperator({
213+
port: started.port,
214+
identityPath: attacker.identityPath,
215+
deviceToken: attacker.pairingToken,
216+
});
217+
218+
const rotate = await rpcReq(pairingWs, "device.token.rotate", {
219+
deviceId: attacker.deviceId,
220+
role: "operator",
221+
scopes: ["operator.admin"],
222+
});
223+
expect(rotate.ok).toBe(false);
224+
expect(rotate.error?.message).toBe("missing scope: operator.admin");
225+
226+
const paired = await getPairedDevice(attacker.deviceId);
227+
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.pairing"]);
228+
expect(paired?.approvedScopes).toEqual(["operator.admin"]);
229+
} finally {
230+
pairingWs?.close();
231+
started.ws.close();
232+
await started.server.close();
233+
started.envSnapshot.restore();
234+
}
235+
});
236+
237+
test("blocks the pairing-token to admin-node-invoke escalation chain", async () => {
238+
const started = await startServerWithClient("secret");
239+
const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-rce-attacker");
240+
241+
let sawInvoke = false;
242+
let pairingWs: WebSocket | undefined;
243+
let nodeClient: GatewayClient | undefined;
244+
245+
try {
246+
await connectOk(started.ws);
247+
nodeClient = await connectApprovedNode({
248+
port: started.port,
249+
name: "rotate-rce-node",
250+
onInvoke: () => {
251+
sawInvoke = true;
252+
},
253+
});
254+
await getConnectedNodeId(started.ws);
255+
256+
pairingWs = await connectPairingScopedOperator({
257+
port: started.port,
258+
identityPath: attacker.identityPath,
259+
deviceToken: attacker.pairingToken,
260+
});
261+
262+
const rotate = await rpcReq<{ token?: string }>(pairingWs, "device.token.rotate", {
263+
deviceId: attacker.deviceId,
264+
role: "operator",
265+
scopes: ["operator.admin"],
266+
});
267+
268+
expect(rotate.ok).toBe(false);
269+
expect(rotate.error?.message).toBe("missing scope: operator.admin");
270+
await waitForMacrotasks();
271+
expect(sawInvoke).toBe(false);
272+
273+
const paired = await getPairedDevice(attacker.deviceId);
274+
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.pairing"]);
275+
expect(paired?.tokens?.operator?.token).toBe(attacker.pairingToken);
276+
} finally {
277+
pairingWs?.close();
278+
nodeClient?.stop();
279+
started.ws.close();
280+
await started.server.close();
281+
started.envSnapshot.restore();
282+
}
283+
});
284+
});

0 commit comments

Comments
 (0)