diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index bbfbbf29c3..7ee747abf5 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -925,11 +925,25 @@ describe("gateway server auth/connect", () => { client, device: buildDevice(["operator.admin"]), }); - expect(res.ok).toBe(true); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("pairing required"); + + await approvePendingPairingIfNeeded(); + ws2.close(); + + const ws3 = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws3.once("open", resolve)); + const approved = await connectReq(ws3, { + token: "secret", + scopes: ["operator.admin"], + client, + device: buildDevice(["operator.admin"]), + }); + expect(approved.ok).toBe(true); paired = await getPairedDevice(identity.deviceId); expect(paired?.scopes).toContain("operator.admin"); - ws2.close(); + ws3.close(); await server.close(); restoreGatewayToken(prevToken); }); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 7a50d1cc8a..e7496c51b1 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1,6 +1,10 @@ import type { IncomingMessage } from "node:http"; -import os from "node:os"; import type { WebSocket } from "ws"; +import os from "node:os"; +import type { createSubsystemLogger } from "../../../logging/subsystem.js"; +import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js"; +import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js"; +import type { GatewayWsClient } from "../ws-types.js"; import { loadConfig } from "../../../config/config.js"; import { deriveDeviceIdFromPublicKey, @@ -20,7 +24,6 @@ import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skil import { upsertPresence } from "../../../infra/system-presence.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; import { rawDataToString } from "../../../infra/ws.js"; -import type { createSubsystemLogger } from "../../../logging/subsystem.js"; import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js"; import { resolveRuntimeServiceVersion } from "../../../version.js"; import { @@ -28,7 +31,6 @@ import { AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, type AuthRateLimiter, } from "../../auth-rate-limit.js"; -import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js"; import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js"; @@ -48,7 +50,6 @@ import { } from "../../protocol/index.js"; import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js"; import { handleGatewayRequest } from "../../server-methods.js"; -import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js"; import { formatError } from "../../server-utils.js"; import { formatForLog, logWs } from "../../ws-log.js"; import { truncateCloseReason } from "../close-reason.js"; @@ -59,7 +60,6 @@ import { incrementPresenceVersion, refreshGatewayHealthSnapshot, } from "../health-state.js"; -import type { GatewayWsClient } from "../ws-types.js"; import { formatGatewayAuthFailureMessage, type AuthProvidedKind } from "./auth-messages.js"; type SubsystemLogger = ReturnType; @@ -616,7 +616,34 @@ export function attachGatewayWsMessageHandler(params: { const skipPairing = allowControlUiBypass && sharedAuthOk; if (device && devicePublicKey && !skipPairing) { - const requirePairing = async (reason: string, _paired?: { deviceId: string }) => { + const formatAuditList = (items: string[] | undefined): string => { + if (!items || items.length === 0) { + return ""; + } + const out = new Set(); + for (const item of items) { + const trimmed = item.trim(); + if (trimmed) { + out.add(trimmed); + } + } + if (out.size === 0) { + return ""; + } + return [...out].toSorted().join(","); + }; + const logUpgradeAudit = ( + reason: "role-upgrade" | "scope-upgrade", + currentRoles: string[] | undefined, + currentScopes: string[] | undefined, + ) => { + logGateway.warn( + `security audit: device access upgrade requested reason=${reason} device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} roleFrom=${formatAuditList(currentRoles)} roleTo=${role} scopesFrom=${formatAuditList(currentScopes)} scopesTo=${formatAuditList(scopes)} client=${connectParams.client.id} conn=${connId}`, + ); + }; + const requirePairing = async ( + reason: "not-paired" | "role-upgrade" | "scope-upgrade", + ) => { const pairing = await requestDevicePairing({ deviceId: device.id, publicKey: devicePublicKey, @@ -627,7 +654,7 @@ export function attachGatewayWsMessageHandler(params: { role, scopes, remoteIp: reportedClientIp, - silent: isLocalClient, + silent: isLocalClient && reason === "not-paired", }); const context = buildRequestContext(); if (pairing.request.silent === true) { @@ -679,16 +706,21 @@ export function attachGatewayWsMessageHandler(params: { return; } } else { - const allowedRoles = new Set( - Array.isArray(paired.roles) ? paired.roles : paired.role ? [paired.role] : [], - ); + const pairedRoles = Array.isArray(paired.roles) + ? paired.roles + : paired.role + ? [paired.role] + : []; + const allowedRoles = new Set(pairedRoles); if (allowedRoles.size === 0) { - const ok = await requirePairing("role-upgrade", paired); + logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes); + const ok = await requirePairing("role-upgrade"); if (!ok) { return; } } else if (!allowedRoles.has(role)) { - const ok = await requirePairing("role-upgrade", paired); + logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes); + const ok = await requirePairing("role-upgrade"); if (!ok) { return; } @@ -697,7 +729,8 @@ export function attachGatewayWsMessageHandler(params: { const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : []; if (scopes.length > 0) { if (pairedScopes.length === 0) { - const ok = await requirePairing("scope-upgrade", paired); + logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes); + const ok = await requirePairing("scope-upgrade"); if (!ok) { return; } @@ -705,7 +738,8 @@ export function attachGatewayWsMessageHandler(params: { const allowedScopes = new Set(pairedScopes); const missingScope = scopes.find((scope) => !allowedScopes.has(scope)); if (missingScope) { - const ok = await requirePairing("scope-upgrade", paired); + logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes); + const ok = await requirePairing("scope-upgrade"); if (!ok) { return; }