From 26e0a3ee9a6b5e1251919f6b3b07015cebbf9375 Mon Sep 17 00:00:00 2001 From: Andrew Demczuk Date: Sun, 15 Mar 2026 13:03:39 +0100 Subject: [PATCH] fix(gateway): skip Control UI pairing when auth.mode=none (closes #42931) (#47148) When auth is completely disabled (mode=none), requiring device pairing for Control UI operator sessions adds friction without security value since any client can already connect without credentials. Add authMode parameter to shouldSkipControlUiPairing so the bypass fires only for Control UI + operator role + auth.mode=none. This avoids the #43478 regression where a top-level OR disabled pairing for ALL websocket clients. --- .../ws-connection/connect-policy.test.ts | 24 +++++++++++++++++++ .../server/ws-connection/connect-policy.ts | 13 ++++++++++ .../server/ws-connection/message-handler.ts | 8 ++++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index 670f73637ac..a7baa7f73c1 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -226,6 +226,30 @@ describe("ws connect policy", () => { expect(shouldSkipControlUiPairing(strict, "operator", true)).toBe(true); }); + test("auth.mode=none skips pairing for operator control-ui only", () => { + const controlUi = resolveControlUiAuthPolicy({ + isControlUi: true, + controlUiConfig: undefined, + deviceRaw: null, + }); + const nonControlUi = resolveControlUiAuthPolicy({ + isControlUi: false, + controlUiConfig: undefined, + deviceRaw: null, + }); + // Control UI + operator + auth.mode=none: skip pairing (the fix for #42931) + expect(shouldSkipControlUiPairing(controlUi, "operator", false, "none")).toBe(true); + // Control UI + node role + auth.mode=none: still require pairing + expect(shouldSkipControlUiPairing(controlUi, "node", false, "none")).toBe(false); + // Non-Control-UI + operator + auth.mode=none: still require pairing + // (prevents #43478 regression where ALL clients bypassed pairing) + expect(shouldSkipControlUiPairing(nonControlUi, "operator", false, "none")).toBe(false); + // Control UI + operator + auth.mode=shared-key: no change + expect(shouldSkipControlUiPairing(controlUi, "operator", false, "shared-key")).toBe(false); + // Control UI + operator + no authMode: no change + expect(shouldSkipControlUiPairing(controlUi, "operator", false)).toBe(false); + }); + test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => { const cases: Array<{ role: "operator" | "node"; diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index c5c4c1d0a07..caf4551a714 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -3,6 +3,7 @@ import type { GatewayRole } from "../../role-policy.js"; import { roleCanSkipDeviceIdentity } from "../../role-policy.js"; export type ControlUiAuthPolicy = { + isControlUi: boolean; allowInsecureAuthConfigured: boolean; dangerouslyDisableDeviceAuth: boolean; allowBypass: boolean; @@ -24,6 +25,7 @@ export function resolveControlUiAuthPolicy(params: { const dangerouslyDisableDeviceAuth = params.isControlUi && params.controlUiConfig?.dangerouslyDisableDeviceAuth === true; return { + isControlUi: params.isControlUi, allowInsecureAuthConfigured, dangerouslyDisableDeviceAuth, // `allowInsecureAuth` must not bypass secure-context/device-auth requirements. @@ -36,10 +38,21 @@ export function shouldSkipControlUiPairing( policy: ControlUiAuthPolicy, role: GatewayRole, trustedProxyAuthOk = false, + authMode?: string, ): boolean { if (trustedProxyAuthOk) { return true; } + // When auth is completely disabled (mode=none), there is no shared secret + // or token to gate pairing. Requiring pairing in this configuration adds + // friction without security value since any client can already connect + // without credentials. Guard with policy.isControlUi because this function + // is called for ALL clients (not just Control UI) at the call site. + // Scope to operator role so node-role sessions still need device identity + // (#43478 was reverted for skipping ALL clients). + if (policy.isControlUi && role === "operator" && authMode === "none") { + return true; + } // dangerouslyDisableDeviceAuth is the break-glass path for Control UI // operators. Keep pairing aligned with the missing-device bypass, including // open-auth deployments where there is no shared token/password to prove. diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index e0116190009..f7eec2153ad 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -681,7 +681,13 @@ export function attachGatewayWsMessageHandler(params: { hasBrowserOriginHeader, sharedAuthOk, authMethod, - }) || shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk); + }) || + shouldSkipControlUiPairing( + controlUiAuthPolicy, + role, + trustedProxyAuthOk, + resolvedAuth.mode, + ); if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) {