fix(server): honor X-Forwarded-Proto so daemon web UI auto-connects behind HTTPS reverse proxy (#1739)

* fix(server): honor forwarded proto for daemon web UI

* fix(server): configure trusted daemon proxies

* docs: document daemon web UI reverse proxy setup

* docs: consolidate web UI proxy guidance
This commit is contained in:
Mohamed Boudra
2026-06-26 13:26:50 +08:00
committed by GitHub
parent c5c2ace698
commit 8c3b709794
12 changed files with 325 additions and 3 deletions
@@ -0,0 +1,138 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
interface InitialDaemonConnectionHint {
listen: string;
useTls: boolean;
label: string;
}
function fetchDaemonWebUi(options: {
port: number;
headers?: Record<string, string>;
}): Promise<string> {
return new Promise((resolve, reject) => {
const req = http.get(
{
hostname: "127.0.0.1",
port: options.port,
path: "/",
headers: {
host: `daemon.example.test:${options.port}`,
...options.headers,
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
if (res.statusCode !== 200) {
reject(new Error(`Expected 200 from web UI, got ${res.statusCode ?? 0}`));
return;
}
resolve(Buffer.concat(chunks).toString("utf-8"));
});
},
);
req.on("error", reject);
});
}
function readInjectedConnectionHint(html: string): InitialDaemonConnectionHint {
const match = html.match(
/window\.__PASEO_INITIAL_DAEMON_CONNECTION__=(?<json>\{[^<]+})<\/script>/,
);
if (!match?.groups?.json) {
throw new Error("Missing initial daemon connection hint");
}
return JSON.parse(match.groups.json) as InitialDaemonConnectionHint;
}
describe("daemon web UI bootstrap", () => {
let tempRoot: string | null = null;
let daemonHandle: TestPaseoDaemon | null = null;
async function createWebUiDist(): Promise<string> {
tempRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-web-ui-"));
const distDir = path.join(tempRoot, "dist");
await mkdir(distDir, { recursive: true });
await writeFile(
path.join(distDir, "index.html"),
"<!DOCTYPE html><html><head></head><body>app</body></html>",
);
return distDir;
}
afterEach(async () => {
await daemonHandle?.close();
daemonHandle = null;
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
}
});
test("injects a TLS initial connection hint only for HTTPS forwarded by a trusted proxy", async () => {
const distDir = await createWebUiDist();
daemonHandle = await createTestPaseoDaemon({
mcpEnabled: false,
webUi: {
enabled: true,
distDir,
},
});
const httpHint = readInjectedConnectionHint(
await fetchDaemonWebUi({ port: daemonHandle.port }),
);
const httpsHint = readInjectedConnectionHint(
await fetchDaemonWebUi({
port: daemonHandle.port,
headers: { "x-forwarded-proto": "https" },
}),
);
expect(httpHint).toEqual({
listen: `daemon.example.test:${daemonHandle.port}`,
useTls: false,
label: os.hostname(),
});
expect(httpsHint).toEqual({
listen: `daemon.example.test:${daemonHandle.port}`,
useTls: true,
label: os.hostname(),
});
});
test("ignores forwarded HTTPS when proxy trust is disabled", async () => {
const distDir = await createWebUiDist();
daemonHandle = await createTestPaseoDaemon({
mcpEnabled: false,
trustedProxies: [],
webUi: {
enabled: true,
distDir,
},
});
const httpsHint = readInjectedConnectionHint(
await fetchDaemonWebUi({
port: daemonHandle.port,
headers: { "x-forwarded-proto": "https" },
}),
);
expect(httpsHint).toEqual({
listen: `daemon.example.test:${daemonHandle.port}`,
useTls: false,
label: os.hostname(),
});
});
});
+6
View File
@@ -326,6 +326,7 @@ export interface PaseoDaemonConfig {
corsAllowedOrigins: string[];
allowedHosts?: HostnamesConfig;
hostnames?: HostnamesConfig;
trustedProxies?: true | string[];
mcpEnabled?: boolean;
mcpInjectIntoAgents?: boolean;
autoArchiveAfterMerge?: boolean;
@@ -423,6 +424,10 @@ function mountWebUi(app: express.Application, config: PaseoDaemonConfig, logger:
);
}
function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | string[] {
return config.trustedProxies ?? ["loopback"];
}
export async function createPaseoDaemon(
config: PaseoDaemonConfig,
rootLogger: Logger,
@@ -487,6 +492,7 @@ export async function createPaseoDaemon(
const listenTarget = parseListenString(config.listen);
const app = express();
app.set("trust proxy", resolveExpressTrustProxySetting(config));
let boundListenTarget: ListenTarget | null = null;
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
const terminalManager = createConfiguredTerminalManager({
@@ -150,6 +150,56 @@ describe("daemon service proxy config", () => {
});
});
describe("daemon trusted proxy config", () => {
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("trusts loopback proxies by default", async () => {
const home = await createPaseoHome({ version: 1 });
expect(loadConfig(home, { env: {} }).trustedProxies).toEqual(["loopback"]);
});
test("loads trusted proxies from persisted config", async () => {
const home = await createPaseoHome({
version: 1,
daemon: {
trustedProxies: ["loopback", "10.0.0.0/8"],
},
});
expect(loadConfig(home, { env: {} }).trustedProxies).toEqual(["loopback", "10.0.0.0/8"]);
});
test("PASEO_TRUSTED_PROXIES overrides persisted config", async () => {
const home = await createPaseoHome({
version: 1,
daemon: {
trustedProxies: ["loopback"],
},
});
const config = loadConfig(home, {
env: { PASEO_TRUSTED_PROXIES: "loopback,172.16.0.0/12" },
});
expect(config.trustedProxies).toEqual(["loopback", "172.16.0.0/12"]);
});
test("PASEO_TRUSTED_PROXIES supports explicit trust-all and trust-none modes", async () => {
const trustAllHome = await createPaseoHome({ version: 1 });
expect(
loadConfig(trustAllHome, { env: { PASEO_TRUSTED_PROXIES: "true" } }).trustedProxies,
).toBe(true);
const trustNoneHome = await createPaseoHome({ version: 1 });
expect(
loadConfig(trustNoneHome, { env: { PASEO_TRUSTED_PROXIES: "false" } }).trustedProxies,
).toEqual([]);
});
});
describe("daemon worktree root config", () => {
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
+37
View File
@@ -25,6 +25,7 @@ import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostn
const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
const DEFAULT_APP_BASE_URL = "https://app.paseo.sh";
const DEFAULT_TRUSTED_PROXIES = ["loopback"];
export function resolveBundledWebUiDistDir(moduleUrl: string | URL = import.meta.url): string {
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
@@ -72,6 +73,8 @@ export type CliConfigOverrides = Partial<{
hostnames: HostnamesConfig;
}>;
type TrustedProxiesConfig = true | string[];
function resolveLogConfigFromEnv(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
@@ -307,6 +310,37 @@ function resolveCorsAllowedOrigins(
);
}
function parseTrustedProxiesEnv(value: string | undefined): TrustedProxiesConfig | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
const normalized = trimmed.toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return [];
}
return trimmed
.split(",")
.map((proxy) => proxy.trim())
.filter((proxy) => proxy.length > 0);
}
function resolveTrustedProxiesConfig(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): TrustedProxiesConfig {
return (
parseTrustedProxiesEnv(env.PASEO_TRUSTED_PROXIES) ??
persisted.daemon?.trustedProxies ??
DEFAULT_TRUSTED_PROXIES
);
}
// PASEO_LISTEN can be:
// - host:port (TCP)
// - /path/to/socket (Unix socket)
@@ -374,6 +408,7 @@ function resolveStaticLoadConfigSettings(
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
cli?.hostnames,
]),
trustedProxies: resolveTrustedProxiesConfig(env, persisted),
appBaseUrl: env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL,
};
}
@@ -396,6 +431,7 @@ export function loadConfig(
appendSystemPrompt,
terminalProfiles,
hostnames,
trustedProxies,
appBaseUrl,
} = resolveStaticLoadConfigSettings(env, options?.cli, persisted);
@@ -425,6 +461,7 @@ export function loadConfig(
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
hostnames,
trustedProxies,
mcpEnabled,
mcpInjectIntoAgents,
autoArchiveAfterMerge,
@@ -63,6 +63,28 @@ describe("PersistedConfigSchema daemon relay config", () => {
});
});
describe("PersistedConfigSchema daemon trusted proxy config", () => {
test("accepts optional trusted proxy ranges", () => {
const parsed = PersistedConfigSchema.parse({
daemon: {
trustedProxies: ["loopback", "172.16.0.0/12"],
},
});
expect(parsed.daemon?.trustedProxies).toEqual(["loopback", "172.16.0.0/12"]);
});
test("accepts explicit trust-all proxy config", () => {
const parsed = PersistedConfigSchema.parse({
daemon: {
trustedProxies: true,
},
});
expect(parsed.daemon?.trustedProxies).toBe(true);
});
});
describe("PersistedConfigSchema daemon web UI feature config", () => {
test("accepts optional web UI enable flag and dist dir", () => {
const parsed = PersistedConfigSchema.parse({
@@ -231,6 +231,7 @@ export const PersistedConfigSchema = z
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(),
trustedProxies: z.union([z.literal(true), z.array(z.string())]).optional(),
mcp: z
.object({
enabled: z.boolean().optional(),
@@ -37,6 +37,8 @@ interface TestPaseoDaemonOptions {
auth?: PaseoDaemonConfig["auth"];
pushNotificationSender?: PushNotificationSender;
serviceProxy?: PaseoDaemonConfig["serviceProxy"];
webUi?: PaseoDaemonConfig["webUi"];
trustedProxies?: PaseoDaemonConfig["trustedProxies"];
}
export interface TestPaseoDaemon {
@@ -168,6 +170,8 @@ async function prepareTestDaemonConfig(
auth: options.auth,
pushNotificationSender: options.pushNotificationSender,
serviceProxy: options.serviceProxy,
webUi: options.webUi,
trustedProxies: options.trustedProxies,
openai: options.openai,
speech: options.speech,
voiceLlmProvider: options.voiceLlmProvider ?? null,