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: