fix(gateway): skip device pairing for local backend self-connections (#30801)

* fix(gateway): skip device pairing for local backend self-connections

When gateway.tls is enabled, sessions_spawn (and other internal
callGateway operations) creates a new WebSocket to the gateway.
The gateway treated this self-connection like any external client
and enforced device pairing, rejecting it with "pairing required"
(close code 1008). This made sub-agent spawning impossible when
TLS was enabled in Docker with bind: "lan".

Skip pairing for connections that are gateway-client self-connections
from localhost with valid shared auth (token/password). These are
internal backend calls (e.g. sessions_spawn, subagent-announce) that
already have valid credentials and connect from the same host.

Closes #30740

* gateway: tighten backend self-pair bypass guard

* tests: cover backend self-pairing local-vs-remote auth path

* changelog: add gateway tls pairing fix credit

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Sid 2026-03-02 13:46:33 +08:00 committed by GitHub
parent 3002f13ca7
commit e1e715c53d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 72 additions and 6 deletions

View File

@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
- Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg.
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
- Feishu/Mobile video media type: treat inbound `message_type: "media"` as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.
- Feishu/Inbound sender fallback: fall back to `sender_id.user_id` when `sender_id.open_id` is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.
- Feishu/Reply context metadata: include inbound `parent_id` and `root_id` as `ReplyToId`/`RootMessageId` in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu.

View File

@ -121,6 +121,13 @@ const NODE_CLIENT = {
mode: GATEWAY_CLIENT_MODES.NODE,
};
const BACKEND_GATEWAY_CLIENT = {
id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
version: "1.0.0",
platform: "node",
mode: GATEWAY_CLIENT_MODES.BACKEND,
};
async function expectHelloOkServerVersion(port: number, expectedVersion: string) {
const ws = await openWs(port);
try {
@ -1791,5 +1798,38 @@ describe("gateway server auth/connect", () => {
}
});
test("allows local gateway backend shared-auth connections without device pairing", async () => {
const { server, ws, prevToken } = await startServerWithClient("secret");
try {
const localBackend = await connectReq(ws, {
token: "secret",
client: BACKEND_GATEWAY_CLIENT,
});
expect(localBackend.ok).toBe(true);
} finally {
ws.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();
const wsRemoteLike = await openWs(port, { host: "gateway.example" });
try {
const remoteLikeBackend = await connectReq(wsRemoteLike, {
token: "secret",
client: BACKEND_GATEWAY_CLIENT,
});
expect(remoteLikeBackend.ok).toBe(false);
expect(remoteLikeBackend.error?.message ?? "").toContain("pairing required");
} finally {
wsRemoteLike.close();
await server.close();
restoreGatewayToken(prevToken);
}
});
// Remaining tests require isolated gateway state.
});

View File

@ -45,7 +45,7 @@ import {
} from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import { checkBrowserOrigin } from "../../origin-check.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js";
import {
ConnectErrorDetailCodes,
resolveDeviceAuthConnectErrorDetailCode,
@ -136,6 +136,28 @@ function shouldAllowSilentLocalPairing(params: {
);
}
function shouldSkipBackendSelfPairing(params: {
connectParams: ConnectParams;
isLocalClient: boolean;
hasBrowserOriginHeader: boolean;
sharedAuthOk: boolean;
authMethod: GatewayAuthResult["method"];
}): boolean {
const isGatewayBackendClient =
params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT &&
params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND;
if (!isGatewayBackendClient) {
return false;
}
const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password";
return (
params.isLocalClient &&
!params.hasBrowserOriginHeader &&
params.sharedAuthOk &&
usesSharedSecretAuth
);
}
function resolveDeviceSignaturePayloadVersion(params: {
device: {
id: string;
@ -712,11 +734,14 @@ export function attachGatewayWsMessageHandler(params: {
authOk,
authMethod,
});
const skipPairing = shouldSkipControlUiPairing(
controlUiAuthPolicy,
sharedAuthOk,
trustedProxyAuthOk,
);
const skipPairing =
shouldSkipBackendSelfPairing({
connectParams,
isLocalClient,
hasBrowserOriginHeader,
sharedAuthOk,
authMethod,
}) || shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk, trustedProxyAuthOk);
if (device && devicePublicKey && !skipPairing) {
const formatAuditList = (items: string[] | undefined): string => {
if (!items || items.length === 0) {