From a1f9825d63131e5f0317615795cca2b63d0d06ce Mon Sep 17 00:00:00 2001 From: Jamieson O'Reilly <6668807+orlyjamie@users.noreply.github.com> Date: Tue, 27 Jan 2026 00:32:11 +1100 Subject: [PATCH] security: add mDNS discovery config to reduce information disclosure (#1882) * security: add mDNS discovery config to reduce information disclosure mDNS broadcasts can expose sensitive operational details like filesystem paths (cliPath) and SSH availability (sshPort) to anyone on the local network. This information aids reconnaissance and should be minimized for gateways exposed beyond trusted networks. Changes: - Add discovery.mdns.enabled config option to disable mDNS entirely - Add discovery.mdns.minimal option to omit cliPath/sshPort from TXT records - Update security docs with operational security guidance Minimal mode still broadcasts enough for device discovery (role, gatewayPort, transport) while omitting details that help map the host environment. Apps that need CLI path can fetch it via the authenticated WebSocket. * fix: default mDNS discovery mode to minimal (#1882) (thanks @orlyjamie) --------- Co-authored-by: theonejvo Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 14 ++++++++ docs/gateway/security.md | 43 +++++++++++++++++++++++++ src/config/schema.ts | 3 ++ src/config/types.gateway.ts | 13 ++++++++ src/config/zod-schema.ts | 6 ++++ src/gateway/server-discovery-runtime.ts | 40 ++++++++++++++--------- src/gateway/server.impl.ts | 1 + src/infra/bonjour.test.ts | 36 +++++++++++++++++++++ src/infra/bonjour.ts | 27 ++++++++++++---- 10 files changed, 162 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 668a91823..ce6007b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Status: unreleased. ### Fixes - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. +- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie. - Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. - Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 97427debe..024c0b1c5 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -3175,6 +3175,20 @@ Auto-generated certs require `openssl` on PATH; if generation fails, the bridge } ``` +### `discovery.mdns` (Bonjour / mDNS broadcast mode) + +Controls LAN mDNS discovery broadcasts (`_clawdbot-gw._tcp`). + +- `minimal` (default): omit `cliPath` + `sshPort` from TXT records +- `full`: include `cliPath` + `sshPort` in TXT records +- `off`: disable mDNS broadcasts entirely + +```json5 +{ + discovery: { mdns: { mode: "minimal" } } +} +``` + ### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD) When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.` diff --git a/docs/gateway/security.md b/docs/gateway/security.md index d13d830cf..ce542951d 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -287,6 +287,49 @@ Rules of thumb: - If you must bind to LAN, firewall the port to a tight allowlist of source IPs; do not port-forward it broadly. - Never expose the Gateway unauthenticated on `0.0.0.0`. +### 0.4.1) mDNS/Bonjour discovery (information disclosure) + +The Gateway broadcasts its presence via mDNS (`_clawdbot-gw._tcp` on port 5353) for local device discovery. In full mode, this includes TXT records that may expose operational details: + +- `cliPath`: full filesystem path to the CLI binary (reveals username and install location) +- `sshPort`: advertises SSH availability on the host +- `displayName`, `lanHost`: hostname information + +**Operational security consideration:** Broadcasting infrastructure details makes reconnaissance easier for anyone on the local network. Even "harmless" info like filesystem paths and SSH availability helps attackers map your environment. + +**Recommendations:** + +1. **Minimal mode** (default, recommended for exposed gateways): omit sensitive fields from mDNS broadcasts: + ```json5 + { + discovery: { + mdns: { mode: "minimal" } + } + } + ``` + +2. **Disable entirely** if you don't need local device discovery: + ```json5 + { + discovery: { + mdns: { mode: "off" } + } + } + ``` + +3. **Full mode** (opt-in): include `cliPath` + `sshPort` in TXT records: + ```json5 + { + discovery: { + mdns: { mode: "full" } + } + } + ``` + +4. **Environment variable** (alternative): set `CLAWDBOT_DISABLE_BONJOUR=1` to disable mDNS without config changes. + +In minimal mode, the Gateway still broadcasts enough for device discovery (`role`, `gatewayPort`, `transport`) but omits `cliPath` and `sshPort`. Apps that need CLI path information can fetch it via the authenticated WebSocket connection instead. + ### 0.5) Lock down the Gateway WebSocket (local auth) Gateway auth is **required by default**. If no token/password is configured, diff --git a/src/config/schema.ts b/src/config/schema.ts index 6cd6381ae..ada88dde6 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -338,6 +338,7 @@ const FIELD_LABELS: Record = { "channels.signal.account": "Signal Account", "channels.imessage.cliPath": "iMessage CLI Path", "agents.list[].identity.avatar": "Agent Avatar", + "discovery.mdns.mode": "mDNS Discovery Mode", "plugins.enabled": "Enable Plugins", "plugins.allow": "Plugin Allowlist", "plugins.deny": "Plugin Denylist", @@ -369,6 +370,8 @@ const FIELD_HELP: Record = { "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", "agents.list[].identity.avatar": "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", + "discovery.mdns.mode": + 'mDNS broadcast mode ("minimal" default, "full" includes cliPath/sshPort, "off" disables mDNS).', "gateway.auth.token": "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", "gateway.auth.password": "Required for Tailscale funnel.", diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 61c0d6f06..4c7ddcdf3 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -17,8 +17,21 @@ export type WideAreaDiscoveryConfig = { enabled?: boolean; }; +export type MdnsDiscoveryMode = "off" | "minimal" | "full"; + +export type MdnsDiscoveryConfig = { + /** + * mDNS/Bonjour discovery broadcast mode (default: minimal). + * - off: disable mDNS entirely + * - minimal: omit cliPath/sshPort from TXT records + * - full: include cliPath/sshPort in TXT records + */ + mode?: MdnsDiscoveryMode; +}; + export type DiscoveryConfig = { wideArea?: WideAreaDiscoveryConfig; + mdns?: MdnsDiscoveryConfig; }; export type CanvasHostConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index b3d157355..3c5bba8d7 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -272,6 +272,12 @@ export const ClawdbotSchema = z }) .strict() .optional(), + mdns: z + .object({ + mode: z.enum(["off", "minimal", "full"]).optional(), + }) + .strict() + .optional(), }) .strict() .optional(), diff --git a/src/gateway/server-discovery-runtime.ts b/src/gateway/server-discovery-runtime.ts index ab1628d1d..2dec5883e 100644 --- a/src/gateway/server-discovery-runtime.ts +++ b/src/gateway/server-discovery-runtime.ts @@ -14,36 +14,46 @@ export async function startGatewayDiscovery(params: { canvasPort?: number; wideAreaDiscoveryEnabled: boolean; tailscaleMode: "off" | "serve" | "funnel"; + /** mDNS/Bonjour discovery mode (default: minimal). */ + mdnsMode?: "off" | "minimal" | "full"; logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void }; }) { let bonjourStop: (() => Promise) | null = null; + const mdnsMode = params.mdnsMode ?? "minimal"; + // mDNS can be disabled via config (mdnsMode: off) or env var. const bonjourEnabled = + mdnsMode !== "off" && process.env.CLAWDBOT_DISABLE_BONJOUR !== "1" && process.env.NODE_ENV !== "test" && !process.env.VITEST; + const mdnsMinimal = mdnsMode !== "full"; const tailscaleEnabled = params.tailscaleMode !== "off"; const needsTailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled; const tailnetDns = needsTailnetDns ? await resolveTailnetDnsHint({ enabled: tailscaleEnabled }) : undefined; - const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim(); + const sshPortEnv = mdnsMinimal ? undefined : process.env.CLAWDBOT_SSH_PORT?.trim(); const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN; const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined; + const cliPath = mdnsMinimal ? undefined : resolveBonjourCliPath(); - try { - const bonjour = await startGatewayBonjourAdvertiser({ - instanceName: formatBonjourInstanceName(params.machineDisplayName), - gatewayPort: params.port, - gatewayTlsEnabled: params.gatewayTls?.enabled ?? false, - gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256, - canvasPort: params.canvasPort, - sshPort, - tailnetDns, - cliPath: resolveBonjourCliPath(), - }); - bonjourStop = bonjour.stop; - } catch (err) { - params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`); + if (bonjourEnabled) { + try { + const bonjour = await startGatewayBonjourAdvertiser({ + instanceName: formatBonjourInstanceName(params.machineDisplayName), + gatewayPort: params.port, + gatewayTlsEnabled: params.gatewayTls?.enabled ?? false, + gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256, + canvasPort: params.canvasPort, + sshPort, + tailnetDns, + cliPath, + minimal: mdnsMinimal, + }); + bonjourStop = bonjour.stop; + } catch (err) { + params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`); + } } if (params.wideAreaDiscoveryEnabled) { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index fdf40be61..7435ed1a7 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -352,6 +352,7 @@ export async function startGatewayServer( : undefined, wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true, tailscaleMode, + mdnsMode: cfgAtStart.discovery?.mdns?.mode, logDiscovery, }); bonjourStop = discovery.bonjourStop; diff --git a/src/infra/bonjour.test.ts b/src/infra/bonjour.test.ts index 82c8253d7..dabdb483e 100644 --- a/src/infra/bonjour.test.ts +++ b/src/infra/bonjour.test.ts @@ -138,6 +138,42 @@ describe("gateway bonjour advertiser", () => { expect(shutdown).toHaveBeenCalledTimes(1); }); + it("omits cliPath and sshPort in minimal mode", async () => { + // Allow advertiser to run in unit tests. + delete process.env.VITEST; + process.env.NODE_ENV = "development"; + + vi.spyOn(os, "hostname").mockReturnValue("test-host"); + + const destroy = vi.fn().mockResolvedValue(undefined); + const advertise = vi.fn().mockResolvedValue(undefined); + + createService.mockImplementation((options: Record) => { + return { + advertise, + destroy, + serviceState: "announced", + on: vi.fn(), + getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, + getHostname: () => asString(options.hostname, "unknown"), + getPort: () => Number(options.port ?? -1), + }; + }); + + const started = await startGatewayBonjourAdvertiser({ + gatewayPort: 18789, + sshPort: 2222, + cliPath: "/opt/homebrew/bin/clawdbot", + minimal: true, + }); + + const [gatewayCall] = createService.mock.calls as Array<[Record]>; + expect((gatewayCall?.[0]?.txt as Record)?.sshPort).toBeUndefined(); + expect((gatewayCall?.[0]?.txt as Record)?.cliPath).toBeUndefined(); + + await started.stop(); + }); + it("attaches conflict listeners for services", async () => { // Allow advertiser to run in unit tests. delete process.env.VITEST; diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 302717116..94b38d68c 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -20,6 +20,11 @@ export type GatewayBonjourAdvertiseOpts = { canvasPort?: number; tailnetDns?: string; cliPath?: string; + /** + * Minimal mode - omit sensitive fields (cliPath, sshPort) from TXT records. + * Reduces information disclosure for better operational security. + */ + minimal?: boolean; }; function isDisabledByEnv() { @@ -115,12 +120,24 @@ export async function startGatewayBonjourAdvertiser( if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) { txtBase.tailnetDns = opts.tailnetDns.trim(); } - if (typeof opts.cliPath === "string" && opts.cliPath.trim()) { + // In minimal mode, omit cliPath to avoid exposing filesystem structure. + // This info can be obtained via the authenticated WebSocket if needed. + if (!opts.minimal && typeof opts.cliPath === "string" && opts.cliPath.trim()) { txtBase.cliPath = opts.cliPath.trim(); } const services: Array<{ label: string; svc: BonjourService }> = []; + // Build TXT record for the gateway service. + // In minimal mode, omit sshPort to avoid advertising SSH availability. + const gatewayTxt: Record = { + ...txtBase, + transport: "gateway", + }; + if (!opts.minimal) { + gatewayTxt.sshPort = String(opts.sshPort ?? 22); + } + const gateway = responder.createService({ name: safeServiceName(instanceName), type: "clawdbot-gw", @@ -128,11 +145,7 @@ export async function startGatewayBonjourAdvertiser( port: opts.gatewayPort, domain: "local", hostname, - txt: { - ...txtBase, - sshPort: String(opts.sshPort ?? 22), - transport: "gateway", - }, + txt: gatewayTxt, }); services.push({ label: "gateway", @@ -149,7 +162,7 @@ export async function startGatewayBonjourAdvertiser( logDebug( `bonjour: starting (hostname=${hostname}, instance=${JSON.stringify( safeServiceName(instanceName), - )}, gatewayPort=${opts.gatewayPort}, sshPort=${opts.sshPort ?? 22})`, + )}, gatewayPort=${opts.gatewayPort}${opts.minimal ? ", minimal=true" : `, sshPort=${opts.sshPort ?? 22}`})`, ); for (const { label, svc } of services) {