Gateway: make device approval scope checks atomic

This commit is contained in:
Vincent Koc 2026-03-14 23:20:15 -07:00
parent 84dab1c310
commit 5e2f07597e
3 changed files with 49 additions and 19 deletions

View File

@ -223,6 +223,13 @@ describe("resolveClientIp", () => {
trustedProxies: ["127.0.0.1", "::1"],
expected: undefined,
},
{
name: "fails closed when all non-loopback X-Forwarded-For hops are trusted proxies",
remoteAddr: "10.0.0.50",
forwardedFor: "10.0.0.2, 10.0.0.1",
trustedProxies: ["10.0.0.0/8"],
expected: undefined,
},
{
name: "fails closed when trusted proxy omits forwarding headers",
remoteAddr: "127.0.0.1",

View File

@ -1,6 +1,5 @@
import {
approveDevicePairing,
getPendingDevicePairing,
getPairedDevice,
listDevicePairing,
removePairedDevice,
@ -94,31 +93,20 @@ export const deviceHandlers: GatewayRequestHandlers = {
return;
}
const { requestId } = params as { requestId: string };
const pending = await getPendingDevicePairing(requestId);
if (!pending) {
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
const approved = await approveDevicePairing(requestId, { callerScopes });
if (!approved) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
const requestedScopes = normalizeDeviceAuthScopes(pending.scopes);
const missingScope = resolveMissingRequestedScope({
role: pending.role,
requestedScopes,
callerScopes,
});
if (missingScope) {
if (approved.status === "forbidden") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${missingScope}`),
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${approved.missingScope}`),
);
return;
}
const approved = await approveDevicePairing(requestId);
if (!approved) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
context.logGateway.info(
`device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
);

View File

@ -71,6 +71,11 @@ export type DevicePairingList = {
paired: PairedDevice[];
};
export type ApproveDevicePairingResult =
| { status: "approved"; requestId: string; device: PairedDevice }
| { status: "forbidden"; missingScope: string }
| null;
type DevicePairingStateFile = {
pendingById: Record<string, DevicePairingPendingRequest>;
pairedByDeviceId: Record<string, PairedDevice>;
@ -237,6 +242,25 @@ function scopesWithinApprovedDeviceBaseline(params: {
});
}
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 async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
const state = await loadState(baseDir);
const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts);
@ -312,14 +336,25 @@ export async function requestDevicePairing(
export async function approveDevicePairing(
requestId: string,
options?: { callerScopes?: readonly string[] },
baseDir?: string,
): Promise<{ requestId: string; device: PairedDevice } | null> {
): Promise<ApproveDevicePairingResult> {
return await withLock(async () => {
const state = await loadState(baseDir);
const pending = state.pendingById[requestId];
if (!pending) {
return null;
}
if (pending.role && options?.callerScopes) {
const missingScope = resolveMissingRequestedScope({
role: pending.role,
requestedScopes: normalizeDeviceAuthScopes(pending.scopes),
callerScopes: options.callerScopes,
});
if (missingScope) {
return { status: "forbidden", missingScope };
}
}
const now = Date.now();
const existing = state.pairedByDeviceId[pending.deviceId];
const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
@ -372,7 +407,7 @@ export async function approveDevicePairing(
delete state.pendingById[requestId];
state.pairedByDeviceId[device.deviceId] = device;
await persistState(state, baseDir);
return { requestId, device };
return { status: "approved", requestId, device };
});
}