/** * @file same-origin.js * @description Origin guard for the routes that act on the machine rather than * on the dashboard's own database — spawning a process, or writing a file into * a directory the request names — plus `isLocalRequest`, the "a browser on this * very machine, not through a proxy" test. * * The dashboard binds to localhost, so any page in the user's browser can * reach it. Without this, a malicious site could POST to it and, say, drop a * CLAUDE.md full of instructions into a project the user later opens. * * Browser requests carry `Origin`; it must be a loopback host, or — for a * dashboard served under an operator-allowed name (server mode behind a * reverse proxy, `DASHBOARD_ALLOWED_HOSTS`) — exactly the request's own Host, * which `hostGuard` has already checked against that allowlist. Requests with * no `Origin` (curl, the CLI) pass — they are not the drive-by case. `Referer` * is the fallback for older browsers. * * Extracted from routes/run.js so the config routes enforce the identical * rule instead of a second, drifting copy. * * @author Nguyễn Ngọc Trí Vĩ */ const { isHostAllowed } = require("./security"); const ALLOWED_ORIGIN_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]); function isLoopback(raw) { try { return ALLOWED_ORIGIN_HOSTS.has(new URL(raw).hostname); } catch { return false; } } /** `raw`'s host:port is the request's own Host, and that Host is allowed. */ function isOwnHost(raw, req) { const host = String(req.headers.host || "").toLowerCase(); try { return Boolean(host) && new URL(raw).host.toLowerCase() === host && isHostAllowed(host); } catch { return false; } } function sameOriginGuard(req, res, next) { const raw = req.headers.origin || req.headers.referer; if (!raw || isLoopback(raw) || isOwnHost(raw, req)) return next(); return res.status(403).json({ error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" }, }); } /** * The request came straight from this machine: a loopback socket and no * forwarding header. A reverse proxy's requests arrive from loopback too, so a * forwarding header counts as remote. An SSH tunnel carries none and cannot be * told apart. */ function isLocalRequest(req) { const addr = req.socket?.remoteAddress || ""; const proxied = req.headers["x-forwarded-for"] || req.headers.forwarded; return !proxied && ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(addr); } module.exports = { sameOriginGuard, isLocalRequest, ALLOWED_ORIGIN_HOSTS, isLoopback, isOwnHost };