diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index e0b691fecdc..320f90537ce 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -49,6 +49,7 @@ describe("ws connect policy", () => { role: "node", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -68,6 +69,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: true, controlUiAuthPolicy: controlUiStrict, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -82,6 +84,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: true, controlUiAuthPolicy: controlUiStrict, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -101,6 +104,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: true, controlUiAuthPolicy: controlUiNoInsecure, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -114,6 +118,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, @@ -127,6 +132,7 @@ describe("ws connect policy", () => { role: "operator", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: false, authOk: false, hasSharedAuth: true, @@ -140,15 +146,31 @@ describe("ws connect policy", () => { role: "node", isControlUi: false, controlUiAuthPolicy: policy, + trustedProxyAuthOk: false, sharedAuthOk: true, authOk: true, hasSharedAuth: true, isLocalClient: false, }).kind, ).toBe("reject-device-required"); + + // Trusted-proxy authenticated Control UI should bypass device-identity gating. + expect( + evaluateMissingDeviceIdentity({ + hasDeviceIdentity: false, + role: "operator", + isControlUi: true, + controlUiAuthPolicy: controlUiNoInsecure, + trustedProxyAuthOk: true, + sharedAuthOk: false, + authOk: true, + hasSharedAuth: false, + isLocalClient: false, + }).kind, + ).toBe("allow"); }); - test("pairing bypass requires control-ui bypass + shared auth", () => { + test("pairing bypass requires control-ui bypass + shared auth (or trusted-proxy auth)", () => { const bypass = resolveControlUiAuthPolicy({ isControlUi: true, controlUiConfig: { dangerouslyDisableDeviceAuth: true }, @@ -159,8 +181,9 @@ describe("ws connect policy", () => { controlUiConfig: undefined, deviceRaw: null, }); - expect(shouldSkipControlUiPairing(bypass, true)).toBe(true); - expect(shouldSkipControlUiPairing(bypass, false)).toBe(false); - expect(shouldSkipControlUiPairing(strict, true)).toBe(false); + expect(shouldSkipControlUiPairing(bypass, true, false)).toBe(true); + expect(shouldSkipControlUiPairing(bypass, false, false)).toBe(false); + expect(shouldSkipControlUiPairing(strict, true, false)).toBe(false); + expect(shouldSkipControlUiPairing(strict, false, true)).toBe(true); }); }); diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index b52cb066411..70dbea07505 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -35,7 +35,11 @@ export function resolveControlUiAuthPolicy(params: { export function shouldSkipControlUiPairing( policy: ControlUiAuthPolicy, sharedAuthOk: boolean, + trustedProxyAuthOk = false, ): boolean { + if (trustedProxyAuthOk) { + return true; + } return policy.allowBypass && sharedAuthOk; } @@ -50,6 +54,7 @@ export function evaluateMissingDeviceIdentity(params: { role: GatewayRole; isControlUi: boolean; controlUiAuthPolicy: ControlUiAuthPolicy; + trustedProxyAuthOk?: boolean; sharedAuthOk: boolean; authOk: boolean; hasSharedAuth: boolean; @@ -58,6 +63,9 @@ export function evaluateMissingDeviceIdentity(params: { if (params.hasDeviceIdentity) { return { kind: "allow" }; } + if (params.isControlUi && params.trustedProxyAuthOk) { + return { kind: "allow" }; + } if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) { // Allow localhost Control UI connections when allowInsecureAuth is configured. // Localhost has no network interception risk, and browser SubtleCrypto diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 1798b71afb4..191278275ee 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -427,11 +427,17 @@ export function attachGatewayWsMessageHandler(params: { if (!device) { clearUnboundScopes(); } + const trustedProxyAuthOk = + isControlUi && + resolvedAuth.mode === "trusted-proxy" && + authOk && + authMethod === "trusted-proxy"; const decision = evaluateMissingDeviceIdentity({ hasDeviceIdentity: Boolean(device), role, isControlUi, controlUiAuthPolicy, + trustedProxyAuthOk, sharedAuthOk, authOk, hasSharedAuth, @@ -563,8 +569,13 @@ export function attachGatewayWsMessageHandler(params: { // In that case, don't force device pairing on first connect. const skipPairingForOperatorSharedAuth = role === "operator" && sharedAuthOk && !isControlUi && !isWebchat; + const trustedProxyAuthOk = + isControlUi && + resolvedAuth.mode === "trusted-proxy" && + authOk && + authMethod === "trusted-proxy"; const skipPairing = - shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk) || + shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk, trustedProxyAuthOk) || skipPairingForOperatorSharedAuth; if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => {