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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions src/dash.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" }),
);
Expand Down
30 changes: 30 additions & 0 deletions test/dash.test.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down
Loading