fix: preserve docker cli pairing locality (#55113) (thanks @sar618)

This commit is contained in:
Peter Steinberger 2026-04-04 06:31:11 +01:00
parent 224fceee1a
commit 40da986b21
4 changed files with 124 additions and 23 deletions

View File

@ -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

View File

@ -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);
}
});
}

View File

@ -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))
);
}

View File

@ -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,