From 8ef356d5c32044f56a03287ff0a451f6ac40eeb3 Mon Sep 17 00:00:00 2001 From: SARAMALI15792 Date: Mon, 20 Apr 2026 23:01:10 +0500 Subject: [PATCH] fix(gateway): classify loopback shared-secret clients as local for pairing (#69397) --- .../handshake-auth-helpers.test.ts | 111 +++++++++++++++++- .../ws-connection/handshake-auth-helpers.ts | 33 ++++++ 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index ae5f2401a6f..907c74745e4 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -322,7 +322,7 @@ describe("handshake auth helpers", () => { ).toBe("remote"); }); - it("keeps non-CLI clients remote when only the Docker CLI fallback conditions match", () => { + it("classifies non-CLI Docker-published loopback clients as shared_secret_loopback_local when auth is token/password", () => { const connectParams = { client: { id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, @@ -340,7 +340,7 @@ describe("handshake auth helpers", () => { sharedAuthOk: true, authMethod: "token", }), - ).toBe("remote"); + ).toBe("shared_secret_loopback_local"); }); it("skips backend self-pairing only for direct-local backend clients", () => { @@ -441,4 +441,111 @@ describe("handshake auth helpers", () => { }), ).toBe(false); }); + + it("classifies non-CLI loopback + shared-secret clients as shared_secret_loopback_local", () => { + const connectParams = { + client: { + id: GATEWAY_CLIENT_IDS.NODE_HOST, + mode: GATEWAY_CLIENT_MODES.NODE, + }, + } as ConnectParams; + expect( + resolvePairingLocality({ + connectParams, + isLocalClient: false, + requestHost: "127.0.0.1:18789", + remoteAddress: "127.0.0.1", + hasProxyHeaders: false, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe("shared_secret_loopback_local"); + }); + + it("keeps non-CLI loopback clients remote without shared-secret auth", () => { + const connectParams = { + client: { + id: GATEWAY_CLIENT_IDS.NODE_HOST, + mode: GATEWAY_CLIENT_MODES.NODE, + }, + } as ConnectParams; + const base = { + connectParams, + isLocalClient: false, + requestHost: "127.0.0.1:18789", + remoteAddress: "127.0.0.1", + hasProxyHeaders: false, + hasBrowserOriginHeader: false, + } as const; + + expect( + resolvePairingLocality({ + ...base, + sharedAuthOk: false, + authMethod: "token", + }), + ).toBe("remote"); + expect( + resolvePairingLocality({ + ...base, + sharedAuthOk: true, + authMethod: "device-token", + }), + ).toBe("remote"); + expect( + resolvePairingLocality({ + ...base, + remoteAddress: "192.168.1.10", + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe("remote"); + expect( + resolvePairingLocality({ + ...base, + hasProxyHeaders: true, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe("remote"); + expect( + resolvePairingLocality({ + ...base, + hasBrowserOriginHeader: true, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe("remote"); + }); + + it("allows silent scope-upgrade for shared_secret_loopback_local", () => { + expect( + shouldAllowSilentLocalPairing({ + locality: "shared_secret_loopback_local", + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "scope-upgrade", + }), + ).toBe(true); + expect( + shouldAllowSilentLocalPairing({ + locality: "shared_secret_loopback_local", + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "role-upgrade", + }), + ).toBe(true); + expect( + shouldAllowSilentLocalPairing({ + locality: "shared_secret_loopback_local", + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "metadata-upgrade", + }), + ).toBe(false); + }); }); diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index babbd781568..daa7dd89bdf 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -20,6 +20,7 @@ export type PairingLocalityKind = | "direct_local" | "cli_container_local" | "browser_container_local" + | "shared_secret_loopback_local" | "remote"; export type HandshakeBrowserSecurityContext = { @@ -111,6 +112,26 @@ function isCliContainerLocalEquivalent(params: { ); } +function isSharedSecretLoopbackLocalEquivalent(params: { + requestHost?: string; + remoteAddress?: string; + hasProxyHeaders: boolean; + hasBrowserOriginHeader: boolean; + sharedAuthOk: boolean; + authMethod: GatewayAuthResult["method"]; +}): boolean { + const usesSharedSecretAuth = + params.authMethod === "token" || params.authMethod === "password"; + return ( + params.sharedAuthOk && + usesSharedSecretAuth && + !params.hasProxyHeaders && + !params.hasBrowserOriginHeader && + isLoopbackAddress(params.remoteAddress) && + isPrivateOrLoopbackHost(resolveHostName(params.requestHost)) + ); +} + function resolveOriginHost(origin?: string): string { const trimmed = origin?.trim(); if (!trimmed) { @@ -190,6 +211,18 @@ export function resolvePairingLocality(params: { ) { return "cli_container_local"; } + if ( + isSharedSecretLoopbackLocalEquivalent({ + requestHost: params.requestHost, + remoteAddress: params.remoteAddress, + hasProxyHeaders: params.hasProxyHeaders, + hasBrowserOriginHeader: params.hasBrowserOriginHeader, + sharedAuthOk: params.sharedAuthOk, + authMethod: params.authMethod, + }) + ) { + return "shared_secret_loopback_local"; + } return "remote"; }