/** * @file security.js * @description Network-exposure hardening for the dashboard server * (GHSA-gr74-4xfh-6jw9). The server historically bound 0.0.0.0 with no auth and * `cors()` (Access-Control-Allow-Origin: *), exposing transcripts, data export, * local-directory reads, ~/.claude writes, and a claude-spawning endpoint to any * host on the network. This module centralizes the defenses: * * 1. Default bind to loopback (127.0.0.1); opt into a wider bind only via the * explicit DASHBOARD_HOST env (with a startup warning). * 2. Host-header allowlist — rejects requests whose Host isn't loopback (or an * operator-allowlisted name), which defeats DNS-rebinding drive-bys. * 3. CORS restricted to loopback origins (no more `*`). * 4. An OPTIONAL bearer token (DASHBOARD_TOKEN) gating /api/* and the * WebSocket — for operators who deliberately bind to a LAN. Off by default * so the zero-config loopback experience is unchanged. * * @author Nguyễn Ngọc Trí Vĩ */ const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); // Hostnames that count as "this machine". "0.0.0.0" is included because a // browser may resolve a 0.0.0.0 bind via localhost; an empty Host is treated as // loopback (HTTP/1.0 / local tooling). const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0", ""]); /** * Every address to bind, in order: `DASHBOARD_HOST` may list several, * comma-separated (`192.168.1.20,100.64.0.7`). Loopback unless the operator * opts into a wider bind. */ function resolveHosts() { const list = (process.env.DASHBOARD_HOST || "") .split(",") .map((s) => s.trim()) .filter(Boolean); return list.length ? [...new Set(list)] : ["127.0.0.1"]; } /** The first (main) address to bind. */ function resolveHost() { return resolveHosts()[0]; } /** An all-interfaces bind (it answers on loopback too). */ const isWildcardHost = (h) => ["0.0.0.0", "::", "[::]"].includes(String(h || "").trim()); /** * The addresses to listen on besides the main one: the rest of the list, plus * 127.0.0.1 when no address in it already answers there. The hook handler, * the CLI, the MCP server and the statusline all call 127.0.0.1 — a dashboard * bound only to a LAN address (WSL in mirrored networking, where Windows * cannot reach the VM's loopback) would otherwise lose every hook. */ function extraListenHosts() { const hosts = resolveHosts(); const extra = hosts.slice(1).map((h) => h.replace(/^\[(.*)\]$/, "$1")); const coversLoopback = hosts.some( (h) => isWildcardHost(h) || ["127.0.0.1", "localhost"].includes(h.toLowerCase()) ); if (!coversLoopback) extra.push("127.0.0.1"); return [...new Set(extra)]; } /** * The URLs a browser can open this dashboard at, for the start-up line and the * CLI: `localhost` first, then each bound address — every non-internal IPv4 * address of this machine for a wildcard bind. */ function accessUrls(port) { const os = require("node:os"); const urls = new Set([`http://localhost:${port}`]); for (const h of resolveHosts()) { if (isWildcardHost(h)) { for (const addrs of Object.values(os.networkInterfaces())) { for (const a of addrs || []) { if (a.family === "IPv4" && !a.internal) urls.add(`http://${a.address}:${port}`); } } } else if (!isLoopbackHostname(h)) { urls.add(`http://${h.includes(":") && !h.startsWith("[") ? `[${h}]` : h}:${port}`); } } return [...urls]; } /** Whether any bind is reachable from beyond this machine. */ function isExposedBind() { return resolveHosts().some((h) => isWildcardHost(h) || !isLoopbackHostname(h)); } function isLoopbackHostname(name) { return LOOPBACK_HOSTS.has(String(name || "").toLowerCase()); } /** Extra Host-header names the operator allows (set when binding to a LAN). */ function allowedHostnames() { return (process.env.DASHBOARD_ALLOWED_HOSTS || "") .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); } /** Strip the port from a Host header, preserving bracketed IPv6 literals. */ function hostnameOf(hostHeader) { const h = String(hostHeader || ""); if (h.startsWith("[")) { const end = h.indexOf("]"); return end >= 0 ? h.slice(0, end + 1).toLowerCase() : h.toLowerCase(); } return h.split(":")[0].toLowerCase(); } function isHostAllowed(hostHeader) { const name = hostnameOf(hostHeader); return isLoopbackHostname(name) || allowedHostnames().includes(name); } /** * Express middleware: reject requests whose Host header isn't loopback (or an * operator-allowlisted name). This is the primary defense against DNS-rebinding * — a rebound attacker domain arrives with its own Host (e.g. evil.example) and * is refused even though the TCP connection is local→local. */ function hostGuard(req, res, next) { if (isHostAllowed(req.headers.host)) return next(); return res.status(403).json({ error: { code: "EBADHOST", message: "host not allowed" } }); } /** * CORS options: allow same-origin / no-Origin (curl, the server's own client) * and loopback origins; refuse everything else (so a cross-origin page cannot * read responses). Credentials stay off — the API is token- or trust-gated, not * cookie-authed. */ function corsOptions() { return { origin(origin, cb) { if (!origin) return cb(null, true); try { const u = new URL(origin); if ( isLoopbackHostname(u.hostname) || allowedHostnames().includes(u.hostname.toLowerCase()) ) { return cb(null, true); } } catch { /* malformed Origin → treat as disallowed */ } return cb(null, false); }, credentials: false, }; } /** The configured auth token, or null when auth is disabled (the default). */ function getDashboardToken() { const t = process.env.DASHBOARD_TOKEN; return typeof t === "string" && t.length > 0 ? t : null; } /** Where the running server publishes its token for the CLI, MCP server and statusline. */ function tokenFilePath() { const { getDataDir } = require("./claude-home"); return path.join(getDataDir(), "runtime", "dashboard-token"); } /** * Publish the active token (0600, this account only) so a local CLI/MCP/ * statusline started from a shell that never exported DASHBOARD_TOKEN can * still call a token-gated dashboard; remove a stale one when no token is * set. Best effort: never throws. */ function publishDashboardToken() { const file = tokenFilePath(); const token = getDashboardToken(); try { if (!token) { fs.rmSync(file, { force: true }); return; } fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, token, { mode: 0o600 }); fs.chmodSync(tmp, 0o600); fs.renameSync(tmp, file); } catch (e) { console.warn(`[security] could not publish the dashboard token: ${e.message}`); } } function tokensMatch(provided, expected) { if (typeof provided !== "string" || provided.length === 0) return false; const a = Buffer.from(provided); const b = Buffer.from(expected); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } function extractToken(req) { const auth = req.headers.authorization; if (typeof auth === "string" && auth.startsWith("Bearer ")) return auth.slice(7); const header = req.headers["x-dashboard-token"]; if (typeof header === "string" && header) return header; if (req.query && typeof req.query.token === "string") return req.query.token; return null; } // API subpaths exempt from the token gate even when a token is set: // /health, /openapi.json, /docs — harmless metadata / docs. // /hooks — local Claude Code hook ingestion (the hook handler posts to // loopback and carries no token); loopback bind already protects it. const TOKEN_EXEMPT_PREFIXES = ["/health", "/openapi.json", "/docs", "/hooks"]; /** * Express middleware (mount at "/api"): when DASHBOARD_TOKEN is set, require a * matching bearer token on every API route except the exempt prefixes. A no-op * when no token is configured — preserving the zero-config loopback default. */ function tokenGuard(req, res, next) { const expected = getDashboardToken(); if (!expected) return next(); if (TOKEN_EXEMPT_PREFIXES.some((p) => req.path === p || req.path.startsWith(p + "/"))) { return next(); } if (tokensMatch(extractToken(req), expected)) return next(); return res .status(401) .json({ error: { code: "EUNAUTHORIZED", message: "missing or invalid dashboard token" } }); } /** * WebSocket upgrade auth. When a token is configured, the client must pass it as * `?token=` (or an x-dashboard-token header). No-op when auth is disabled. */ function isWebSocketAuthorized(req) { const expected = getDashboardToken(); if (!expected) return true; try { const u = new URL(req.url, "http://localhost"); if (tokensMatch(u.searchParams.get("token"), expected)) return true; } catch { /* fall through */ } const header = req.headers["x-dashboard-token"]; if (typeof header === "string" && tokensMatch(header, expected)) return true; return false; } module.exports = { LOOPBACK_HOSTS, resolveHost, resolveHosts, extraListenHosts, isWildcardHost, isExposedBind, accessUrls, isLoopbackHostname, allowedHostnames, hostnameOf, isHostAllowed, hostGuard, corsOptions, getDashboardToken, tokenFilePath, publishDashboardToken, tokenGuard, isWebSocketAuthorized, // exported for tests tokensMatch, extractToken, };