diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 50fdb59617d..78ec8c05c55 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -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", diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index f1e244b9990..52155c92b8f 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -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"}`, ); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index fb03a0e33fe..bba105595cf 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -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; pairedByDeviceId: Record; @@ -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 { 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 { 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 }; }); }