fix(device): reject unapproved token roles

This commit is contained in:
Agustin Rivera 2026-04-03 18:06:35 +00:00 committed by Peter Steinberger
parent d58b4d7425
commit 7cda9df4cb
4 changed files with 112 additions and 3 deletions

View File

@ -1,6 +1,7 @@
import {
approveDevicePairing,
getPairedDevice,
listApprovedPairedDeviceRoles,
listDevicePairing,
removePairedDevice,
type DeviceAuthToken,
@ -196,6 +197,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
role: string;
scopes?: string[];
};
const normalizedRole = role.trim();
const pairedDevice = await getPairedDevice(deviceId);
if (!pairedDevice) {
logDeviceTokenRotationDenied({
@ -211,9 +213,23 @@ export const deviceHandlers: GatewayRequestHandlers = {
);
return;
}
if (!listApprovedPairedDeviceRoles(pairedDevice).includes(normalizedRole)) {
logDeviceTokenRotationDenied({
log: context.logGateway,
deviceId,
role,
reason: "unknown-device-or-role",
});
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, DEVICE_TOKEN_ROTATION_DENIED_MESSAGE),
);
return;
}
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
const requestedScopes = normalizeDeviceAuthScopes(
scopes ?? pairedDevice.tokens?.[role.trim()]?.scopes ?? pairedDevice.scopes,
scopes ?? pairedDevice.tokens?.[normalizedRole]?.scopes ?? pairedDevice.scopes,
);
const missingScope = resolveMissingRequestedScope({
role,

View File

@ -239,4 +239,41 @@ describe("gateway device.token.rotate caller scope guard", () => {
started.envSnapshot.restore();
}
});
test("rejects rotating a token for an unapproved role on an existing paired device", async () => {
const started = await startServerWithClient("secret");
const attacker = await issueOperatorToken({
name: "rotate-unapproved-role",
approvedScopes: ["operator.pairing"],
tokenScopes: ["operator.pairing"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
let pairingWs: WebSocket | undefined;
try {
pairingWs = await connectPairingScopedOperator({
port: started.port,
identityPath: attacker.identityPath,
deviceToken: attacker.token,
});
const rotate = await rpcReq(pairingWs, "device.token.rotate", {
deviceId: attacker.deviceId,
role: "node",
});
expect(rotate.ok).toBe(false);
expect(rotate.error?.message).toBe("device token rotation denied");
const paired = await getPairedDevice(attacker.deviceId);
expect(paired?.tokens?.node).toBeUndefined();
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.pairing"]);
} finally {
pairingWs?.close();
started.ws.close();
await started.server.close();
started.envSnapshot.restore();
}
});
});

View File

@ -803,6 +803,52 @@ describe("device pairing tokens", () => {
expect(hasEffectivePairedDeviceRole(device, "operator")).toBe(true);
});
test("filters active token roles to the approved pairing role set", async () => {
const now = Date.now();
const device: PairedDevice = {
deviceId: "device-filtered",
publicKey: "pk-filtered",
role: "operator",
roles: ["operator"],
tokens: {
node: {
token: "forged-node-token",
role: "node",
scopes: [],
createdAtMs: now,
},
operator: {
token: "real-operator-token",
role: "operator",
scopes: ["operator.read"],
createdAtMs: now,
},
},
createdAtMs: now,
approvedAtMs: now,
};
expect(listEffectivePairedDeviceRoles(device)).toEqual(["operator"]);
expect(hasEffectivePairedDeviceRole(device, "node")).toBe(false);
});
test("rejects rotating a token for a role that was never approved", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.pairing"]);
await expect(
rotateDeviceToken({
deviceId: "device-1",
role: "node",
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "unknown-device-or-role" });
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.tokens?.node).toBeUndefined();
expect(paired && listEffectivePairedDeviceRoles(paired)).toEqual(["operator"]);
});
test("removes paired devices by device id", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.read"]);

View File

@ -168,12 +168,19 @@ function listActiveTokenRoles(
);
}
export function listApprovedPairedDeviceRoles(
device: Pick<PairedDevice, "role" | "roles">,
): string[] {
return mergeRoles(device.roles, device.role) ?? [];
}
export function listEffectivePairedDeviceRoles(
device: Pick<PairedDevice, "role" | "roles" | "tokens">,
): string[] {
const activeTokenRoles = listActiveTokenRoles(device.tokens);
if (activeTokenRoles && activeTokenRoles.length > 0) {
return activeTokenRoles;
const approvedRoles = new Set(listApprovedPairedDeviceRoles(device));
return activeTokenRoles.filter((role) => approvedRoles.has(role));
}
// Only fall back to legacy role fields when the tokens map is absent
// or has no entries at all (empty object from a fresh pairing record).
@ -182,7 +189,7 @@ export function listEffectivePairedDeviceRoles(
if (device.tokens && Object.keys(device.tokens).length > 0) {
return [];
}
return mergeRoles(device.roles, device.role) ?? [];
return listApprovedPairedDeviceRoles(device);
}
export function hasEffectivePairedDeviceRole(
@ -873,6 +880,9 @@ function resolveDeviceTokenUpdateContext(params: {
if (!role) {
return null;
}
if (!listApprovedPairedDeviceRoles(device).includes(role)) {
return null;
}
const tokens = cloneDeviceTokens(device);
const existing = tokens[role];
return { device, role, tokens, existing };