Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 82 additions & 9 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
});

Expand All @@ -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
Expand All @@ -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) {
Expand Down
180 changes: 180 additions & 0 deletions server/proxyDashboardAllowlist.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading