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) {