From 7a8f65805d62e7bd2e3c3db1299fb0d8bcdef01c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Apr 2026 11:47:49 +0700 Subject: [PATCH] refactor: rename allowedHosts to hostnames (#413) * refactor: rename allowedHosts to hostnames for DNS rebinding config The old name confused users who thought it was related to CORS. "hostnames" communicates that you're declaring the daemon's own hostnames, not granting access to external parties. Backward compatible: old config files (daemon.allowedHosts), env var (PASEO_ALLOWED_HOSTS), CLI flag (--allowed-hosts), and Nix option (allowedHosts) all still work as deprecated aliases. * fix(nix): update npmDepsHash to match current package-lock.json --- SECURITY.md | 2 +- docs/AD-HOC-DAEMON-TESTING.md | 2 +- docs/DATA_MODEL.md | 2 +- nix/module.nix | 16 ++++-- nix/package.nix | 2 +- packages/cli/src/cli.ts | 25 +++++++-- packages/cli/src/commands/daemon/index.ts | 25 +++++++-- .../cli/src/commands/daemon/local-daemon.ts | 7 ++- packages/cli/src/commands/daemon/restart.ts | 2 +- packages/cli/src/commands/daemon/start.ts | 18 ++++-- packages/cli/src/commands/onboard.ts | 26 ++++++--- .../src/server/agent/agent-mcp.e2e.test.ts | 8 +-- .../agent/agent-response-loop.e2e.test.ts | 6 +- .../server/src/server/allowed-hosts.test.ts | 45 --------------- .../server/src/server/bootstrap.smoke.test.ts | 4 +- packages/server/src/server/bootstrap.ts | 8 +-- packages/server/src/server/config.ts | 18 +++--- packages/server/src/server/hostnames.test.ts | 56 +++++++++++++++++++ .../server/{allowed-hosts.ts => hostnames.ts} | 26 ++++----- .../server/src/server/persisted-config.ts | 5 ++ .../src/server/test-utils/paseo-daemon.ts | 2 +- .../server/src/server/websocket-server.ts | 10 ++-- .../public/schemas/paseo.config.v1.json | 15 +++++ .../website/src/routes/docs/configuration.tsx | 20 +++++-- packages/website/src/routes/docs/security.tsx | 6 +- 25 files changed, 223 insertions(+), 133 deletions(-) delete mode 100644 packages/server/src/server/allowed-hosts.test.ts create mode 100644 packages/server/src/server/hostnames.test.ts rename packages/server/src/server/{allowed-hosts.ts => hostnames.ts} (72%) diff --git a/SECURITY.md b/SECURITY.md index 6ad5fdeb20..d582f9cfd7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding). -Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected. +Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected. ## Agent authentication diff --git a/docs/AD-HOC-DAEMON-TESTING.md b/docs/AD-HOC-DAEMON-TESTING.md index 30c0d2e65a..6918bb727a 100644 --- a/docs/AD-HOC-DAEMON-TESTING.md +++ b/docs/AD-HOC-DAEMON-TESTING.md @@ -23,7 +23,7 @@ const daemon = await createPaseoDaemon( listen: "127.0.0.1:0", // OS picks a free port paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: false, staticDir, mcpDebug: false, diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 86fb7306a7..f839afb51d 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`. version: 1, daemon: { listen: "127.0.0.1:6767", - allowedHosts: true | string[], + hostnames: true | string[], mcp: { enabled: boolean }, cors: { allowedOrigins: string[] }, relay: { enabled: boolean, endpoint: string, publicEndpoint: string } diff --git a/nix/module.nix b/nix/module.nix index 72a3876148..022fc9eb80 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -9,6 +9,10 @@ let cfg = config.services.paseo; in { + imports = [ + (lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ]) + ]; + options.services.paseo = { enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents"; @@ -58,12 +62,12 @@ in description = "Whether to open the firewall for the Paseo daemon port."; }; - allowedHosts = lib.mkOption { + hostnames = lib.mkOption { type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str); default = [ ]; example = [ ".example.com" "myhost.local" ]; description = '' - Hosts allowed to connect to the Paseo daemon (DNS rebinding protection). + Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection). Localhost and IP addresses are always allowed by default. Use a leading dot to match a domain and all its subdomains @@ -141,10 +145,10 @@ in "/run/wrappers/bin" "/nix/var/nix/profiles/default/bin" ]); - } // lib.optionalAttrs (cfg.allowedHosts == true) { - PASEO_ALLOWED_HOSTS = "true"; - } // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) { - PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts; + } // lib.optionalAttrs (cfg.hostnames == true) { + PASEO_HOSTNAMES = "true"; + } // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) { + PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames; } // cfg.environment; serviceConfig = { diff --git a/nix/package.nix b/nix/package.nix index 5eef96dc37..dd0909a3fd 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-1mnfVYAAGP6x9ywFHEQ55q4i/C9uuZ2foU1ytacRMrE="; + npmDepsHash = "sha256-NBB8+DNyodnREhVEiNMLADd+4MMK4lssRuzlwvvPjk8="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5da470821a..cb4baceec7 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { createRequire } from "node:module"; import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; @@ -124,10 +124,27 @@ export function createCli(): Command { .option("--no-relay", "Disable relay on restarted daemon") .option("--no-mcp", "Disable Agent MCP on restarted daemon") .option( - "--allowed-hosts ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) - .action(withOutput(runDaemonRestartCommand)); + .addOption(new Option("--allowed-hosts ").hideHelp()) + .action( + withOutput((...args) => { + const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; + return runDaemonRestartCommand( + { + ...options, + hostnames: + typeof options.hostnames === "string" + ? options.hostnames + : typeof options.allowedHosts === "string" + ? options.allowedHosts + : undefined, + }, + command, + ); + }), + ); // Advanced agent commands (less common operations) program.addCommand(createAgentCommand()); diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 5cb4f5b804..d9ef612973 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { startCommand } from "./start.js"; import { runStatusCommand } from "./status.js"; import { runStopCommand } from "./stop.js"; @@ -36,10 +36,27 @@ export function createDaemonCommand(): Command { .option("--no-mcp", "Disable Agent MCP on restarted daemon") .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") .option( - "--allowed-hosts ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) - .action(withOutput(runRestartCommand)); + .addOption(new Option("--allowed-hosts ").hideHelp()) + .action( + withOutput((...args) => { + const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; + return runRestartCommand( + { + ...options, + hostnames: + typeof options.hostnames === "string" + ? options.hostnames + : typeof options.allowedHosts === "string" + ? options.allowedHosts + : undefined, + }, + command, + ); + }), + ); return daemon; } diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 7d00a32719..812a48ae40 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -13,7 +13,7 @@ export interface DaemonStartOptions { relay?: boolean; mcp?: boolean; injectMcp?: boolean; - allowedHosts?: string; + hostnames?: string; } export interface LocalDaemonPidInfo { @@ -113,8 +113,8 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv { } else if (options.port) { childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`; } - if (options.allowedHosts) { - childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts; + if (options.hostnames) { + childEnv.PASEO_HOSTNAMES = options.hostnames; } return childEnv; } @@ -322,6 +322,7 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD ...envWithHome(options.home), // Status should reflect local persisted config + pid file, not inherited daemon env overrides. PASEO_LISTEN: undefined, + PASEO_HOSTNAMES: undefined, PASEO_ALLOWED_HOSTS: undefined, }; const home = resolvePaseoHome(env); diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index 82df597f5c..741057f6a1 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -61,7 +61,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions { relay: typeof options.relay === "boolean" ? options.relay : undefined, mcp: typeof options.mcp === "boolean" ? options.mcp : undefined, injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined, - allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined, + hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined, }; if (startOptions.listen && startOptions.port) { diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index 1e5cd22645..7a3e5991eb 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import chalk from "chalk"; import { startLocalDaemonForeground, @@ -9,6 +9,10 @@ import { getErrorMessage } from "../../utils/errors.js"; export type { DaemonStartOptions as StartOptions } from "./local-daemon.js"; +type RawStartCommandOptions = StartOptions & { + allowedHosts?: string; +}; + export function startCommand(): Command { return new Command("start") .description("Start the local Paseo daemon") @@ -20,11 +24,15 @@ export function startCommand(): Command { .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") .option( - "--allowed-hosts ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) - .action(async (options: StartOptions) => { - await runStart(options); + .addOption(new Option("--allowed-hosts ").hideHelp()) + .action(async (options: RawStartCommandOptions) => { + await runStart({ + ...options, + hostnames: options.hostnames ?? options.allowedHosts, + }); }); } diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index a9d4168d32..480e857a79 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -1,5 +1,5 @@ import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts"; -import { Command } from "commander"; +import { Command, Option } from "commander"; import { writeFileSync } from "node:fs"; import path from "node:path"; import { @@ -24,6 +24,10 @@ interface OnboardOptions extends DaemonStartOptions { voice?: "ask" | "enable" | "disable"; } +type RawOnboardOptions = OnboardOptions & { + allowedHosts?: string; +}; + type OnboardPersistedConfig = PersistedConfig & { features?: PersistedConfig["features"] & { dictation?: PersistedConfig["features"] extends { dictation?: infer T } @@ -64,7 +68,7 @@ function parseTimeoutMs(raw: string | undefined): number { return Math.ceil(seconds * 1000); } -function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides { +function toCliOverrides(options: OnboardOptions): CliConfigOverrides { const cliOverrides: CliConfigOverrides = {}; if (options.listen) { @@ -77,9 +81,9 @@ function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides { cliOverrides.relayEnabled = false; } - if (options.allowedHosts) { - const raw = options.allowedHosts.trim(); - cliOverrides.allowedHosts = + if (options.hostnames) { + const raw = options.hostnames.trim(); + cliOverrides.hostnames = raw.toLowerCase() === "true" ? true : raw @@ -297,13 +301,17 @@ export function onboardCommand(): Command { .option("--no-relay", "Disable relay connection") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") .option( - "--allowed-hosts ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) + .addOption(new Option("--allowed-hosts ").hideHelp()) .option("--timeout ", "Max time to wait for daemon readiness (default: 600)") .option("--voice ", "Voice setup mode: ask, enable, disable", "ask") - .action(async (options: OnboardOptions) => { - await runOnboard(options); + .action(async (options: RawOnboardOptions) => { + await runOnboard({ + ...options, + hostnames: options.hostnames ?? options.allowedHosts, + }); }); } diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index ad95ce1b60..16ff84152f 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -119,7 +119,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${port}`, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, @@ -192,7 +192,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${port}`, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, @@ -216,7 +216,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${disabledPort}`, paseoHome: disabledPaseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, mcpInjectIntoAgents: false, staticDir: disabledStaticDir, @@ -307,7 +307,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${port}`, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, diff --git a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts index da63dae7fe..8b5c3aa717 100644 --- a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts @@ -42,7 +42,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise(); const createAgentMcpTransport = async (callerAgentId?: string) => { @@ -62,7 +62,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise { @@ -129,7 +129,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise { - it("allows localhost by default", () => { - expect(isHostAllowed("localhost:6767", undefined)).toBe(true); - }); - - it("allows subdomains of .localhost by default", () => { - expect(isHostAllowed("foo.localhost:6767", undefined)).toBe(true); - }); - - it("allows IP addresses by default", () => { - expect(isHostAllowed("127.0.0.1:6767", undefined)).toBe(true); - expect(isHostAllowed("[::1]:6767", undefined)).toBe(true); - }); - - it("rejects non-default hosts when no allowlist is provided", () => { - expect(isHostAllowed("evil.com:6767", undefined)).toBe(false); - }); - - it("allows any host when set to true", () => { - expect(isHostAllowed("evil.com:6767", true)).toBe(true); - }); - - it("supports leading-dot patterns", () => { - const allowed = [".example.com"]; - expect(isHostAllowed("example.com:6767", allowed)).toBe(true); - expect(isHostAllowed("foo.example.com:6767", allowed)).toBe(true); - expect(isHostAllowed("foo.bar.example.com:6767", allowed)).toBe(true); - expect(isHostAllowed("notexample.com:6767", allowed)).toBe(false); - }); - - it("merges arrays (append + de-dupe) and short-circuits on true", () => { - expect(mergeAllowedHosts([["a"], ["a", "b"]])).toEqual(["a", "b"]); - expect(mergeAllowedHosts([["a"], true, ["b"]])).toBe(true); - }); - - it("parses env var values", () => { - expect(parseAllowedHostsEnv(undefined)).toBeUndefined(); - expect(parseAllowedHostsEnv("")).toBeUndefined(); - expect(parseAllowedHostsEnv("true")).toBe(true); - expect(parseAllowedHostsEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]); - }); -}); diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index ccc42eccf9..9d1096aea6 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -50,7 +50,7 @@ describe("paseo daemon bootstrap", () => { listen: "127.0.0.1:0", paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: false, staticDir, mcpDebug: false, @@ -163,7 +163,7 @@ describe("paseo daemon bootstrap", () => { listen: socketPath, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: false, staticDir, mcpDebug: false, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index b224a674c0..f3eedfcaa3 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -119,7 +119,7 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride, } from "./agent/provider-launch-config.js"; -import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; +import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js"; type AgentMcpTransportMap = Map; @@ -162,7 +162,7 @@ export type PaseoDaemonConfig = { listen: string; paseoHome: string; corsAllowedOrigins: string[]; - allowedHosts?: AllowedHostsConfig; + hostnames?: HostnamesConfig; mcpEnabled?: boolean; mcpInjectIntoAgents?: boolean; staticDir: string; @@ -231,7 +231,7 @@ export async function createPaseoDaemon( if (listenTarget.type === "tcp") { app.use((req, res, next) => { const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined; - if (!isHostAllowed(hostHeader, config.allowedHosts)) { + if (!isHostnameAllowed(hostHeader, config.hostnames)) { res.status(403).json({ error: "Invalid Host header" }); return; } @@ -604,7 +604,7 @@ export async function createPaseoDaemon( config.paseoHome, daemonConfigStore, mcpBaseUrl, - { allowedOrigins, allowedHosts: config.allowedHosts }, + { allowedOrigins, hostnames: config.hostnames }, speechService, terminalManager, { diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 02fc0cf8e1..3115e63d70 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -11,11 +11,7 @@ import type { import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; import { AgentProviderSchema } from "./agent/provider-manifest.js"; import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; -import { - mergeAllowedHosts, - parseAllowedHostsEnv, - type AllowedHostsConfig, -} from "./allowed-hosts.js"; +import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js"; const DEFAULT_PORT = 6767; const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443"; @@ -42,7 +38,7 @@ export type CliConfigOverrides = Partial<{ relayEnabled: boolean; mcpEnabled: boolean; mcpInjectIntoAgents: boolean; - allowedHosts: AllowedHostsConfig; + hostnames: HostnamesConfig; }>; const OptionalVoiceLlmProviderSchema = z @@ -133,10 +129,10 @@ export function loadConfig( const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? []; - const allowedHosts = mergeAllowedHosts([ - persisted.daemon?.allowedHosts, - parseAllowedHostsEnv(env.PASEO_ALLOWED_HOSTS), - options?.cli?.allowedHosts, + const hostnames = mergeHostnames([ + persisted.daemon?.hostnames, + parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), + options?.cli?.hostnames, ]); const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true; @@ -181,7 +177,7 @@ export function loadConfig( corsAllowedOrigins: Array.from( new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)), ), - allowedHosts, + hostnames, mcpEnabled, mcpInjectIntoAgents, mcpDebug: env.MCP_DEBUG === "1", diff --git a/packages/server/src/server/hostnames.test.ts b/packages/server/src/server/hostnames.test.ts new file mode 100644 index 0000000000..88778f519e --- /dev/null +++ b/packages/server/src/server/hostnames.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { PersistedConfigSchema } from "./persisted-config.js"; +import { isHostnameAllowed, mergeHostnames, parseHostnamesEnv } from "./hostnames.js"; + +describe("hostnames (vite-style)", () => { + it("allows localhost by default", () => { + expect(isHostnameAllowed("localhost:6767", undefined)).toBe(true); + }); + + it("allows subdomains of .localhost by default", () => { + expect(isHostnameAllowed("foo.localhost:6767", undefined)).toBe(true); + }); + + it("allows IP addresses by default", () => { + expect(isHostnameAllowed("127.0.0.1:6767", undefined)).toBe(true); + expect(isHostnameAllowed("[::1]:6767", undefined)).toBe(true); + }); + + it("rejects non-default hosts when no allowlist is provided", () => { + expect(isHostnameAllowed("evil.com:6767", undefined)).toBe(false); + }); + + it("allows any host when set to true", () => { + expect(isHostnameAllowed("evil.com:6767", true)).toBe(true); + }); + + it("supports leading-dot patterns", () => { + const hostnames = [".example.com"]; + expect(isHostnameAllowed("example.com:6767", hostnames)).toBe(true); + expect(isHostnameAllowed("foo.example.com:6767", hostnames)).toBe(true); + expect(isHostnameAllowed("foo.bar.example.com:6767", hostnames)).toBe(true); + expect(isHostnameAllowed("notexample.com:6767", hostnames)).toBe(false); + }); + + it("merges arrays (append + de-dupe) and short-circuits on true", () => { + expect(mergeHostnames([["a"], ["a", "b"]])).toEqual(["a", "b"]); + expect(mergeHostnames([["a"], true, ["b"]])).toBe(true); + }); + + it("parses env var values", () => { + expect(parseHostnamesEnv(undefined)).toBeUndefined(); + expect(parseHostnamesEnv("")).toBeUndefined(); + expect(parseHostnamesEnv("true")).toBe(true); + expect(parseHostnamesEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]); + }); + + it("normalizes persisted allowedHosts into hostnames for backward compatibility", () => { + const parsed = PersistedConfigSchema.parse({ + daemon: { + allowedHosts: [".example.com"], + }, + }); + + expect(parsed.daemon?.hostnames).toEqual([".example.com"]); + }); +}); diff --git a/packages/server/src/server/allowed-hosts.ts b/packages/server/src/server/hostnames.ts similarity index 72% rename from packages/server/src/server/allowed-hosts.ts rename to packages/server/src/server/hostnames.ts index 6186b1fe34..0253305b27 100644 --- a/packages/server/src/server/allowed-hosts.ts +++ b/packages/server/src/server/hostnames.ts @@ -1,6 +1,6 @@ import net from "node:net"; -export type AllowedHostsConfig = true | string[] | undefined; +export type HostnamesConfig = true | string[] | undefined; function normalizeHostname(hostname: string): string { return hostname.trim().toLowerCase(); @@ -25,7 +25,7 @@ function parseHostnameFromHostHeader(hostHeader: string): string | null { return normalizeHostname(trimmed.slice(0, colonIndex)); } -function matchesAllowedHostPattern(hostname: string, pattern: string): boolean { +function matchesHostnamePattern(hostname: string, pattern: string): boolean { const normalizedPattern = normalizeHostname(pattern); if (!normalizedPattern) return false; @@ -47,33 +47,33 @@ function isDefaultAllowedHostname(hostname: string): boolean { } /** - * Vite-style allowed hosts check, adapted to raw Host headers. + * Vite-style hostname allowlist check, adapted to raw Host headers. * * Semantics: - * - `allowedHosts === true` => allow any host. - * - `allowedHosts === []` or `undefined` => allow localhost, *.localhost, and all IPs. - * - `allowedHosts === ['.example.com', 'myhost']` => allow those *in addition* to defaults. + * - `hostnames === true` => allow any host. + * - `hostnames === []` or `undefined` => allow localhost, *.localhost, and all IPs. + * - `hostnames === ['.example.com', 'myhost']` => allow those *in addition* to defaults. */ -export function isHostAllowed( +export function isHostnameAllowed( hostHeader: string | undefined, - allowedHosts: AllowedHostsConfig, + hostnames: HostnamesConfig, ): boolean { const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null; if (!hostname) return false; - if (allowedHosts === true) return true; + if (hostnames === true) return true; // Defaults are always allowed. if (isDefaultAllowedHostname(hostname)) return true; - const patterns = allowedHosts ?? []; + const patterns = hostnames ?? []; for (const pattern of patterns) { - if (matchesAllowedHostPattern(hostname, pattern)) return true; + if (matchesHostnamePattern(hostname, pattern)) return true; } return false; } -export function mergeAllowedHosts(values: Array): AllowedHostsConfig { +export function mergeHostnames(values: Array): HostnamesConfig { let merged: string[] = []; for (const value of values) { if (value === true) return true; @@ -85,7 +85,7 @@ export function mergeAllowedHosts(values: Array): AllowedHos return deduped; } -export function parseAllowedHostsEnv(raw: string | undefined): AllowedHostsConfig { +export function parseHostnamesEnv(raw: string | undefined): HostnamesConfig { if (!raw) return undefined; const trimmed = raw.trim(); if (!trimmed) return undefined; diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 5fd0483a21..362369932b 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -228,6 +228,7 @@ export const PersistedConfigSchema = z daemon: z .object({ listen: z.string().optional(), + hostnames: z.union([z.literal(true), z.array(z.string())]).optional(), allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(), mcp: z .object({ @@ -252,6 +253,10 @@ export const PersistedConfigSchema = z .optional(), }) .strict() + .transform(({ allowedHosts, ...daemon }) => { + const hostnames = daemon.hostnames ?? allowedHosts; + return hostnames === undefined ? daemon : { ...daemon, hostnames }; + }) .optional(), app: z diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 902dcb0f84..c34fa0a3d5 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -85,7 +85,7 @@ export async function createTestPaseoDaemon( listen: `${listenHost}:0`, paseoHome, corsAllowedOrigins: options.corsAllowedOrigins ?? [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 637a1902b4..5e1a10e730 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -23,8 +23,8 @@ import { wrapSessionMessage, } from "./messages.js"; import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js"; -import type { AllowedHostsConfig } from "./allowed-hosts.js"; -import { isHostAllowed } from "./allowed-hosts.js"; +import type { HostnamesConfig } from "./hostnames.js"; +import { isHostnameAllowed } from "./hostnames.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { @@ -61,7 +61,7 @@ type PendingConnection = { type WebSocketServerConfig = { allowedOrigins: Set; - allowedHosts?: AllowedHostsConfig; + hostnames?: HostnamesConfig; }; type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; @@ -433,7 +433,7 @@ export class VoiceAssistantWebSocketServer { this.broadcastAgentAttention(params); }); - const { allowedOrigins, allowedHosts } = wsConfig; + const { allowedOrigins, hostnames } = wsConfig; this.wss = new WebSocketServer({ server, path: "/ws", @@ -441,7 +441,7 @@ export class VoiceAssistantWebSocketServer { const requestMetadata = extractSocketRequestMetadata(req); const origin = requestMetadata.origin; const requestHost = requestMetadata.host ?? null; - if (requestHost && !isHostAllowed(requestHost, allowedHosts)) { + if (requestHost && !isHostnameAllowed(requestHost, hostnames)) { this.incrementRuntimeCounter("hostRejected"); this.logger.warn( { ...requestMetadata, host: requestHost }, diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index a4e12119ac..c9b74032a7 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -14,7 +14,22 @@ "listen": { "type": "string" }, + "hostnames": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, "allowedHosts": { + "description": "Deprecated: use hostnames instead", "anyOf": [ { "type": "boolean", diff --git a/packages/website/src/routes/docs/configuration.tsx b/packages/website/src/routes/docs/configuration.tsx index 1aac1f2a68..368ae6157a 100644 --- a/packages/website/src/routes/docs/configuration.tsx +++ b/packages/website/src/routes/docs/configuration.tsx @@ -51,8 +51,7 @@ function Configuration() {
  • CLI flags
  • - Lists append across sources (for example, allowedHosts{" "} - and + Lists append across sources (for example, hostnames and cors.allowedOrigins).

    @@ -60,7 +59,7 @@ function Configuration() {

    Example

    - Minimal example that configures listening address, host allowlist, provider keys, and MCP: + Minimal example that configures listening address, hostnames, provider keys, and MCP:

               {`{
    @@ -71,11 +70,16 @@ function Configuration() {
       },
       "daemon": {
         "listen": "127.0.0.1:6767",
    -    "allowedHosts": ["localhost", ".localhost"],
    +    "hostnames": ["localhost", ".localhost"],
         "mcp": { "enabled": true }
       }
     }`}
             
    +

    + daemon.hostnames is the primary field. The old{" "} + daemon.allowedHosts name still works as a deprecated + alias for backward compatibility. +

    @@ -229,8 +233,12 @@ docker run --rm -i \\ daemon.listen
  • - PASEO_ALLOWED_HOSTS — override/extend{" "} - daemon.allowedHosts + PASEO_HOSTNAMES — override/extend{" "} + daemon.hostnames +
  • +
  • + PASEO_ALLOWED_HOSTS — deprecated alias for{" "} + PASEO_HOSTNAMES
  • PASEO_LOG_CONSOLE_LEVEL — override{" "} diff --git a/packages/website/src/routes/docs/security.tsx b/packages/website/src/routes/docs/security.tsx index d148331cfa..5b0d03f789 100644 --- a/packages/website/src/routes/docs/security.tsx +++ b/packages/website/src/routes/docs/security.tsx @@ -185,7 +185,7 @@ function Security() { 100.x.y.z:6767)
  • - Add your Tailscale hostname to allowedHosts and{" "} + Add your Tailscale hostname to hostnames and{" "} cors.allowedOrigins
  • @@ -199,7 +199,7 @@ function Security() { daemon reachable on all network interfaces, including public Wi-Fi and local networks. This can expose your daemon to unauthorized access. If you must bind to all interfaces, ensure you have proper firewall rules and review your{" "} - allowedHosts configuration. + hostnames configuration.
  • @@ -216,7 +216,7 @@ function Security() { on incoming requests. Requests with unrecognized hosts are rejected.

    - Configure via daemon.allowedHosts in{" "} + Configure via daemon.hostnames in{" "} config.json: