diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1bc7436..5ec355b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -173,6 +173,54 @@ Read the value on the server with: grep session_token ~/.quadwork/config.json ``` +### Reverse proxy (nginx + Basic Auth): `trusted_dashboard_hosts` — #988 + +If you serve the dashboard through an **on-box, authenticated reverse proxy** — +e.g. the [VPS guide](install-vps.md) nginx setup terminating HTTP Basic Auth and +proxying `https://p7.quadwork.xyz` to `127.0.0.1:8400` — the browser's `Host` +and `Origin` are the public domain, so `GET /api/session-token` refuses them and +every terminal WebSocket closes with code `1006`. Rather than set the token by +hand in each browser, allowlist the proxied host so the dashboard can fetch the +token itself. + +Add the public host(s) to `~/.quadwork/config.json` and restart QuadWork: + +```jsonc +{ + "port": 8400, + "trusted_dashboard_hosts": ["p7.quadwork.xyz"] + // ... +} +``` + +With this set, `GET /api/session-token` and the WS upgrade accept a request +**only** when *both*: (1) the socket is loopback — i.e. the request genuinely +arrived via the local proxy, not directly off-box — **and** (2) the forwarded +`Host` and `Origin` are in the allowlist. Anything else (a foreign domain, a +DNS-rebinding page, an un-allowlisted proxy) still gets `403`, so #968's +protections are unchanged. The allowlist is **opt-in**: with it unset (the +default) behaviour is exactly loopback-only as before. + +> [!IMPORTANT] +> The reverse proxy **must** be authenticated (e.g. nginx Basic Auth) and bound +> so QuadWork only ever receives proxied traffic on loopback. The allowlist +> tells QuadWork to trust the proxy to have already authenticated the user — it +> is not itself an authentication layer. Your nginx `server` block must forward +> the real host and origin, e.g.: +> +> ```nginx +> location / { +> auth_basic "QuadWork"; +> auth_basic_user_file /etc/nginx/.htpasswd; +> proxy_pass http://127.0.0.1:8400; +> proxy_set_header Host $host; +> proxy_set_header Origin $http_origin; +> proxy_http_version 1.1; +> proxy_set_header Upgrade $http_upgrade; # terminal WebSocket +> proxy_set_header Connection $connection_upgrade; +> } +> ``` + **Security note:** keep `session_token` private and keep QuadWork behind your existing network controls (127.0.0.1 bind + SSH tunnel, tailnet ACL, or the nginx Basic Auth from the VPS guide). The token + Origin allowlist are diff --git a/server/index.js b/server/index.js index 35ed3e1..58ae4de 100644 --- a/server/index.js +++ b/server/index.js @@ -39,6 +39,30 @@ if (!SESSION_TOKEN) { } } +// #988: opt-in allowlist of trusted reverse-proxy dashboard hosts. When the +// dashboard is served through a LOCAL, AUTHENTICATED reverse proxy (e.g. nginx +// terminating basic-auth and proxying p7.quadwork.xyz -> 127.0.0.1:8400 with +// `proxy_set_header Host $host`), the forwarded Host/Origin is a REMOTE name so +// #968's strict loopback checks 403 the token fetch and every terminal WS dies. +// Configuring `trusted_dashboard_hosts: ["p7.quadwork.xyz"]` lets those (and +// ONLY those) forwarded names through, but ONLY when the socket itself is +// loopback (the request truly arrived via the on-box proxy). Empty/unset (the +// default) → exactly #968's loopback-only behavior, no change. The proxy MUST +// be authenticated; see docs/troubleshooting.md. +const TRUSTED_DASHBOARD_HOSTS = normalizeTrustedDashboardHosts(config.trusted_dashboard_hosts); + +function normalizeTrustedDashboardHosts(raw) { + const out = new Set(); + if (!Array.isArray(raw)) return out; + for (const entry of raw) { + if (typeof entry !== "string") continue; + // Accept a bare host ("p7.quadwork.xyz[:443]") or a full origin URL. + const h = hostnameOfHostHeader(entry) || hostnameOfOrigin(entry); + if (h) out.add(h); + } + return out; +} + function emitSystemMessage(projectId, text) { try { if (routes.getProjectChatMode(projectId) !== "file") return; @@ -79,9 +103,12 @@ app.get("/api/health", (_req, res) => { // checks socket IP + Host + Origin are all loopback (see isLocalTokenRequest) // so a DNS-rebinding page or a same-host reverse proxy can't pull the token to // a remote origin. Non-loopback (tailnet/LAN/proxied) callers get 403 and must -// configure the token out-of-band — it is never leaked off-box. +// configure the token out-of-band — it is never leaked off-box. The one +// exception (#988) is an operator-configured trusted_dashboard_hosts allowlist +// for an authenticated on-box reverse proxy (see isTrustedProxyRequest). app.get("/api/session-token", (req, res) => { - if (!isLocalTokenRequest(req)) return res.status(403).json({ error: "Local access only" }); + if (!isLocalTokenRequest(req) && !isTrustedProxyRequest(req)) + return res.status(403).json({ error: "Local access only" }); res.json({ token: SESSION_TOKEN }); }); @@ -102,15 +129,48 @@ function isLocalhost(ip) { return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; } +// Parse the hostname out of a `Host` header value ("name[:port]"). Lowercased +// for case-insensitive allowlist comparison. Returns null on garbage. +function hostnameOfHostHeader(host) { + if (!host || typeof host !== "string") return null; + try { return new URL(`http://${host}`).hostname.toLowerCase(); } catch { return null; } +} + +// Parse the hostname out of an `Origin` header value (a full URL). Lowercased. +function hostnameOfOrigin(origin) { + if (!origin || typeof origin !== "string") return null; + try { return new URL(origin).hostname.toLowerCase(); } catch { return null; } +} + // True when `host` (an `Origin`/`Host` header value, "name[:port]") resolves to // a loopback name. Used to keep the session token on-box. function isLoopbackHostHeader(host) { - if (!host) return false; - let hostname; - try { hostname = new URL(`http://${host}`).hostname; } catch { return false; } + const hostname = hostnameOfHostHeader(host); return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1"; } +// #988: true when the request arrived over loopback (i.e. from the on-box +// reverse proxy) AND its forwarded Host — and, when the browser sends one, its +// Origin — are BOTH in the operator's trusted_dashboard_hosts allowlist. An +// empty allowlist (the default) short-circuits to false, so with no config this +// is a no-op and the strict #968 loopback checks are the only gate. A foreign +// Host/Origin (DNS-rebind, untrusted proxy, tailnet name) is never in the +// allowlist, so it still fails here → 403 / rejected upgrade. Works for both an +// Express req (req.ip) and a raw upgrade req (req.socket.remoteAddress). +function isTrustedProxyRequest(req) { + if (TRUSTED_DASHBOARD_HOSTS.size === 0) return false; + const ip = req.ip || (req.socket && req.socket.remoteAddress); + if (!isLocalhost(ip)) return false; + const host = hostnameOfHostHeader(req.headers.host); + if (!host || !TRUSTED_DASHBOARD_HOSTS.has(host)) return false; + const origin = req.headers.origin; + if (origin) { + const o = hostnameOfOrigin(origin); + if (!o || !TRUSTED_DASHBOARD_HOSTS.has(o)) return false; + } + return true; +} + // #968 (hardened per review): the session token is a bearer secret, so only // hand it to a request that is unambiguously this machine's own loopback. // `req.ip` alone is insufficient — a DNS-rebinding page (attacker domain rebound @@ -134,16 +194,29 @@ function isLocalTokenRequest(req) { // A cross-origin web page CAN open a WebSocket to 127.0.0.1 (browsers don't // enforce same-origin on WS), so the upgrade handler must vet Origin itself. -// Allow any localhost origin (the local dashboard, incl. dev on :3000) and a -// request whose Origin host matches the server Host (direct tailnet/LAN access -// to this very port). Absent Origin → reject (browsers always send it on WS). +// Allow any localhost origin (the local dashboard, incl. dev on :3000), an +// allowlisted reverse-proxy origin (#988), and — for a DIRECT (non-loopback) +// connection only — a request whose Origin host matches the server Host (direct +// tailnet/LAN access to this very port). Absent Origin → reject (browsers always +// send it on WS). function isAllowedWsOrigin(req) { const origin = req.headers.origin; if (!origin) return false; let u; try { u = new URL(origin); } catch { return false; } if (u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "::1") return true; - return !!req.headers.host && u.host === req.headers.host; + // #988: an allowlisted reverse-proxy dashboard origin (verified loopback + + // trusted forwarded Host/Origin) is accepted consistently with the token fetch. + if (isTrustedProxyRequest(req)) return true; + // #968 direct tailnet/LAN access: the browser's Origin host matches the server + // Host. #988: honor this ONLY for a genuine direct (non-loopback) connection. + // A LOOPBACK socket carrying a non-loopback Host means the request arrived via + // an on-box reverse proxy, which MUST be explicitly allowlisted (handled just + // above). Without the loopback-socket guard, a forged/foreign Host+Origin pair + // (DNS-rebinding page, untrusted proxy) matching each other would slip through + // this fallback and, with a token, open the WS — violating #988's invariant. + const ip = req.socket && req.socket.remoteAddress; + return !isLocalhost(ip) && !!req.headers.host && u.host === req.headers.host; } function tokenMatches(token) { diff --git a/server/proxyDashboardAllowlist.test.js b/server/proxyDashboardAllowlist.test.js new file mode 100644 index 0000000..a29d6b5 --- /dev/null +++ b/server/proxyDashboardAllowlist.test.js @@ -0,0 +1,180 @@ +// #988: reverse-proxy dashboard allowlist — trusted_dashboard_hosts lets an +// on-box AUTHENTICATED reverse proxy (nginx: p7.quadwork.xyz -> 127.0.0.1:8400, +// proxy_set_header Host $host) fetch /api/session-token and open the terminal +// WS, WITHOUT weakening #968's DNS-rebinding protection for any host NOT in the +// allowlist. Regression guard for the v2.5.1 hotfix. +// +// Boots the REAL server as a subprocess on a THROWAWAY port with a temp HOME +// whose config.json sets `trusted_dashboard_hosts: ["p7.quadwork.xyz"]`, then +// drives the actual server.on("upgrade") handler + /api/session-token over the +// wire. Security invariants proven here: +// (a) trusted host in allowlist → token 200 + WS opens +// (b) foreign host not in allowlist → token 403 + WS upgrade rejected +// (c) plain loopback → unchanged (token 200, local dashboard OK) +// (d) partial spoof (only Host OR only Origin trusted) → rejected +// +// A companion (case c with NO allowlist at all) is covered by wsPtyAuth.test.js, +// which boots with an empty config and asserts loopback-only behavior. +// +// Plain node:assert script — run with `node server/proxyDashboardAllowlist.test.js`. + +const assert = require("node:assert/strict"); +const { spawn } = require("child_process"); +const http = require("http"); +const net = require("net"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const WebSocket = require("ws"); + +const ROOT = path.resolve(__dirname, ".."); +const TRUSTED = "p7.quadwork.xyz"; +const TMP_HOME = path.join(os.tmpdir(), `proxy-allowlist-${process.pid}-${Date.now()}`); +const CONFIG_DIR = path.join(TMP_HOME, ".quadwork"); + +let child; +function cleanup() { + try { if (child && !child.killed) child.kill("SIGKILL"); } catch {} + try { fs.rmSync(TMP_HOME, { recursive: true, force: true }); } catch {} +} +process.on("exit", cleanup); + +function freePort() { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.once("error", reject); + srv.listen(0, "127.0.0.1", () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); + }); +} + +function waitForListen(proc, port) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("server did not start in time")), 15000); + const onData = (buf) => { + if (buf.toString().includes(`listening on http://127.0.0.1:${port}`)) { + clearTimeout(timer); + proc.stdout.off("data", onData); + resolve(); + } + }; + proc.stdout.on("data", onData); + proc.on("exit", (code) => { clearTimeout(timer); reject(new Error(`server exited early (${code})`)); }); + }); +} + +function httpReq(port, { method = "GET", path: p, headers = {}, body } = {}) { + return new Promise((resolve, reject) => { + const payload = body === undefined ? null : Buffer.from(body); + const req = http.request( + { host: "127.0.0.1", port, method, path: p, + headers: { ...(payload ? { "content-type": "application/json", "content-length": payload.length } : {}), ...headers } }, + (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() })); + }, + ); + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +// Drive the WS with explicit Host/Origin headers so we can simulate what nginx +// forwards (and what an attacker page could try). `ws` lets options.headers +// override the auto-computed Host. +function tryWs(port, query, headers) { + return new Promise((resolve) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}${query}`, { headers, handshakeTimeout: 5000 }); + let settled = false; + const done = (r) => { if (!settled) { settled = true; try { ws.terminate(); } catch {} resolve(r); } }; + ws.on("open", () => done({ open: true })); + ws.on("unexpected-response", (_req, res) => done({ open: false, status: res.statusCode })); + ws.on("error", (err) => done({ open: false, error: String(err.message || err) })); + }); +} + +let passed = 0; +const ok = (c, m) => { assert.ok(c, m); passed++; console.log(` PASS: ${m}`); }; + +(async () => { + const PORT = await freePort(); + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync( + path.join(CONFIG_DIR, "config.json"), + JSON.stringify({ port: PORT, projects: [], trusted_dashboard_hosts: [TRUSTED] }), + ); + + child = spawn(process.execPath, [path.join(ROOT, "server", "index.js")], { + cwd: ROOT, + env: { ...process.env, HOME: TMP_HOME, USERPROFILE: TMP_HOME }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stderr.on("data", () => {}); // drain + + await waitForListen(child, PORT); + const wsPath = `/ws/terminal?project=x&agent=head`; + + // ── (a) Trusted reverse-proxy host: token fetch succeeds ───────────────── + // nginx forwards Host: p7.quadwork.xyz, browser sends Origin: https://p7... + const proxyHeaders = { host: TRUSTED, origin: `https://${TRUSTED}` }; + const tokRes = await httpReq(PORT, { path: "/api/session-token", headers: proxyHeaders }); + ok(tokRes.status === 200, "(a) GET /api/session-token via trusted proxy Host+Origin → 200"); + const token = JSON.parse(tokRes.body).token; + ok(typeof token === "string" && token.length >= 32, "(a) trusted proxy receives the real session token"); + + // Bare Host (no Origin, e.g. a non-CORS fetch through the proxy) also OK. + const tokHostOnly = await httpReq(PORT, { path: "/api/session-token", headers: { host: `${TRUSTED}:443` } }); + ok(tokHostOnly.status === 200, "(a) GET /api/session-token via trusted Host:port, no Origin → 200"); + + // ── (c) Plain loopback still works with an allowlist configured ────────── + const tokLoop = await httpReq(PORT, { path: "/api/session-token", headers: { host: `localhost:${PORT}` } }); + ok(tokLoop.status === 200, "(c) GET /api/session-token from loopback (local dashboard) → 200, unchanged"); + ok(JSON.parse(tokLoop.body).token === token, "(c) loopback dashboard gets the same token"); + + // ── (b) Foreign host NOT in the allowlist: token 403 (DNS-rebind intact) ─ + const foreignHost = await httpReq(PORT, { path: "/api/session-token", headers: { host: "evil.example" } }); + ok(foreignHost.status === 403, "(b) GET /api/session-token with a foreign Host (not allowlisted) → 403"); + const foreignOrigin = await httpReq(PORT, { path: "/api/session-token", headers: { host: `localhost:${PORT}`, origin: "https://evil.example" } }); + ok(foreignOrigin.status === 403, "(b) GET /api/session-token with a foreign Origin (not allowlisted) → 403"); + + // ── (d) Partial spoof: only ONE of Host/Origin is trusted → 403 ────────── + const spoofOrigin = await httpReq(PORT, { path: "/api/session-token", headers: { host: TRUSTED, origin: "https://evil.example" } }); + ok(spoofOrigin.status === 403, "(d) trusted Host but foreign Origin → 403 (both must be allowlisted)"); + const spoofHost = await httpReq(PORT, { path: "/api/session-token", headers: { host: "evil.example", origin: `https://${TRUSTED}` } }); + ok(spoofHost.status === 403, "(d) foreign Host but trusted Origin → 403"); + + // ── (a) WS: trusted proxy Origin + Host + token → upgrade accepted ─────── + const wsGood = await tryWs(PORT, `${wsPath}&token=${token}`, proxyHeaders); + ok(wsGood.open === true, "(a) WS via trusted proxy Origin+Host + token → opens"); + + // ── (a) WS: trusted proxy, no token → 401 (origin OK, token still required) ─ + const wsNoTok = await tryWs(PORT, wsPath, proxyHeaders); + ok(wsNoTok.open === false && wsNoTok.status === 401, "(a) WS via trusted proxy but no token → 401"); + + // ── (b) WS: foreign Origin (realistic browser: Host stays loopback) → 403 ─ + const wsForeign = await tryWs(PORT, `${wsPath}&token=${token}`, { origin: "https://evil.example" }); + ok(wsForeign.open === false && wsForeign.status === 403, "(b) WS with a foreign Origin (not allowlisted) → 403, not opened"); + + // ── (d) WS: partial spoof (trusted Host, foreign Origin) → 403 ─────────── + const wsSpoof = await tryWs(PORT, `${wsPath}&token=${token}`, { host: TRUSTED, origin: "https://evil.example" }); + ok(wsSpoof.open === false && wsSpoof.status === 403, "(d) WS with trusted Host but foreign Origin → 403, not opened"); + + // ── (b) WS: MATCHED foreign Host+Origin (both evil.example) + VALID token ── + // The #968 same-host fallback must NOT open the WS here: the socket is loopback + // (an on-box/DNS-rebound proxy), the host is not allowlisted, so the forged pair + // is rejected despite a valid token. Regression guard for the #988 invariant. + const wsForgedMatch = await tryWs(PORT, `${wsPath}&token=${token}`, { host: "evil.example", origin: "http://evil.example" }); + ok(wsForgedMatch.open === false && wsForgedMatch.status === 403, + "(b) WS with matched foreign Host+Origin (not allowlisted) + valid token → 403, not opened"); + + console.log(`\n${passed} passed`); + console.log("server/proxyDashboardAllowlist.test.js: all assertions passed"); + process.exit(0); +})().catch((err) => { + console.error(err.message || err); + process.exit(1); +});