Skip to content

Commit 7cda9df

Browse files
eleqtrizitsteipete
authored andcommitted
fix(device): reject unapproved token roles
1 parent d58b4d7 commit 7cda9df

4 files changed

Lines changed: 112 additions & 3 deletions

File tree

src/gateway/server-methods/devices.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
approveDevicePairing,
33
getPairedDevice,
4+
listApprovedPairedDeviceRoles,
45
listDevicePairing,
56
removePairedDevice,
67
type DeviceAuthToken,
@@ -196,6 +197,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
196197
role: string;
197198
scopes?: string[];
198199
};
200+
const normalizedRole = role.trim();
199201
const pairedDevice = await getPairedDevice(deviceId);
200202
if (!pairedDevice) {
201203
logDeviceTokenRotationDenied({
@@ -211,9 +213,23 @@ export const deviceHandlers: GatewayRequestHandlers = {
211213
);
212214
return;
213215
}
216+
if (!listApprovedPairedDeviceRoles(pairedDevice).includes(normalizedRole)) {
217+
logDeviceTokenRotationDenied({
218+
log: context.logGateway,
219+
deviceId,
220+
role,
221+
reason: "unknown-device-or-role",
222+
});
223+
respond(
224+
false,
225+
undefined,
226+
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
227+
);
228+
return;
229+
}
214230
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
215231
const requestedScopes = normalizeDeviceAuthScopes(
216-
scopes ?? pairedDevice.tokens?.[role.trim()]?.scopes ?? pairedDevice.scopes,
232+
scopes ?? pairedDevice.tokens?.[normalizedRole]?.scopes ?? pairedDevice.scopes,
217233
);
218234
const missingScope = resolveMissingRequestedScope({
219235
role,

src/gateway/server.device-token-rotate-authz.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,41 @@ describe("gateway device.token.rotate caller scope guard", () => {
239239
started.envSnapshot.restore();
240240
}
241241
});
242+
243+
test("rejects rotating a token for an unapproved role on an existing paired device", async () => {
244+
const started = await startServerWithClient("secret");
245+
const attacker = await issueOperatorToken({
246+
name: "rotate-unapproved-role",
247+
approvedScopes: ["operator.pairing"],
248+
tokenScopes: ["operator.pairing"],
249+
clientId: GATEWAY_CLIENT_NAMES.TEST,
250+
clientMode: GATEWAY_CLIENT_MODES.TEST,
251+
});
252+
253+
let pairingWs: WebSocket | undefined;
254+
try {
255+
pairingWs = await connectPairingScopedOperator({
256+
port: started.port,
257+
identityPath: attacker.identityPath,
258+
deviceToken: attacker.token,
259+
});
260+
261+
const rotate = await rpcReq(pairingWs, "device.token.rotate", {
262+
deviceId: attacker.deviceId,
263+
role: "node",
264+
});
265+
266+
expect(rotate.ok).toBe(false);
267+
expect(rotate.error?.message).toBe("device token rotation denied");
268+
269+
const paired = await getPairedDevice(attacker.deviceId);
270+
expect(paired?.tokens?.node).toBeUndefined();
271+
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.pairing"]);
272+
} finally {
273+
pairingWs?.close();
274+
started.ws.close();
275+
await started.server.close();
276+
started.envSnapshot.restore();
277+
}
278+
});
242279
});

src/infra/device-pairing.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,52 @@ describe("device pairing tokens", () => {
803803
expect(hasEffectivePairedDeviceRole(device, "operator")).toBe(true);
804804
});
805805

806+
test("filters active token roles to the approved pairing role set", async () => {
807+
const now = Date.now();
808+
const device: PairedDevice = {
809+
deviceId: "device-filtered",
810+
publicKey: "pk-filtered",
811+
role: "operator",
812+
roles: ["operator"],
813+
tokens: {
814+
node: {
815+
token: "forged-node-token",
816+
role: "node",
817+
scopes: [],
818+
createdAtMs: now,
819+
},
820+
operator: {
821+
token: "real-operator-token",
822+
role: "operator",
823+
scopes: ["operator.read"],
824+
createdAtMs: now,
825+
},
826+
},
827+
createdAtMs: now,
828+
approvedAtMs: now,
829+
};
830+
831+
expect(listEffectivePairedDeviceRoles(device)).toEqual(["operator"]);
832+
expect(hasEffectivePairedDeviceRole(device, "node")).toBe(false);
833+
});
834+
835+
test("rejects rotating a token for a role that was never approved", async () => {
836+
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
837+
await setupPairedOperatorDevice(baseDir, ["operator.pairing"]);
838+
839+
await expect(
840+
rotateDeviceToken({
841+
deviceId: "device-1",
842+
role: "node",
843+
baseDir,
844+
}),
845+
).resolves.toEqual({ ok: false, reason: "unknown-device-or-role" });
846+
847+
const paired = await getPairedDevice("device-1", baseDir);
848+
expect(paired?.tokens?.node).toBeUndefined();
849+
expect(paired && listEffectivePairedDeviceRoles(paired)).toEqual(["operator"]);
850+
});
851+
806852
test("removes paired devices by device id", async () => {
807853
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
808854
await setupPairedOperatorDevice(baseDir, ["operator.read"]);

src/infra/device-pairing.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,19 @@ function listActiveTokenRoles(
168168
);
169169
}
170170

171+
export function listApprovedPairedDeviceRoles(
172+
device: Pick<PairedDevice, "role" | "roles">,
173+
): string[] {
174+
return mergeRoles(device.roles, device.role) ?? [];
175+
}
176+
171177
export function listEffectivePairedDeviceRoles(
172178
device: Pick<PairedDevice, "role" | "roles" | "tokens">,
173179
): string[] {
174180
const activeTokenRoles = listActiveTokenRoles(device.tokens);
175181
if (activeTokenRoles && activeTokenRoles.length > 0) {
176-
return activeTokenRoles;
182+
const approvedRoles = new Set(listApprovedPairedDeviceRoles(device));
183+
return activeTokenRoles.filter((role) => approvedRoles.has(role));
177184
}
178185
// Only fall back to legacy role fields when the tokens map is absent
179186
// or has no entries at all (empty object from a fresh pairing record).
@@ -182,7 +189,7 @@ export function listEffectivePairedDeviceRoles(
182189
if (device.tokens && Object.keys(device.tokens).length > 0) {
183190
return [];
184191
}
185-
return mergeRoles(device.roles, device.role) ?? [];
192+
return listApprovedPairedDeviceRoles(device);
186193
}
187194

188195
export function hasEffectivePairedDeviceRole(
@@ -873,6 +880,9 @@ function resolveDeviceTokenUpdateContext(params: {
873880
if (!role) {
874881
return null;
875882
}
883+
if (!listApprovedPairedDeviceRoles(device).includes(role)) {
884+
return null;
885+
}
876886
const tokens = cloneDeviceTokens(device);
877887
const existing = tokens[role];
878888
return { device, role, tokens, existing };

0 commit comments

Comments
 (0)