From deecf68b59a9b7eea978e40fd3c2fe543087b569 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Mar 2026 00:25:03 -0700 Subject: [PATCH] fix(gateway): fail closed on unresolved discovery endpoints --- CHANGELOG.md | 6 +++ src/cli/gateway-cli.coverage.test.ts | 4 +- src/cli/gateway-cli/discover.ts | 19 +++++---- src/cli/gateway-cli/run-loop.test.ts | 6 +-- src/commands/gateway-status.test.ts | 58 ++++++++++++++++++++++++---- src/commands/gateway-status.ts | 18 +++++---- src/commands/onboard-remote.test.ts | 53 +++++++++++++++++++++++-- src/commands/onboard-remote.ts | 24 ++++++++---- src/infra/bonjour-discovery.ts | 10 +++++ 9 files changed, 160 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce2390273d..c16db0b4762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,12 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/discovery: fail closed on unresolved Bonjour and DNS-SD service endpoints in CLI discovery, onboarding, and `gateway status` so TXT-only hints can no longer steer routing or SSH auto-target selection. Thanks @nexrin for reporting. +- Security/pairing: bind iOS setup codes to the intended node profile and reject first-use bootstrap redemption that asks for broader roles or scopes. Thanks @tdjackey. +- Web tools/Exa: align the bundled Exa plugin with the current Exa API by supporting newer search types and richer `contents` options, while fixing the result-count cap to honor Exa's higher limit. Thanks @vincentkoc. +- Plugins/Matrix: move bundled plugin `KeyedAsyncQueue` imports onto the stable `plugin-sdk/core` surface so Matrix Docker/runtime builds do not depend on the brittle keyed-async-queue subpath. Thanks @ecohash-co and @vincentkoc. +- Nostr/security: enforce inbound DM policy before decrypt, route Nostr DMs through the standard reply pipeline, and add pre-crypto rate and size guards so unknown senders cannot bypass pairing or force unbounded crypto work. Thanks @kuranikaran. +- Synology Chat/security: keep reply delivery bound to stable numeric `user_id` by default, and gate mutable username/nickname recipient lookup behind `dangerouslyAllowNameMatching` with new regression coverage. Thanks @nexrin. - Agents/default timeout: raise the shared default agent timeout from `600s` to `48h` so long-running ACP and agent sessions do not fail unless you configure a shorter limit. - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman. - Gateway/startup: prewarm the configured primary model before channel startup and retry one transient provider-runtime miss so the first Telegram or Discord message after boot no longer fails with `Unknown model: openai-codex/gpt-5.4`. Thanks @vincentkoc. diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 3beac2f67fa..89a106eac0c 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -81,7 +81,8 @@ vi.mock("../daemon/program-args.js", () => ({ }), })); -vi.mock("../infra/bonjour-discovery.js", () => ({ +vi.mock("../infra/bonjour-discovery.js", async (importOriginal) => ({ + ...(await importOriginal()), discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts), })); @@ -147,6 +148,7 @@ describe("gateway-cli coverage", () => { displayName: "Studio", domain: "openclaw.internal.", host: "studio.openclaw.internal", + port: 18789, lanHost: "studio.local", tailnetDns: "studio.tailnet.ts.net", gatewayPort: 18789, diff --git a/src/cli/gateway-cli/discover.ts b/src/cli/gateway-cli/discover.ts index 51eac4feb76..681e06e47ea 100644 --- a/src/cli/gateway-cli/discover.ts +++ b/src/cli/gateway-cli/discover.ts @@ -1,4 +1,8 @@ -import type { GatewayBonjourBeacon } from "../../infra/bonjour-discovery.js"; +import { + type GatewayBonjourBeacon, + pickResolvedGatewayHost, + pickResolvedGatewayPort, +} from "../../infra/bonjour-discovery.js"; import { colorize, theme } from "../../terminal/theme.js"; import { parseTimeoutMsWithFallback } from "../parse-timeout.js"; @@ -13,15 +17,14 @@ export function parseDiscoverTimeoutMs(raw: unknown, fallbackMs: number): number export function pickBeaconHost(beacon: GatewayBonjourBeacon): string | null { // Security: TXT records are unauthenticated. Prefer the resolved service endpoint (SRV/A/AAAA) - // over TXT-provided routing hints. - const host = beacon.host || beacon.tailnetDns || beacon.lanHost; - return host?.trim() ? host.trim() : null; + // and fail closed when discovery did not resolve a routable host. + return pickResolvedGatewayHost(beacon); } -export function pickGatewayPort(beacon: GatewayBonjourBeacon): number { +export function pickGatewayPort(beacon: GatewayBonjourBeacon): number | null { // Security: TXT records are unauthenticated. Prefer the resolved service port over TXT gatewayPort. - const port = beacon.port ?? beacon.gatewayPort ?? 18789; - return port > 0 ? port : 18789; + // Fail closed when discovery did not resolve a routable port. + return pickResolvedGatewayPort(beacon); } export function dedupeBeacons(beacons: GatewayBonjourBeacon[]): GatewayBonjourBeacon[] { @@ -56,7 +59,7 @@ export function renderBeaconLines(beacon: GatewayBonjourBeacon, rich: boolean): const host = pickBeaconHost(beacon); const gatewayPort = pickGatewayPort(beacon); const scheme = beacon.gatewayTls ? "wss" : "ws"; - const wsUrl = host ? `${scheme}://${host}:${gatewayPort}` : null; + const wsUrl = host && gatewayPort ? `${scheme}://${host}:${gatewayPort}` : null; const lines = [`- ${title} ${domain}`]; diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index ce8fbccbe93..bf6afcdd12a 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -444,13 +444,13 @@ describe("gateway discover routing helpers", () => { expect(pickGatewayPort(beacon)).toBe(18789); }); - it("falls back to TXT host/port when resolve data is missing", () => { + it("fails closed when resolve data is missing", () => { const beacon: GatewayBonjourBeacon = { instanceName: "Test", lanHost: "test-host.local", gatewayPort: 18789, }; - expect(pickBeaconHost(beacon)).toBe("test-host.local"); - expect(pickGatewayPort(beacon)).toBe(18789); + expect(pickBeaconHost(beacon)).toBeNull(); + expect(pickGatewayPort(beacon)).toBeNull(); }); }); diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index e52cc10f945..22b5e2d88f2 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { GatewayProbeResult } from "../gateway/probe.js"; +import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js"; import type { RuntimeEnv } from "../runtime.js"; import { withEnvAsync } from "../test-utils/env.js"; @@ -12,7 +13,7 @@ const readBestEffortConfig = vi.fn(async () => ({ })); const resolveGatewayPort = vi.fn((_cfg?: unknown) => 18789); const discoverGatewayBeacons = vi.fn( - async (_opts?: unknown): Promise> => [], + async (_opts?: unknown): Promise => [], ); const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10"); const sshStop = vi.fn(async () => {}); @@ -117,9 +118,13 @@ vi.mock("../config/config.js", () => ({ resolveGatewayPort, })); -vi.mock("../infra/bonjour-discovery.js", () => ({ - discoverGatewayBeacons, -})); +vi.mock("../infra/bonjour-discovery.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + discoverGatewayBeacons, + }; +}); vi.mock("../infra/tailnet.js", () => ({ pickPrimaryTailnetIPv4, @@ -220,6 +225,27 @@ describe("gateway-status command", () => { expect(targets[0]?.summary).toBeTruthy(); }); + it("omits discovery wsUrl when only TXT hints are present", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + discoverGatewayBeacons.mockResolvedValueOnce([ + { + instanceName: "gateway", + displayName: "Gateway", + tailnetDns: "attacker.tailnet.ts.net", + lanHost: "attacker.example.com", + gatewayPort: 19443, + }, + ]); + + await runGatewayStatus(runtime, { timeout: "1000", json: true }); + + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + discovery?: { beacons?: Array<{ wsUrl?: string | null }> }; + }; + expect(parsed.discovery?.beacons?.[0]?.wsUrl).toBeNull(); + }); + it("keeps status output working when tailnet discovery throws", async () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); pickPrimaryTailnetIPv4.mockImplementationOnce(() => { @@ -625,13 +651,29 @@ describe("gateway-status command", () => { ); }); - it("skips invalid ssh-auto discovery targets", async () => { + it("does not infer ssh-auto targets from TXT-only discovery metadata", async () => { const { runtime } = createRuntimeCapture(); await withEnvAsync({ USER: "steipete" }, async () => { readBestEffortConfig.mockResolvedValueOnce(makeRemoteGatewayConfig("", "", "ltok")); discoverGatewayBeacons.mockResolvedValueOnce([ - { tailnetDns: "-V" }, - { tailnetDns: "goodhost" }, + { instanceName: "bad", tailnetDns: "-V" }, + { instanceName: "txt-only", tailnetDns: "goodhost" }, + ]); + + startSshPortForward.mockClear(); + await runGatewayStatus(runtime, { timeout: "1000", json: true, sshAuto: true }); + + expect(startSshPortForward).not.toHaveBeenCalled(); + }); + }); + + it("infers ssh-auto targets from resolved discovery hosts", async () => { + const { runtime } = createRuntimeCapture(); + await withEnvAsync({ USER: "steipete" }, async () => { + readBestEffortConfig.mockResolvedValueOnce(makeRemoteGatewayConfig("", "", "ltok")); + discoverGatewayBeacons.mockResolvedValueOnce([ + { instanceName: "bad", tailnetDns: "-V" }, + { host: "goodhost", sshPort: 2222, port: 18789, instanceName: "Gateway" }, ]); startSshPortForward.mockClear(); @@ -639,7 +681,7 @@ describe("gateway-status command", () => { expect(startSshPortForward).toHaveBeenCalledTimes(1); const call = startSshPortForward.mock.calls[0]?.[0] as { target: string }; - expect(call.target).toBe("steipete@goodhost"); + expect(call.target).toBe("steipete@goodhost:2222"); }); }); diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index ddde2006c84..d0ca227f070 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -1,7 +1,11 @@ import { withProgress } from "../cli/progress.js"; import { readBestEffortConfig, resolveGatewayPort } from "../config/config.js"; import { probeGateway } from "../gateway/probe.js"; -import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; +import { + discoverGatewayBeacons, + pickResolvedGatewayHost, + pickResolvedGatewayPort, +} from "../infra/bonjour-discovery.js"; import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; @@ -123,12 +127,12 @@ export async function gatewayStatusCommand( const user = process.env.USER?.trim() || ""; const candidates = discovery .map((b) => { - const host = b.tailnetDns || b.lanHost || b.host; - if (!host?.trim()) { + const host = pickResolvedGatewayHost(b); + if (!host) { return null; } const sshPort = typeof b.sshPort === "number" && b.sshPort > 0 ? b.sshPort : 22; - const base = user ? `${user}@${host.trim()}` : host.trim(); + const base = user ? `${user}@${host}` : host; return sshPort !== 22 ? `${base}:${sshPort}` : base; }) .filter((candidate): candidate is string => Boolean(candidate)); @@ -286,9 +290,9 @@ export async function gatewayStatusCommand( gatewayPort: b.gatewayPort ?? null, sshPort: b.sshPort ?? null, wsUrl: (() => { - const host = b.tailnetDns || b.lanHost || b.host; - const port = b.gatewayPort ?? 18789; - return host ? `ws://${host}:${port}` : null; + const host = pickResolvedGatewayHost(b); + const port = pickResolvedGatewayPort(b); + return host && port ? `ws://${host}:${port}` : null; })(), })), }, diff --git a/src/commands/onboard-remote.test.ts b/src/commands/onboard-remote.test.ts index 1b9a18fad8d..070bb2575e3 100644 --- a/src/commands/onboard-remote.test.ts +++ b/src/commands/onboard-remote.test.ts @@ -9,9 +9,13 @@ const discoverGatewayBeacons = vi.hoisted(() => vi.fn<() => Promise vi.fn(() => undefined)); const detectBinary = vi.hoisted(() => vi.fn<(name: string) => Promise>()); -vi.mock("../infra/bonjour-discovery.js", () => ({ - discoverGatewayBeacons, -})); +vi.mock("../infra/bonjour-discovery.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + discoverGatewayBeacons, + }; +}); vi.mock("../infra/widearea-dns.js", () => ({ resolveWideAreaDiscoveryDomain, @@ -113,6 +117,49 @@ describe("promptRemoteGatewayConfig", () => { ); }); + it("does not route from TXT-only discovery metadata", async () => { + detectBinary.mockResolvedValue(true); + discoverGatewayBeacons.mockResolvedValue([ + { + instanceName: "gateway", + displayName: "Gateway", + lanHost: "attacker.example.com", + tailnetDns: "attacker.tailnet.ts.net", + gatewayPort: 19443, + sshPort: 2222, + }, + ]); + + const select: WizardPrompter["select"] = vi.fn(async (params) => { + if (params.message === "Select gateway") { + return "0" as never; + } + if (params.message === "Gateway auth") { + return "off" as never; + } + return (params.options[0]?.value ?? "") as never; + }); + const text: WizardPrompter["text"] = vi.fn(async (params) => { + if (params.message === "Gateway WebSocket URL") { + expect(params.initialValue).toBe("ws://127.0.0.1:18789"); + return String(params.initialValue); + } + return ""; + }) as WizardPrompter["text"]; + const prompter = createPrompter({ + confirm: vi.fn(async () => true), + select, + text, + }); + + const next = await promptRemoteGatewayConfig({} as OpenClawConfig, prompter); + + expect(next.gateway?.remote?.url).toBe("ws://127.0.0.1:18789"); + expect(select).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Connection method" }), + ); + }); + it("validates insecure ws:// remote URLs and allows only loopback ws:// by default", async () => { const text: WizardPrompter["text"] = vi.fn(async (params) => { if (params.message === "Gateway WebSocket URL") { diff --git a/src/commands/onboard-remote.ts b/src/commands/onboard-remote.ts index a9b652b9bd2..d1bf4742c81 100644 --- a/src/commands/onboard-remote.ts +++ b/src/commands/onboard-remote.ts @@ -1,8 +1,12 @@ import type { OpenClawConfig } from "../config/config.js"; import type { SecretInput } from "../config/types.secrets.js"; import { isSecureWebSocketUrl } from "../gateway/net.js"; -import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js"; -import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; +import { + discoverGatewayBeacons, + pickResolvedGatewayHost, + pickResolvedGatewayPort, + type GatewayBonjourBeacon, +} from "../infra/bonjour-discovery.js"; import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth-mode.js"; import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js"; @@ -14,15 +18,19 @@ const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; function pickHost(beacon: GatewayBonjourBeacon): string | undefined { // Security: TXT is unauthenticated. Prefer the resolved service endpoint host. - return beacon.host || beacon.tailnetDns || beacon.lanHost; + return pickResolvedGatewayHost(beacon) ?? undefined; +} + +function pickPort(beacon: GatewayBonjourBeacon): number | undefined { + // Security: TXT is unauthenticated. Prefer the resolved service endpoint port. + return pickResolvedGatewayPort(beacon) ?? undefined; } function buildLabel(beacon: GatewayBonjourBeacon): string { const host = pickHost(beacon); - // Security: Prefer the resolved service endpoint port. - const port = beacon.port ?? beacon.gatewayPort ?? 18789; + const port = pickPort(beacon); const title = beacon.displayName ?? beacon.instanceName; - const hint = host ? `${host}:${port}` : "host unknown"; + const hint = host && port ? `${host}:${port}` : "host unknown"; return `${title} (${hint})`; } @@ -106,8 +114,8 @@ export async function promptRemoteGatewayConfig( if (selectedBeacon) { const host = pickHost(selectedBeacon); - const port = selectedBeacon.port ?? selectedBeacon.gatewayPort ?? 18789; - if (host) { + const port = pickPort(selectedBeacon); + if (host && port) { const mode = await prompter.select({ message: "Connection method", options: [ diff --git a/src/infra/bonjour-discovery.ts b/src/infra/bonjour-discovery.ts index 426d4eb5141..7700996661e 100644 --- a/src/infra/bonjour-discovery.ts +++ b/src/infra/bonjour-discovery.ts @@ -20,6 +20,16 @@ export type GatewayBonjourBeacon = { txt?: Record; }; +export function pickResolvedGatewayHost(beacon: GatewayBonjourBeacon): string | null { + const host = beacon.host?.trim(); + return host ? host : null; +} + +export function pickResolvedGatewayPort(beacon: GatewayBonjourBeacon): number | null { + const port = beacon.port; + return typeof port === "number" && Number.isFinite(port) && port > 0 ? port : null; +} + export type GatewayBonjourDiscoverOpts = { timeoutMs?: number; domains?: string[];