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"], trustedProxies: ["127.0.0.1", "::1"],
expected: undefined, 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", name: "fails closed when trusted proxy omits forwarding headers",
remoteAddr: "127.0.0.1", remoteAddr: "127.0.0.1",

View File

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

View File

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