diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index ffd58fcdf84..39c76fad8ef 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -385,6 +385,48 @@ describe("ws connect policy", () => { }), ).toBe(false); + // Backend client authenticated via verified Tailscale identity is trusted. + expect( + shouldSkipBackendSelfPairing({ + connectParams: makeConnectParams( + GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + GATEWAY_CLIENT_MODES.BACKEND, + ), + isLocalClient: true, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "tailscale", + }), + ).toBe(true); + + // Remote backend client over Tailscale is also trusted. + expect( + shouldSkipBackendSelfPairing({ + connectParams: makeConnectParams( + GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + GATEWAY_CLIENT_MODES.BACKEND, + ), + isLocalClient: false, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "tailscale", + }), + ).toBe(true); + + // Browser-origin Tailscale backend connection is still rejected. + expect( + shouldSkipBackendSelfPairing({ + connectParams: makeConnectParams( + GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + GATEWAY_CLIENT_MODES.BACKEND, + ), + isLocalClient: false, + hasBrowserOriginHeader: true, + sharedAuthOk: true, + authMethod: "tailscale", + }), + ).toBe(false); + expect( shouldSkipBackendSelfPairing({ connectParams: makeConnectParams("webchat", GATEWAY_CLIENT_MODES.BACKEND), diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index e077f87b87e..ee36d93958a 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -92,7 +92,11 @@ export function shouldSkipBackendSelfPairing(params: { return false; } // token/password: sharedAuthOk is set specifically for these in auth-context.ts. - const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; + // tailscale: WS auth can also attest a backend client via verified Tailscale identity. + const usesSharedSecretAuth = + params.authMethod === "token" || + params.authMethod === "password" || + params.authMethod === "tailscale"; // device-token: a derived credential issued after initial shared-secret pairing. sharedAuthOk // stays false for device-token in the WS flow (auth-context.ts only sets it for token/password/ // trusted-proxy), so we gate on authOk directly instead.