diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f9bada..7a1a051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Security + +- **The `forge dash` write routes are now guarded against CSRF and DNS-rebinding.** The two + human-driven writes (`POST /api/ratify`, `POST /api/retract`) on the unauthenticated + localhost dashboard now refuse (`403`) any request whose `Host` header isn't the loopback + interface (a domain rebound to 127.0.0.1) or whose browser `Origin` isn't the loopback + origin (a cross-site POST). Native clients that send no `Origin` still work; a deliberate + non-loopback `--host` bind opts out. Covered by new regression tests. + ## [0.27.0] - 2026-07-20 ### Changed diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 29acc25..244aa77 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -980,6 +980,10 @@ cache), Trends (per-stage metrics history as inline-SVG sparklines), Memory brow (ranked recall search over the ledger with confidence + freshness bars), and Session timeline (durable mint/tombstone events across sessions). The page live-refreshes every 5s, paused while the tab is hidden. Every claim row shows its `forge ledger blame` command. +The only two writes are the human-driven `POST /api/ratify` and `POST /api/retract`; both +are guarded against CSRF and DNS-rebinding — a request whose `Host` isn't the loopback +interface, or whose browser `Origin` isn't the loopback origin, is refused with `403` (a +non-loopback `--host` bind is the documented opt-out). ```console $ forge dash diff --git a/src/dash.js b/src/dash.js index 62d385e..dcfadd6 100644 --- a/src/dash.js +++ b/src/dash.js @@ -463,6 +463,34 @@ async function handleWrite(root, pathname, req, res) { const WRITE_ROUTES = new Set(["/api/ratify", "/api/retract"]); +// CSRF + DNS-rebinding guard for the two write routes. The dashboard is a localhost +// convenience server with no auth, so an unguarded POST is reachable by (a) any web page +// the user visits (a cross-site form/fetch — the browser attaches an Origin), and (b) a +// DNS-rebinding attack (an attacker domain rebinds to 127.0.0.1 — the request then carries +// the ATTACKER's Host, not the loopback one). We refuse a write unless the Host names the +// loopback interface AND any browser Origin is that same loopback origin; native clients +// (curl, the CLI) send no Origin and pass. Skipped entirely for an explicit non-loopback +// bind — passing a public host is the documented "on your own head" opt-out. +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]); +const normHost = (h) => + String(h ?? "") + .replace(/:\d+$/, "") + .replace(/^\[|\]$/g, "") + .toLowerCase(); +function writeAllowed(req, boundHost) { + if (!LOOPBACK_HOSTS.has(boundHost)) return true; // non-loopback bind: explicit opt-out + if (!LOOPBACK_HOSTS.has(normHost(req.headers.host))) return false; // DNS-rebinding + const origin = req.headers.origin; + if (origin) { + try { + if (!LOOPBACK_HOSTS.has(new URL(origin).hostname.toLowerCase())) return false; // CSRF + } catch { + return false; // a malformed Origin is never trusted + } + } + return true; +} + /** * The dashboard server: GET / → the page, GET /api/data → dashData, GET * /api/history → metrics trends, GET /api/claims?q=&kind= → memory browser, GET @@ -479,6 +507,12 @@ export function serve(root, { port = 4242, host = "127.0.0.1" } = {}) { const server = createServer((req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); if (req.method === "POST" && WRITE_ROUTES.has(url.pathname)) { + if (!writeAllowed(req, host)) { + sendJson(res, 403, { + error: "write refused: cross-origin or non-loopback request (CSRF/DNS-rebinding guard)", + }); + return; + } handleWrite(root, url.pathname, req, res).catch(() => sendJson(res, 400, { error: "bad request body" }), ); diff --git a/test/dash.test.js b/test/dash.test.js index 045cb19..8ad62da 100644 --- a/test/dash.test.js +++ b/test/dash.test.js @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { request } from "node:http"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { test } from "node:test"; @@ -251,6 +252,35 @@ test("serve: POST /api/ratify and /api/retract are the two append-only writes", assert.equal((await post("/api/retract", { id: "x" })).status, 400, "1-char prefix refused"); assert.equal((await post("/api/ratify", { id: "zz" })).status, 404, "unknown prefix"); assert.equal((await post("/api/retract", { id: "zz", reason: "r" })).status, 404); + + // CSRF/DNS-rebinding guard: a cross-site browser Origin, or a foreign Host header + // (a domain rebound to 127.0.0.1), is refused with 403 before any write is attempted. + const evilOrigin = await fetch(`${base}/api/ratify`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: "https://evil.example", + }, + body: JSON.stringify({ id: trusted.id.slice(0, 8) }), + }); + assert.equal(evilOrigin.status, 403, "cross-origin POST refused"); + // fetch forbids overriding Host, so use the low-level client to forge it (a domain + // rebound to 127.0.0.1 carries its own Host, which the guard must reject). + const evilHostStatus = await new Promise((resolve) => { + const r = request( + { + host: "127.0.0.1", + port: addr.port, + path: "/api/ratify", + method: "POST", + headers: { "content-type": "application/json", host: "evil.example" }, + }, + (res) => resolve(res.statusCode), + ); + r.on("error", () => resolve(-1)); + r.end(JSON.stringify({ id: trusted.id.slice(0, 8) })); + }); + assert.equal(evilHostStatus, 403, "foreign Host (DNS-rebinding) refused"); } finally { delete process.env.FORGE_AUTHOR; server.close();