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
This commit is contained in:
Robin Waslander
2026-03-11 03:03:41 +01:00
parent 04e103d10e
commit dafd61b5c1
3 changed files with 330 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
import {
approveDevicePairing,
getPairedDevice,
listDevicePairing,
removePairedDevice,
type DeviceAuthToken,
@@ -8,6 +9,8 @@ import {
rotateDeviceToken,
summarizeDeviceTokens,
} from "../../infra/device-pairing.js";
import { normalizeDeviceAuthScopes } from "../../shared/device-auth.js";
import { roleScopesAllow } from "../../shared/operator-scope-compat.js";
import {
ErrorCodes,
errorShape,
@@ -31,6 +34,25 @@ function redactPairedDevice(
};
}
function resolveMissingRequestedScope(params: {
role: string;
requestedScopes: readonly string[];
callerScopes: readonly string[];
}): string | null {
for (const scope of params.requestedScopes) {
if (
!roleScopesAllow({
role: params.role,
requestedScopes: [scope],
allowedScopes: params.callerScopes,
})
) {
return scope;
}
}
return null;
}
export const deviceHandlers: GatewayRequestHandlers = {
"device.pair.list": async ({ params, respond }) => {
if (!validateDevicePairListParams(params)) {
@@ -146,7 +168,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
context.logGateway.info(`device pairing removed device=${removed.deviceId}`);
respond(true, removed, undefined);
},
"device.token.rotate": async ({ params, respond, context }) => {
"device.token.rotate": async ({ params, respond, context, client }) => {
if (!validateDeviceTokenRotateParams(params)) {
respond(
false,
@@ -165,6 +187,28 @@ export const deviceHandlers: GatewayRequestHandlers = {
role: string;
scopes?: string[];
};
const pairedDevice = await getPairedDevice(deviceId);
if (!pairedDevice) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
return;
}
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
const requestedScopes = normalizeDeviceAuthScopes(
scopes ?? pairedDevice.tokens?.[role.trim()]?.scopes ?? pairedDevice.scopes,
);
const missingScope = resolveMissingRequestedScope({
role,
requestedScopes,
callerScopes,
});
if (missingScope) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${missingScope}`),
);
return;
}
const entry = await rotateDeviceToken({ deviceId, role, scopes });
if (!entry) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));