From 40da986b21127bcfc485cfe314b3da711ffc17cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 06:31:11 +0100 Subject: [PATCH] fix: preserve docker cli pairing locality (#55113) (thanks @sar618) --- CHANGELOG.md | 3 + src/gateway/server.auth.control-ui.suite.ts | 79 +++++++++++++++++++ .../ws-connection/handshake-auth-helpers.ts | 52 ++++++------ .../server/ws-connection/message-handler.ts | 13 ++- 4 files changed, 124 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cedfff60914..30cd4aa9a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -619,6 +619,9 @@ Docs: https://docs.openclaw.ai - Plugins/Matrix: encrypt E2EE image thumbnails with `thumbnail_file` while keeping unencrypted-room previews on `thumbnail_url`, so encrypted Matrix image events keep thumbnail metadata without leaking plaintext previews. (#54711) thanks @frischeDaten. - Telegram/forum topics: keep native `/new` and `/reset` routed to the active topic by preserving the topic target on forum-thread command context. (#35963) - Status/port diagnostics: treat single-process dual-stack loopback gateway listeners as healthy in `openclaw status --all`, suppressing false "port already in use" conflict warnings. (#53398) Thanks @DanWebb1949. +- CLI/Docker: treat loopback private-host CLI gateway connects as local for silent pairing auto-approval, while keeping remote backend and public-host CLI connects behind pairing. (#55113) Thanks @sar618. + +## 2026.3.24 ### Breaking diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index bf6bf29c487..421801e861b 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -1373,6 +1373,44 @@ export function registerControlUiAndPairingSuite(): void { } }); + test("auto-approves Docker-style CLI connects on loopback with a private host header", async () => { + const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + ws.close(); + const wsDockerCli = await openWs(port, { host: "172.17.0.2:18789" }); + try { + const { identity, identityPath } = + await createOperatorIdentityFixture("openclaw-cli-docker-"); + const nonce = await readConnectChallengeNonce(wsDockerCli); + const dockerCli = await connectReq(wsDockerCli, { + token: "secret", + client: { + id: GATEWAY_CLIENT_NAMES.CLI, + version: "1.0.0", + platform: "linux", + mode: GATEWAY_CLIENT_MODES.CLI, + }, + device: await buildSignedDeviceForIdentity({ + identityPath, + client: { + id: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, + scopes: ["operator.admin"], + nonce, + }), + }); + expect(dockerCli.ok).toBe(true); + const pending = await listDevicePairing(); + expect(pending.pending).toHaveLength(0); + expect(await getPairedDevice(identity.deviceId)).toBeTruthy(); + } finally { + wsDockerCli.close(); + await server.close(); + restoreGatewayToken(prevToken); + } + }); + test("requires pairing for gateway backend clients when connection is not local-direct", async () => { const { server, ws, port, prevToken } = await startServerWithClient("secret"); ws.close(); @@ -1390,4 +1428,45 @@ export function registerControlUiAndPairingSuite(): void { restoreGatewayToken(prevToken); } }); + + test("requires pairing for gateway backend clients on loopback with a private host header", async () => { + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + ws.close(); + const wsPrivateHost = await openWs(port, { host: "172.17.0.2:18789" }); + try { + const remoteLikeBackend = await connectReq(wsPrivateHost, { + token: "secret", + client: BACKEND_GATEWAY_CLIENT, + }); + expect(remoteLikeBackend.ok).toBe(false); + expect(remoteLikeBackend.error?.message ?? "").toContain("pairing required"); + } finally { + wsPrivateHost.close(); + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("requires pairing for CLI clients when the host header is not private-or-loopback", async () => { + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + ws.close(); + const wsRemoteLike = await openWs(port, { host: "gateway.example" }); + try { + const remoteCli = await connectReq(wsRemoteLike, { + token: "secret", + client: { + id: GATEWAY_CLIENT_NAMES.CLI, + version: "1.0.0", + platform: "linux", + mode: GATEWAY_CLIENT_MODES.CLI, + }, + }); + expect(remoteCli.ok).toBe(false); + expect(remoteCli.error?.message ?? "").toContain("pairing required"); + } finally { + wsRemoteLike.close(); + await server.close(); + restoreGatewayToken(prevToken); + } + }); } diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index 55ce419d611..4a2d1af4d01 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -2,7 +2,7 @@ import { verifyDeviceSignature } from "../../../infra/device-identity.js"; 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 { isLoopbackAddress, isPrivateOrLoopbackHost, resolveHostName } 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"; @@ -69,34 +69,42 @@ export function shouldSkipBackendSelfPairing(params: { 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) { + const isBackendClient = + params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && + params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND; + if (!isBackendClient) { 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 + ((params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth) + ); +} + +export function shouldTreatCliContainerHostAsLocal(params: { + connectParams: ConnectParams; + requestHost?: string; + remoteAddress?: string; + hasProxyHeaders: boolean; + hasBrowserOriginHeader: boolean; + sharedAuthOk: boolean; + authMethod: GatewayAuthResult["method"]; +}): boolean { + const isCliClient = + params.connectParams.client.id === GATEWAY_CLIENT_IDS.CLI && + params.connectParams.client.mode === GATEWAY_CLIENT_MODES.CLI; + const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; + return ( + isCliClient && + params.sharedAuthOk && + usesSharedSecretAuth && + !params.hasProxyHeaders && + !params.hasBrowserOriginHeader && + isLoopbackAddress(params.remoteAddress) && + isPrivateOrLoopbackHost(resolveHostName(params.requestHost)) ); } diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index fa1f2917e1b..ce8c2156653 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -110,6 +110,7 @@ import { resolveUnauthorizedHandshakeContext, shouldAllowSilentLocalPairing, shouldSkipBackendSelfPairing, + shouldTreatCliContainerHostAsLocal, } from "./handshake-auth-helpers.js"; import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-flood-guard.js"; @@ -725,6 +726,16 @@ export function attachGatewayWsMessageHandler(params: { authOk, authMethod, }); + const allowCliContainerLocalPairing = shouldTreatCliContainerHostAsLocal({ + connectParams, + requestHost, + remoteAddress: remoteAddr, + hasProxyHeaders, + hasBrowserOriginHeader, + sharedAuthOk, + authMethod, + }); + const effectiveIsLocalClient = isLocalClient || allowCliContainerLocalPairing; const skipPairing = shouldSkipBackendSelfPairing({ connectParams, @@ -814,7 +825,7 @@ export function attachGatewayWsMessageHandler(params: { }); }; const allowSilentLocalPairing = shouldAllowSilentLocalPairing({ - isLocalClient, + isLocalClient: effectiveIsLocalClient, hasBrowserOriginHeader, isControlUi, isWebchat,