diff --git a/docs/data-model.md b/docs/data-model.md index 7c635ad9b..0fb1dc8c5 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -150,6 +150,7 @@ Single file, validated with `PersistedConfigSchema`. daemon: { listen: "127.0.0.1:6767", hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load + trustedProxies: true | string[], // defaults to ["loopback"]; Express proxy names/CIDRs mcp: { enabled: boolean, injectIntoAgents: boolean }, appendSystemPrompt: string, // appended to supported provider system/developer prompts cors: { allowedOrigins: string[] }, diff --git a/docs/service-proxy.md b/docs/service-proxy.md index 6e11f3e99..6f4436aff 100644 --- a/docs/service-proxy.md +++ b/docs/service-proxy.md @@ -67,6 +67,19 @@ For generated URLs to be reachable, you need wildcard DNS pointing to the machin Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services. +If the same reverse proxy serves the daemon web UI over HTTPS, it must also set `X-Forwarded-Proto` so the web UI can auto-connect with `wss://`. The daemon trusts forwarded headers from loopback proxies by default. If your proxy reaches the daemon from another address, configure the proxy ranges explicitly: + +```json +{ + "version": 1, + "daemon": { + "trustedProxies": ["loopback", "172.16.0.0/12"] + } +} +``` + +`PASEO_TRUSTED_PROXIES` accepts the same comma-separated values, for example `loopback,172.16.0.0/12`. Use `true` only when the final trusted proxy overwrites client-supplied `X-Forwarded-*` headers. + Nginx example: ```nginx @@ -77,6 +90,7 @@ server { location / { proxy_pass http://10.1.1.1:8080; proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; } } ``` diff --git a/packages/server/src/server/bootstrap-web-ui.test.ts b/packages/server/src/server/bootstrap-web-ui.test.ts new file mode 100644 index 000000000..348a6cf0e --- /dev/null +++ b/packages/server/src/server/bootstrap-web-ui.test.ts @@ -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; +}): Promise { + 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__=(?\{[^<]+})<\/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 { + 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"), + "app", + ); + 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(), + }); + }); +}); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 3816cec18..87690c564 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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({ diff --git a/packages/server/src/server/config-relay.test.ts b/packages/server/src/server/config-relay.test.ts index c3e8fb31c..f2e4744e5 100644 --- a/packages/server/src/server/config-relay.test.ts +++ b/packages/server/src/server/config-relay.test.ts @@ -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 }))); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index e567eb4e1..d8987bb80 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -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, @@ -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, +): 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, diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 94571d3ce..b66ec6b51 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -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({ diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index b141465fd..48a19e97d 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -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(), diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 39bf3bbc1..58c2ebd9b 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -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, diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index cd2e750af..a1b6115b7 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -45,6 +45,21 @@ } ] }, + "trustedProxies": { + "description": "Express trusted proxy setting for X-Forwarded-* headers. Defaults to [\"loopback\"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.", + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, "mcp": { "type": "object", "properties": { @@ -388,6 +403,19 @@ } }, "additionalProperties": false + }, + "webUi": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "distDir": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/public-docs/configuration.md b/public-docs/configuration.md index 3d66bf91c..3bae59923 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -164,6 +164,9 @@ In the mobile app, enter the password in the direct connection setup screen. - `PASEO_LISTEN`, override `daemon.listen` - `PASEO_HOSTNAMES`, override/extend `daemon.hostnames` - `PASEO_ALLOWED_HOSTS`, deprecated alias for `PASEO_HOSTNAMES` +- `PASEO_WEB_UI_ENABLED`, enable or disable the daemon-served web UI +- `PASEO_WEB_UI_DIST_DIR`, override the daemon web UI build directory +- `PASEO_TRUSTED_PROXIES`, configure trusted reverse proxy ranges for `X-Forwarded-*` headers - `PASEO_LOG_CONSOLE_LEVEL`, override `log.console.level` - `PASEO_LOG_FILE_LEVEL`, override `log.file.level` - `PASEO_LOG_FILE_PATH`, override `log.file.path` diff --git a/public-docs/web-ui.md b/public-docs/web-ui.md index 1daa0959d..eecd57c1f 100644 --- a/public-docs/web-ui.md +++ b/public-docs/web-ui.md @@ -164,9 +164,27 @@ That's the whole config. Caddy provisions a certificate automatically and preser Terminate TLS at the proxy (or tunnel) and forward to the daemon over plain HTTP on localhost, that's what the configs above do. When the page is served over HTTPS and the proxy passes `X-Forwarded-Proto: https`, the app automatically connects back over `wss://`. You don't configure the scheme anywhere; it follows the edge. -The daemon honors the forwarded scheme only from a proxy on the **same host** (a loopback connection), which is what all the setups above do, the proxy or tunnel forwards to `127.0.0.1:6767`. Keep the proxy co-located with the daemon. If you must run the proxy on a different machine, the daemon won't trust its forwarded scheme; in that case add the daemon as a host manually in the UI with TLS enabled. +The daemon trusts forwarded headers from loopback proxies by default, which is what all the setups above do, the proxy or tunnel forwards to `127.0.0.1:6767`. -If you serve the UI over HTTPS but the app tries to connect over `ws://` (and the browser blocks it as mixed content), your proxy isn't on the daemon's host or isn't forwarding `X-Forwarded-Proto`. Fix whichever applies. +If your proxy reaches the daemon from another address, as in some Docker, LAN, or load-balancer setups, configure the trusted proxy ranges: + +```json +{ + "daemon": { + "trustedProxies": ["loopback", "172.16.0.0/12"] + } +} +``` + +`PASEO_TRUSTED_PROXIES` accepts the same comma-separated values: + +```bash +PASEO_TRUSTED_PROXIES=loopback,172.16.0.0/12 paseo daemon start --web-ui +``` + +Only use `trustedProxies: true` when your final trusted proxy overwrites client-supplied `X-Forwarded-*` headers. Otherwise a client could spoof forwarded header values. + +If you serve the UI over HTTPS but the app tries to connect over `ws://` (and the browser blocks it as mixed content), your proxy isn't forwarding `X-Forwarded-Proto` or the daemon doesn't trust the proxy address. Fix whichever applies. For the remote/relay path (driving a daemon through the Paseo relay rather than a reverse proxy), the relay has its own public-vs-internal TLS settings, see [Security](/docs/security). @@ -206,7 +224,7 @@ For the full threat model, relay encryption, and DNS-rebinding details, see [Sec - **Blank page or 404 at `/`.** The web UI isn't enabled. Start the daemon with `--web-ui` and confirm with `paseo daemon status` that it's the daemon you're hitting. - **Page loads but never connects.** The proxy isn't forwarding the WebSocket upgrade, or it's stripping the `Host` header. Check the upgrade headers in your proxy config. - **Connects, then output freezes.** Response buffering is on, or read timeouts are too short. Disable buffering and raise the timeouts. -- **"Mixed content" / connection blocked over HTTPS.** The app fell back to `ws://`. Either the proxy isn't sending `X-Forwarded-Proto: https`, or it isn't running on the daemon's host (the daemon only trusts the forwarded scheme from a loopback proxy). Co-locate the proxy with the daemon and forward the header. +- **"Mixed content" / connection blocked over HTTPS.** The app fell back to `ws://`. Either the proxy isn't sending `X-Forwarded-Proto: https`, or the daemon doesn't trust the proxy address. Forward the header and configure `daemon.trustedProxies` if the proxy is not loopback. - **`403 Invalid Host header`.** Your domain isn't in the allowlist. Add it with `--hostnames` or `daemon.hostnames`, see [DNS rebinding protection](/docs/security#dns-rebinding-protection). - **Large prompts or uploads fail.** Raise the proxy's max body size (`client_max_body_size` in Nginx).