Files
paseo/packages/server/src/server/hostnames.ts
T
Mohamed Boudra 7a8f65805d 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
2026-04-15 12:47:49 +08:00

98 lines
2.9 KiB
TypeScript

import net from "node:net";
export type HostnamesConfig = true | string[] | undefined;
function normalizeHostname(hostname: string): string {
return hostname.trim().toLowerCase();
}
function parseHostnameFromHostHeader(hostHeader: string): string | null {
const trimmed = hostHeader.trim();
if (!trimmed) return null;
// IPv6 in brackets: [::1]:6767
if (trimmed.startsWith("[")) {
const end = trimmed.indexOf("]");
if (end === -1) return null;
return normalizeHostname(trimmed.slice(1, end));
}
// IPv4/hostname with optional port: localhost:6767
const colonIndex = trimmed.indexOf(":");
if (colonIndex === -1) {
return normalizeHostname(trimmed);
}
return normalizeHostname(trimmed.slice(0, colonIndex));
}
function matchesHostnamePattern(hostname: string, pattern: string): boolean {
const normalizedPattern = normalizeHostname(pattern);
if (!normalizedPattern) return false;
if (normalizedPattern.startsWith(".")) {
const base = normalizedPattern.slice(1);
if (!base) return false;
return hostname === base || hostname.endsWith(`.${base}`);
}
return hostname === normalizedPattern;
}
function isDefaultAllowedHostname(hostname: string): boolean {
// Vite-style defaults: localhost, *.localhost, and all IP addresses.
if (hostname === "localhost") return true;
if (hostname.endsWith(".localhost")) return true;
if (net.isIP(hostname) !== 0) return true;
return false;
}
/**
* Vite-style hostname allowlist check, adapted to raw Host headers.
*
* Semantics:
* - `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 isHostnameAllowed(
hostHeader: string | undefined,
hostnames: HostnamesConfig,
): boolean {
const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null;
if (!hostname) return false;
if (hostnames === true) return true;
// Defaults are always allowed.
if (isDefaultAllowedHostname(hostname)) return true;
const patterns = hostnames ?? [];
for (const pattern of patterns) {
if (matchesHostnamePattern(hostname, pattern)) return true;
}
return false;
}
export function mergeHostnames(values: Array<HostnamesConfig>): HostnamesConfig {
let merged: string[] = [];
for (const value of values) {
if (value === true) return true;
if (!value) continue;
merged = merged.concat(value);
}
const deduped = Array.from(new Set(merged.map((v) => v.trim()).filter((v) => v.length > 0)));
return deduped;
}
export function parseHostnamesEnv(raw: string | undefined): HostnamesConfig {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
if (trimmed.toLowerCase() === "true") return true;
return trimmed
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}