4a665da79e
- /ws-pty carries the dashboard token like /ws (the terminal never attached with DASHBOARD_TOKEN set), and so does everything that cannot send the header: images, downloads, exports, drops (withToken/tokenHeader). A refused upgrade answers 401/403 and is logged and counted by reason. - The browser asks for a missing or wrong token (401 EUNAUTHORIZED) and takes ?token= from the link agenthub start prints, dropping it from the URL. - The CLI sends the token (DASHBOARD_TOKEN, else the one the running server publishes 0600 in <data dir>/runtime/dashboard-token); MCP and statusline read the same file. - DASHBOARD_HOST takes a list, and 127.0.0.1 is always listened on too, so a LAN-only bind keeps hooks and the CLI working. - agenthub start --host/--allowed-hosts/--token are saved in <data dir>/network.json for every later start; a wide bind gets a token; every reachable URL is printed. - tmux < 3.7 is an amber, once-per-version notice that says only the pane scrollbar is missing, with a ~/.local build instead of sudo make install. - INSTALL: PATH belongs in ~/.profile for sudo -iu/ssh; WSL mirrored notes.
276 lines
9.5 KiB
JavaScript
276 lines
9.5 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
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,
|
|
};
|