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:
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user