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) {