mirror of https://github.com/openclaw/openclaw.git
fix: preserve docker cli pairing locality (#55113) (thanks @sar618)
This commit is contained in:
parent
224fceee1a
commit
40da986b21
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue