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 6ad5fdeb2..d582f9cfd 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 30c0d2e65..6918bb727 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 86fb7306a..f839afb51 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 72a387614..022fc9eb8 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 5eef96dc3..dd0909a3f 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 5da470821..cb4baceec 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 5cb4f5b80..d9ef61297 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 7d00a3271..812a48ae4 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 82df597f5..741057f6a 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 1e5cd2264..7a3e5991e 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 a9d4168d3..480e857a7 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 ad95ce1b6..16ff84152 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 da63dae7f..8b5c3aa71 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 ccc42eccf..9d1096aea 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 b224a674c..f3eedfcaa 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 02fc0cf8e..3115e63d7 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 000000000..88778f519 --- /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 6186b1fe3..0253305b2 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 5fd0483a2..362369932 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 902dcb0f8..c34fa0a3d 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 637a1902b..5e1a10e73 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 a4e12119a..c9b74032a 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 1aac1f2a6..368ae6157 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 d148331cf..5b0d03f78 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: