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: