diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index a7baa7f73c1..23182ebe22b 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "vitest"; +import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js"; import { evaluateMissingDeviceIdentity, isTrustedProxyControlUiOperatorAuth, resolveControlUiAuthPolicy, + shouldSkipBackendSelfPairing, shouldSkipControlUiPairing, } from "./connect-policy.js"; @@ -300,4 +302,89 @@ describe("ws connect policy", () => { ).toBe(tc.expected); } }); + + test("backend self-pairing skip requires trusted local backend handshake conditions", () => { + const makeConnectParams = (clientId: string, mode: string) => ({ + client: { + id: clientId, + mode, + version: "1.0.0", + }, + }); + + expect( + shouldSkipBackendSelfPairing({ + connectParams: makeConnectParams( + GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + GATEWAY_CLIENT_MODES.BACKEND, + ), + isLocalClient: true, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(true); + + expect( + shouldSkipBackendSelfPairing({ + connectParams: makeConnectParams( + GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + GATEWAY_CLIENT_MODES.BACKEND, + ), + isLocalClient: false, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(false); + + expect( + shouldSkipBackendSelfPairing({ + connectParams: makeConnectParams( + GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + GATEWAY_CLIENT_MODES.BACKEND, + ), + isLocalClient: true, + hasBrowserOriginHeader: true, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(false); + + expect( + shouldSkipBackendSelfPairing({ + connectParams: makeConnectParams( + GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + GATEWAY_CLIENT_MODES.BACKEND, + ), + isLocalClient: true, + hasBrowserOriginHeader: false, + sharedAuthOk: false, + authMethod: "token", + }), + ).toBe(false); + + expect( + shouldSkipBackendSelfPairing({ + connectParams: makeConnectParams( + GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + GATEWAY_CLIENT_MODES.BACKEND, + ), + isLocalClient: true, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "trusted-proxy", + }), + ).toBe(false); + + expect( + shouldSkipBackendSelfPairing({ + connectParams: makeConnectParams("not-gateway-client", GATEWAY_CLIENT_MODES.BACKEND), + isLocalClient: true, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(false); + }); }); diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index caf4551a714..20f486ed7c8 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -1,3 +1,5 @@ +import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js"; +import type { GatewayAuthResult } from "../../protocol/index.js"; import type { ConnectParams } from "../../protocol/index.js"; import type { GatewayRole } from "../../role-policy.js"; import { roleCanSkipDeviceIdentity } from "../../role-policy.js"; @@ -75,6 +77,28 @@ export function isTrustedProxyControlUiOperatorAuth(params: { ); } +export function shouldSkipBackendSelfPairing(params: { + connectParams: ConnectParams; + isLocalClient: boolean; + hasBrowserOriginHeader: boolean; + sharedAuthOk: boolean; + authMethod: GatewayAuthResult["method"]; +}): boolean { + const isGatewayBackendClient = + params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && + params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND; + if (!isGatewayBackendClient) { + return false; + } + const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; + return ( + params.isLocalClient && + !params.hasBrowserOriginHeader && + params.sharedAuthOk && + usesSharedSecretAuth + ); +} + export type MissingDeviceIdentityDecision = | { kind: "allow" } | { kind: "reject-control-ui-insecure-auth" } diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 03793b279ba..a3aa8fbe825 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -83,6 +83,7 @@ import { evaluateMissingDeviceIdentity, isTrustedProxyControlUiOperatorAuth, resolveControlUiAuthPolicy, + shouldSkipBackendSelfPairing, shouldSkipControlUiPairing, } from "./connect-policy.js"; import { @@ -90,7 +91,6 @@ import { resolveHandshakeBrowserSecurityContext, resolveUnauthorizedHandshakeContext, shouldAllowSilentLocalPairing, - shouldSkipBackendSelfPairing, } from "./handshake-auth-helpers.js"; import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js";