fix(security): require explicit approval for device access upgrades

This commit is contained in:
Peter Steinberger
2026-02-19 14:49:03 +01:00
parent 182ffdf557
commit 0bda0202fd
2 changed files with 64 additions and 16 deletions

View File

@@ -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<void>((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);
});

View File

@@ -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<typeof createSubsystemLogger>;
@@ -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 "<none>";
}
const out = new Set<string>();
for (const item of items) {
const trimmed = item.trim();
if (trimmed) {
out.add(trimmed);
}
}
if (out.size === 0) {
return "<none>";
}
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;
}