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
This commit is contained in:
Mohamed Boudra
2026-04-15 12:47:49 +08:00
committed by GitHub
parent 89b87c0235
commit 7a8f65805d
25 changed files with 223 additions and 133 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 }
+10 -6
View File
@@ -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 = {
+1 -1
View File
@@ -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).
+21 -4
View File
@@ -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 <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.action(withOutput(runDaemonRestartCommand));
.addOption(new Option("--allowed-hosts <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());
+21 -4
View File
@@ -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 <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.action(withOutput(runRestartCommand));
.addOption(new Option("--allowed-hosts <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;
}
@@ -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);
+1 -1
View File
@@ -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) {
+13 -5
View File
@@ -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 <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'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 <hosts>").hideHelp())
.action(async (options: RawStartCommandOptions) => {
await runStart({
...options,
hostnames: options.hostnames ?? options.allowedHosts,
});
});
}
+17 -9
View File
@@ -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 <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
"--hostnames <hosts>",
'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)',
)
.addOption(new Option("--allowed-hosts <hosts>").hideHelp())
.option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)")
.option("--voice <mode>", "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,
});
});
}
@@ -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,
@@ -42,7 +42,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
logger,
});
let allowedHosts: string[] | undefined;
let mcpAllowedHosts: string[] | undefined;
const agentMcpTransports = new Map<string, StreamableHTTPServerTransport>();
const createAgentMcpTransport = async (callerAgentId?: string) => {
@@ -62,7 +62,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
agentMcpTransports.delete(sessionId);
},
enableDnsRebindingProtection: true,
...(allowedHosts ? { allowedHosts } : {}),
...(mcpAllowedHosts ? { allowedHosts: mcpAllowedHosts } : {}),
});
transport.onclose = () => {
@@ -129,7 +129,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise<AgentMcpServerH
});
});
allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
mcpAllowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
const url = `http://127.0.0.1:${port}/mcp/agents`;
return {
@@ -1,45 +0,0 @@
import { describe, it, expect } from "vitest";
import { isHostAllowed, mergeAllowedHosts, parseAllowedHostsEnv } from "./allowed-hosts.js";
describe("allowed hosts (vite-style)", () => {
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"]);
});
});
@@ -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,
+4 -4
View File
@@ -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<string, StreamableHTTPServerTransport>;
@@ -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,
{
+7 -11
View File
@@ -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",
@@ -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"]);
});
});
@@ -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>): AllowedHostsConfig {
export function mergeHostnames(values: Array<HostnamesConfig>): HostnamesConfig {
let merged: string[] = [];
for (const value of values) {
if (value === true) return true;
@@ -85,7 +85,7 @@ export function mergeAllowedHosts(values: Array<AllowedHostsConfig>): 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;
@@ -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
@@ -85,7 +85,7 @@ export async function createTestPaseoDaemon(
listen: `${listenHost}:0`,
paseoHome,
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
allowedHosts: true,
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
@@ -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<string>;
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 },
@@ -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",
@@ -51,8 +51,7 @@ function Configuration() {
<li>CLI flags</li>
</ol>
<p className="text-white/60 leading-relaxed">
Lists append across sources (for example, <code className="font-mono">allowedHosts</code>{" "}
and
Lists append across sources (for example, <code className="font-mono">hostnames</code> and
<code className="font-mono">cors.allowedOrigins</code>).
</p>
</section>
@@ -60,7 +59,7 @@ function Configuration() {
<section className="space-y-4">
<h2 className="text-xl font-medium">Example</h2>
<p className="text-white/60 leading-relaxed">
Minimal example that configures listening address, host allowlist, provider keys, and MCP:
Minimal example that configures listening address, hostnames, provider keys, and MCP:
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
@@ -71,11 +70,16 @@ function Configuration() {
},
"daemon": {
"listen": "127.0.0.1:6767",
"allowedHosts": ["localhost", ".localhost"],
"hostnames": ["localhost", ".localhost"],
"mcp": { "enabled": true }
}
}`}
</pre>
<p className="text-white/60 leading-relaxed">
<code className="font-mono">daemon.hostnames</code> is the primary field. The old{" "}
<code className="font-mono">daemon.allowedHosts</code> name still works as a deprecated
alias for backward compatibility.
</p>
</section>
<section className="space-y-4">
@@ -229,8 +233,12 @@ docker run --rm -i \\
<code className="font-mono">daemon.listen</code>
</li>
<li>
<code className="font-mono">PASEO_ALLOWED_HOSTS</code> — override/extend{" "}
<code className="font-mono">daemon.allowedHosts</code>
<code className="font-mono">PASEO_HOSTNAMES</code> — override/extend{" "}
<code className="font-mono">daemon.hostnames</code>
</li>
<li>
<code className="font-mono">PASEO_ALLOWED_HOSTS</code> — deprecated alias for{" "}
<code className="font-mono">PASEO_HOSTNAMES</code>
</li>
<li>
<code className="font-mono">PASEO_LOG_CONSOLE_LEVEL</code> — override{" "}
@@ -185,7 +185,7 @@ function Security() {
<code className="font-mono">100.x.y.z:6767</code>)
</li>
<li>
Add your Tailscale hostname to <code className="font-mono">allowedHosts</code> and{" "}
Add your Tailscale hostname to <code className="font-mono">hostnames</code> and{" "}
<code className="font-mono">cors.allowedOrigins</code>
</li>
<li>
@@ -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{" "}
<code className="font-mono">allowedHosts</code> configuration.
<code className="font-mono">hostnames</code> configuration.
</div>
</section>
@@ -216,7 +216,7 @@ function Security() {
on incoming requests. Requests with unrecognized hosts are rejected.
</p>
<p className="text-white/60 leading-relaxed">
Configure via <code className="font-mono">daemon.allowedHosts</code> in{" "}
Configure via <code className="font-mono">daemon.hostnames</code> in{" "}
<code className="font-mono">config.json</code>:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">