From 224fceee1a748b301d814d2aab2c5e5202d6a2ca Mon Sep 17 00:00:00 2001 From: sar618 Date: Thu, 26 Mar 2026 11:08:02 +0000 Subject: [PATCH] fix(gateway): skip device pairing for authenticated CLI connections in Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI connections with valid shared auth (token/password) now bypass device pairing, fixing the chicken-and-egg problem where Docker CLI commands fail with 'pairing required' (1008) despite sharing the gateway's network namespace and auth token. The existing shouldSkipBackendSelfPairing only matched gateway-client/backend mode. CLI connections use cli/cli mode and were excluded. Additionally, isLocalDirectRequest produces false negatives in Docker (host networking, network_mode sharing) even when remoteAddress is 127.0.0.1, so CLI connections with valid shared auth skip the locality check entirely — the token is the trust anchor. Closes #55067 Related: #12210, #23471, #30740 --- .../handshake-auth-helpers.test.ts | 168 +++++++++++++++++- .../ws-connection/handshake-auth-helpers.ts | 39 ++++ .../server/ws-connection/message-handler.ts | 22 ++- 3 files changed, 222 insertions(+), 7 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 9f0ff1b6552..f9fd2d430e6 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -1,10 +1,14 @@ import { describe, expect, it } from "vitest"; import type { AuthRateLimiter } from "../../auth-rate-limit.js"; +import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js"; +import type { ConnectParams } from "../../protocol/schema/types.js"; import { BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP, resolveHandshakeBrowserSecurityContext, resolveUnauthorizedHandshakeContext, shouldAllowSilentLocalPairing, + shouldSkipBackendSelfPairing, + shouldTreatCliContainerHostAsLocal, } from "./handshake-auth-helpers.js"; function createRateLimiter(): AuthRateLimiter { @@ -103,7 +107,6 @@ describe("handshake auth helpers", () => { }), ).toBe(false); }); - it("rejects silent role-upgrade for remote clients", () => { expect( shouldAllowSilentLocalPairing({ @@ -115,4 +118,167 @@ describe("handshake auth helpers", () => { }), ).toBe(false); }); + + it("treats CLI loopback/private-host connects as local only with shared auth", () => { + const connectParams = { + client: { + id: GATEWAY_CLIENT_IDS.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, + } as ConnectParams; + expect( + shouldTreatCliContainerHostAsLocal({ + connectParams, + requestHost: "172.17.0.2:18789", + remoteAddress: "127.0.0.1", + hasProxyHeaders: false, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(true); + expect( + shouldTreatCliContainerHostAsLocal({ + connectParams, + requestHost: "172.17.0.2:18789", + remoteAddress: "127.0.0.1", + hasProxyHeaders: true, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(false); + expect( + shouldTreatCliContainerHostAsLocal({ + connectParams, + requestHost: "gateway.example", + remoteAddress: "127.0.0.1", + hasProxyHeaders: false, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(false); + expect( + shouldTreatCliContainerHostAsLocal({ + connectParams, + requestHost: "172.17.0.2:18789", + remoteAddress: "127.0.0.1", + hasProxyHeaders: false, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "device-token", + }), + ).toBe(false); + }); + + it("does not treat non-CLI clients as Docker-local CLI bypass candidates", () => { + const connectParams = { + client: { + id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + }, + } as ConnectParams; + expect( + shouldTreatCliContainerHostAsLocal({ + connectParams, + requestHost: "172.17.0.2:18789", + remoteAddress: "127.0.0.1", + hasProxyHeaders: false, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(false); + }); + + it("skips backend self-pairing only for local backend clients", () => { + const connectParams = { + client: { + id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + }, + } as ConnectParams; + expect( + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient: true, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(true); + expect( + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient: false, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(false); + expect( + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient: false, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "password", + }), + ).toBe(false); + expect( + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient: true, + hasBrowserOriginHeader: false, + sharedAuthOk: false, + authMethod: "device-token", + }), + ).toBe(true); + expect( + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient: false, + hasBrowserOriginHeader: false, + sharedAuthOk: false, + authMethod: "device-token", + }), + ).toBe(false); + }); + + it("does not skip backend self-pairing for CLI clients", () => { + const connectParams = { + client: { + id: GATEWAY_CLIENT_IDS.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, + } as ConnectParams; + expect( + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient: true, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(false); + }); + + it("rejects pairing bypass when browser origin header is present", () => { + const connectParams = { + client: { + id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + }, + } as ConnectParams; + expect( + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient: true, + hasBrowserOriginHeader: true, + sharedAuthOk: true, + authMethod: "token", + }), + ).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 262fd64f743..55ce419d611 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -3,6 +3,7 @@ import type { AuthRateLimiter } from "../../auth-rate-limit.js"; import type { GatewayAuthResult } from "../../auth.js"; import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js"; import { isLoopbackAddress } from "../../net.js"; +import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js"; import type { ConnectParams } from "../../protocol/index.js"; import type { AuthProvidedKind } from "./auth-messages.js"; @@ -61,6 +62,44 @@ export function shouldAllowSilentLocalPairing(params: { ); } +export function shouldSkipBackendSelfPairing(params: { + connectParams: ConnectParams; + isLocalClient: boolean; + hasBrowserOriginHeader: boolean; + sharedAuthOk: boolean; + authMethod: GatewayAuthResult["method"]; +}): boolean { + const isLocalTrustedClient = + (params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && + params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND) || + (params.connectParams.client.id === GATEWAY_CLIENT_IDS.CLI && + params.connectParams.client.mode === GATEWAY_CLIENT_MODES.CLI); + if (!isLocalTrustedClient) { + return false; + } + const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; + const usesDeviceTokenAuth = params.authMethod === "device-token"; + // `authMethod === "device-token"` only reaches this helper after the caller + // has already accepted auth (`authOk === true`), so a separate + // `deviceTokenAuthOk` flag would be redundant here. + // + // For any trusted client identity (backend or CLI) with a valid shared + // secret (token/password) and no browser Origin header, skip pairing + // regardless of isLocalClient. The shared secret is the trust anchor; + // isLocalDirectRequest() produces false negatives in Docker containers + // that share the gateway's network namespace via network_mode, even when + // remoteAddress is 127.0.0.1. + if (params.sharedAuthOk && usesSharedSecretAuth && !params.hasBrowserOriginHeader) { + return true; + } + // For device-token auth, retain the locality check as defense-in-depth. + return ( + params.isLocalClient && + !params.hasBrowserOriginHeader && + usesDeviceTokenAuth + ); +} + function resolveSignatureToken(connectParams: ConnectParams): string | null { return ( connectParams.auth?.token ?? diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 0c4d6dcb38d..fa1f2917e1b 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -109,6 +109,7 @@ import { resolveHandshakeBrowserSecurityContext, resolveUnauthorizedHandshakeContext, shouldAllowSilentLocalPairing, + shouldSkipBackendSelfPairing, } from "./handshake-auth-helpers.js"; import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js"; @@ -247,6 +248,7 @@ export function attachGatewayWsMessageHandler(params: { const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy; const hostIsLocalish = isLocalishHost(requestHost); const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies, allowRealIpFallback); + const reportedClientIp = isLocalClient || hasUntrustedProxyHeaders ? undefined @@ -723,12 +725,20 @@ export function attachGatewayWsMessageHandler(params: { authOk, authMethod, }); - const skipPairing = shouldSkipControlUiPairing( - controlUiAuthPolicy, - role, - trustedProxyAuthOk, - resolvedAuth.mode, - ); + const skipPairing = + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient, + hasBrowserOriginHeader, + sharedAuthOk, + authMethod, + }) || + shouldSkipControlUiPairing( + controlUiAuthPolicy, + role, + trustedProxyAuthOk, + resolvedAuth.mode, + ); if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) {