feat(E7.4): CSRF / same-origin guard on POST mutation routes
The last E7 story — completes Slice 0 (E1b ∥ E7). Every requireCSRF route (the
POST mutations) is now defended against cross-site forgery: same-origin check
+ a per-session CSRF token. Ships dark (flag default off → no-op).
- src/server.ts checkCSRF(req,res,route): flag off → no-op; requireCSRF routes
(POST mutations) check (1) same-origin — Origin (or Referer fallback) host must
equal the request's Host header host; both missing → 403 "origin required"
(curl/scripts send an explicit Origin); mismatch → 403 "cross-origin
forbidden"; (2) X-CSRF-Token header === csrf_token cookie (constant-time);
absent/mismatch → 403 "missing or invalid csrf token". GET/HEAD/OPTIONS exempt
(requireCSRF is only on POST). Auth (E7.1) runs BEFORE CSRF — 401 fires first.
- src/server.ts issueCSRF + GET /api/auth/csrf (open): issues a random per-session
token in an HttpOnly SameSite=Strict cookie + returns {csrfToken} (the JS-readable
mirror; a cross-site can't read the body via CORS nor the HttpOnly cookie, so it
can't forge X-CSRF-Token). Dispatch runs checkCSRF after authenticate.
- src/dashboard.html: apiFetch attaches X-CSRF-Token on non-GET requests (from
localStorage, fetched via /api/auth/csrf on init). The browser auto-sends the
HttpOnly csrf cookie on same-origin POSTs.
- tests: e7-1-dispatch + e7-3-nosecret valid-Bearer POST tests updated to send
Origin + X-CSRF-Token + cookie (getCSRF helper). e7-4-csrf.test.ts (10 tests):
csrf issuance (HttpOnly SameSite cookie + token); same-origin+valid → 200;
cross-origin → 403; no-origin → 403 origin required; missing/invalid token →
403; missing cookie → 403; auth-before-CSRF (no Bearer → 401); GET exempt; flag
off → no-op.
tsc clean; 212 passing project-wide (18 pre-existing fixture-missing unchanged).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -145,9 +145,14 @@ const dryEl = () => document.getElementById('dryRun');
|
||||
// a Bearer header; on a 401 (token expired / wrong / public bind), shows the
|
||||
// login card so the user re-authenticates instead of silently failing.
|
||||
function authToken() { return localStorage.getItem('ofs_token') || ''; }
|
||||
function csrfToken() { return localStorage.getItem('ofs_csrf') || ''; }
|
||||
async function apiFetch(path, init = {}) {
|
||||
const tok = authToken();
|
||||
if (tok) init = { ...init, headers: { ...(init.headers || {}), authorization: `Bearer ${tok}` } };
|
||||
const headers = { ...(init.headers || {}) };
|
||||
if (tok) headers.authorization = `Bearer ${tok}`;
|
||||
// E7.4: attach the CSRF token to mutation (POST) requests.
|
||||
if (csrfToken() && (init.method || 'GET') !== 'GET') headers['x-csrf-token'] = csrfToken();
|
||||
init = { ...init, headers };
|
||||
const r = await fetch(path, init);
|
||||
if (r.status === 401) { showLogin(); throw new Error('unauthorized'); }
|
||||
return r;
|
||||
@@ -210,6 +215,10 @@ async function init() {
|
||||
// E7.2: gate on auth — if the dashboard requires a token and none is stored,
|
||||
// show the login card and stop (don't load the dashboard behind it).
|
||||
if (!(await checkAuth())) return;
|
||||
// E7.4: fetch a per-session CSRF token (stores the JS-readable mirror; the
|
||||
// HttpOnly cookie is sent automatically on same-origin POSTs).
|
||||
const csrf = await apiFetch('/api/auth/csrf').then(r => r.json()).catch(() => null);
|
||||
if (csrf && csrf.csrfToken) localStorage.setItem('ofs_csrf', csrf.csrfToken);
|
||||
const tag = document.getElementById('modeTag');
|
||||
tag.textContent = STATUS.mode + (STATUS.mode === 'apply' ? '' : ' (safe)');
|
||||
if (STATUS.mode === 'apply') tag.classList.add('apply');
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// unless the server was started with --apply.
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { timingSafeEqual, randomBytes } from "node:crypto";
|
||||
import { readFile, writeFile, mkdir, copyFile, access, stat, readdir, unlink, rename } from "node:fs/promises";
|
||||
import { watch, readdirSync, statSync, mkdirSync, existsSync, createWriteStream, type WriteStream, type FSWatcher } from "node:fs";
|
||||
import { join, dirname, relative, basename, extname } from "node:path";
|
||||
@@ -156,6 +156,60 @@ function readAuthToken(req: IncomingMessage): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** E7.4: read a named cookie value from the Cookie header (or null). */
|
||||
function readCookie(req: IncomingMessage, name: string): string | null {
|
||||
const cookie = req.headers["cookie"];
|
||||
if (typeof cookie !== "string") return null;
|
||||
const m = cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
/** E7.4: issue a per-session CSRF token — set an HttpOnly SameSite=Strict cookie
|
||||
* `csrf_token` AND return the same value for the JS to mirror (it can't read the
|
||||
* HttpOnly cookie, so the body carries the value the dashboard stores + sends as
|
||||
* X-CSRF-Token). The browser auto-sends the cookie; the middleware compares. */
|
||||
function issueCSRF(res: ServerResponse): { csrfToken: string } {
|
||||
const token = randomBytes(16).toString("hex");
|
||||
res.setHeader("set-cookie", `csrf_token=${token}; HttpOnly; SameSite=Strict; Path=/`);
|
||||
return { csrfToken: token };
|
||||
}
|
||||
|
||||
/** E7.4: hostname of a URL string (or null if unparseable). */
|
||||
function hostnameOf(urlStr: string): string | null {
|
||||
try { return new URL(urlStr).hostname; } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* E7.4: CSRF / same-origin guard for requireCSRF routes (POST mutations). When
|
||||
* the auth flag is off, it's a no-op pass-through. When on, two checks (both must
|
||||
* pass):
|
||||
* 1. SAME-ORIGIN: the Origin (or Referer fallback) host must equal the request's
|
||||
* Host header host (the host the browser addressed). Both missing → 403
|
||||
* "origin required" (curl/scripts must send an explicit Origin); mismatch →
|
||||
* 403 "cross-origin forbidden".
|
||||
* 2. CSRF TOKEN: the X-CSRF-Token header must match the csrf_token cookie value
|
||||
* (constant-time). Absent/mismatch → 403 "missing or invalid csrf token".
|
||||
* GET/HEAD/OPTIONS are exempt (requireCSRF is only on POST mutation routes).
|
||||
* Auth (E7.1) runs BEFORE this — a 401 (no/invalid auth token) fires first.
|
||||
*/
|
||||
function checkCSRF(req: IncomingMessage, res: ServerResponse, route: AuthRoute): boolean {
|
||||
if (!__authState.enabled) return true; // flag off → no-op
|
||||
if (!route.requireCSRF) return true; // not a CSRF route
|
||||
const host = (req.headers["host"] ?? "").toString().split(":")[0];
|
||||
const origin = req.headers["origin"];
|
||||
const referer = req.headers["referer"];
|
||||
const originHost = (typeof origin === "string" && origin) ? hostnameOf(origin)
|
||||
: (typeof referer === "string" && referer) ? hostnameOf(referer) : null;
|
||||
if (!originHost) { send(res, 403, { error: "origin required" }); return false; }
|
||||
if (originHost !== host) { send(res, 403, { error: "cross-origin forbidden" }); return false; }
|
||||
const headerToken = (req.headers["x-csrf-token"] ?? "").toString();
|
||||
const cookieToken = readCookie(req, "csrf_token");
|
||||
if (!headerToken || !cookieToken || !constantTimeEqual(headerToken, cookieToken)) {
|
||||
send(res, 403, { error: "missing or invalid csrf token" }); return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* E7.1: the auth middleware. Returns true to proceed, false after sending a 401
|
||||
* (missing/invalid token). When ENABLE_AUTH_MIDDLEWARE is off (default), it's a
|
||||
@@ -1481,6 +1535,13 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
|
||||
return send(res, 200, { ok: true });
|
||||
},
|
||||
},
|
||||
// E7.4: issue a per-session CSRF token (HttpOnly cookie + a JS-readable mirror).
|
||||
// Open — issuing a token leaks no secret (a cross-site can't read the body via
|
||||
// CORS nor the HttpOnly cookie, so it can't forge X-CSRF-Token).
|
||||
"GET /api/auth/csrf": {
|
||||
method: "GET", requireAuth: false, requireCSRF: false,
|
||||
handler: async (_s, _req, res) => send(res, 200, issueCSRF(res)),
|
||||
},
|
||||
"GET /api/autosync/conflicts": {
|
||||
method: "GET", requireAuth: true, requireCSRF: false, // E7.3: leaks conflict rows — gate in public mode
|
||||
handler: async (_s, _req, res) => send(res, 200, { conflicts: state.autosync.conflicts }),
|
||||
@@ -1532,8 +1593,9 @@ export async function startServer(cfg: ServerConfig): Promise<{ server: Server;
|
||||
const route = ROUTES[`${method} ${url.pathname}`];
|
||||
if (!route) { send(res, 404, { error: "not found" }); return; }
|
||||
// E7.1: auth (flag-gated). 401 if a requireAuth route lacks a valid token.
|
||||
// CSRF (route.requireCSRF) is declared in the table; E7.4 enforces it.
|
||||
// E7.4: CSRF / same-origin (runs AFTER auth — a 401 fires before a 403).
|
||||
if (!(await authenticate(req, res, route))) return;
|
||||
if (route.requireCSRF && !checkCSRF(req, res, route)) return;
|
||||
await route.handler(state, req, res, url);
|
||||
} catch (e) {
|
||||
send(res, 500, { error: (e as Error).message });
|
||||
|
||||
@@ -64,6 +64,16 @@ async function req(path: string, init: RequestInit = {}): Promise<{ code: number
|
||||
return { code: r.status, body };
|
||||
}
|
||||
|
||||
// E7.4: fetch a CSRF token + the matching cookie (Node fetch has no cookie jar,
|
||||
// so we replay the Set-Cookie as a Cookie header on the POST).
|
||||
async function getCSRF(): Promise<{ token: string; cookie: string; origin: string }> {
|
||||
const r = await fetch(`${baseURL}/api/auth/csrf`);
|
||||
const token = (await r.json() as { csrfToken: string }).csrfToken;
|
||||
const setCookie = r.headers.get("set-cookie") ?? "";
|
||||
const m = setCookie.match(/csrf_token=([^;]+)/);
|
||||
return { token, cookie: m ? `csrf_token=${m[1]}` : "", origin: baseURL };
|
||||
}
|
||||
|
||||
describe("E7.1c dispatch (flag off — no auth, byte-identical)", () => {
|
||||
it("GET /api/status passes without a token", async () => {
|
||||
__authState.enabled = false;
|
||||
@@ -116,13 +126,15 @@ describe("E7.1c dispatch (flag on + token set)", () => {
|
||||
expect(r.code).toBe(401);
|
||||
expect((r.body as { error: string }).error).toMatch(/bad auth header/);
|
||||
});
|
||||
it("POST /api/autosync with a VALID Bearer → reaches the handler (200, enabled:false)", async () => {
|
||||
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer s3cret-token" }, body: JSON.stringify({ enabled: false }) });
|
||||
expect(r.code).toBe(200); // auth passed → setEnabled(false) → 200
|
||||
it("POST /api/autosync with a VALID Bearer + CSRF → reaches the handler (200, enabled:false)", async () => {
|
||||
const csrf = await getCSRF();
|
||||
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer s3cret-token", origin: csrf.origin, "x-csrf-token": csrf.token, cookie: csrf.cookie }, body: JSON.stringify({ enabled: false }) });
|
||||
expect(r.code).toBe(200); // auth + CSRF passed → setEnabled(false) → 200
|
||||
});
|
||||
it("POST /api/autosync {enabled:true} in dev mode with a valid token → 400 apply-gate (auth passed, mode check next)", async () => {
|
||||
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer s3cret-token" }, body: JSON.stringify({ enabled: true }) });
|
||||
expect(r.code).toBe(400); // auth passed → apply-mode gate
|
||||
it("POST /api/autosync {enabled:true} in dev mode with a valid token + CSRF → 400 apply-gate (auth+CSRF passed, mode check next)", async () => {
|
||||
const csrf = await getCSRF();
|
||||
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer s3cret-token", origin: csrf.origin, "x-csrf-token": csrf.token, cookie: csrf.cookie }, body: JSON.stringify({ enabled: true }) });
|
||||
expect(r.code).toBe(400); // auth + CSRF passed → apply-mode gate
|
||||
expect((r.body as { error: string }).error).toMatch(/requires --apply mode/);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,15 @@ function authHeaders(extra: Record<string, string> = {}): Record<string, string>
|
||||
return { authorization: `Bearer ${DASH_SECRET}`, ...extra };
|
||||
}
|
||||
|
||||
// E7.4: CSRF token + cookie (Node fetch has no cookie jar; replay Set-Cookie).
|
||||
async function getCSRF(): Promise<{ token: string; cookie: string; origin: string }> {
|
||||
const r = await fetch(`${baseURL}/api/auth/csrf`);
|
||||
const token = (await r.json() as { csrfToken: string }).csrfToken;
|
||||
const setCookie = r.headers.get("set-cookie") ?? "";
|
||||
const m = setCookie.match(/csrf_token=([^;]+)/);
|
||||
return { token, cookie: m ? `csrf_token=${m[1]}` : "", origin: baseURL };
|
||||
}
|
||||
|
||||
function assertNoSecret(label: string, body: unknown) {
|
||||
const text = typeof body === "string" ? body : JSON.stringify(body);
|
||||
expect(text, `${label} leaked RELAY_SECRET`).not.toContain(RELAY_SECRET);
|
||||
@@ -96,7 +105,8 @@ describe("E7.3 no-secret-egress regression guard", () => {
|
||||
});
|
||||
|
||||
it("/api/refresh (relay-failure path) → 500 with a relay error, no secret", async () => {
|
||||
const r = await req("/api/refresh", { method: "POST", headers: authHeaders({ "content-type": "application/json" }), body: "{}" });
|
||||
const csrf = await getCSRF();
|
||||
const r = await req("/api/refresh", { method: "POST", headers: authHeaders({ "content-type": "application/json", origin: csrf.origin, "x-csrf-token": csrf.token, cookie: csrf.cookie }), body: "{}" });
|
||||
expect(r.code).toBe(500); // the relay fetch fails (nothing on :1)
|
||||
assertNoSecret("refresh-error", r.body); // the error must NOT echo the API key
|
||||
});
|
||||
|
||||
146
tests/e7-4-csrf.test.ts
Normal file
146
tests/e7-4-csrf.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// E7.4 — CSRF / same-origin guard for POST mutation routes.
|
||||
//
|
||||
// flag off → no-op. flag on + public bind → same-origin (Origin/Referer host ===
|
||||
// Host) + X-CSRF-Token === csrf_token cookie (constant-time). Auth (401) runs
|
||||
// before CSRF (403).
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
||||
import { ClassicLevel } from "classic-level";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
import { startServer, __authState } from "../src/server.js";
|
||||
|
||||
let dir: string;
|
||||
let server: Server;
|
||||
let baseURL: string;
|
||||
const savedEnabled = __authState.enabled;
|
||||
const savedToken = __authState.token;
|
||||
const savedBound = __authState.bound;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "e7-4-"));
|
||||
await mkdir(join(dir, "refined"), { recursive: true });
|
||||
await mkdir(join(dir, "cc"), { recursive: true });
|
||||
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
|
||||
await jdb.open(); await jdb.close();
|
||||
__authState.enabled = true; __authState.token = "s3cret";
|
||||
const { server: srv } = await startServer({
|
||||
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
|
||||
outDir: join(dir, "out"), mode: "dev", port: 0, host: "127.0.0.1",
|
||||
});
|
||||
__authState.bound = "0.0.0.0"; // simulate a public bind so enforcement runs
|
||||
server = srv;
|
||||
baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
__authState.enabled = savedEnabled; __authState.token = savedToken; __authState.bound = savedBound;
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function getCSRF(): Promise<{ token: string; cookie: string }> {
|
||||
const r = await fetch(`${baseURL}/api/auth/csrf`);
|
||||
const token = (await r.json() as { csrfToken: string }).csrfToken;
|
||||
const setCookie = r.headers.get("set-cookie") ?? "";
|
||||
const m = setCookie.match(/csrf_token=([^;]+)/);
|
||||
return { token, cookie: m ? `csrf_token=${m[1]}` : "" };
|
||||
}
|
||||
|
||||
async function post(extra: Record<string, string>): Promise<{ code: number; body: unknown }> {
|
||||
const r = await fetch(`${baseURL}/api/autosync`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer s3cret", origin: baseURL, ...extra },
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
const t = await r.text();
|
||||
return { code: r.status, body: t ? JSON.parse(t) : null };
|
||||
}
|
||||
|
||||
describe("E7.4 CSRF / same-origin", () => {
|
||||
it("/api/auth/csrf issues an HttpOnly SameSite=Strict cookie + returns the token", async () => {
|
||||
const r = await fetch(`${baseURL}/api/auth/csrf`);
|
||||
const body = await r.json() as { csrfToken: string };
|
||||
const setCookie = r.headers.get("set-cookie") ?? "";
|
||||
expect(body.csrfToken).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(setCookie).toContain("csrf_token=");
|
||||
expect(setCookie).toContain("HttpOnly");
|
||||
expect(setCookie).toContain("SameSite=Strict");
|
||||
});
|
||||
|
||||
it("flag on + same-origin + valid X-CSRF-Token + cookie → proceeds (200)", async () => {
|
||||
const csrf = await getCSRF();
|
||||
const r = await post({ "x-csrf-token": csrf.token, cookie: csrf.cookie });
|
||||
expect(r.code).toBe(200); // auth + CSRF passed → setEnabled(false) → 200
|
||||
});
|
||||
|
||||
it("cross-origin Origin → 403 cross-origin forbidden (even with a valid token)", async () => {
|
||||
const csrf = await getCSRF();
|
||||
const r = await post({ "x-csrf-token": csrf.token, cookie: csrf.cookie, origin: "http://evil.example" });
|
||||
expect(r.code).toBe(403);
|
||||
expect((r.body as { error: string }).error).toBe("cross-origin forbidden");
|
||||
});
|
||||
|
||||
it("no Origin and no Referer → 403 origin required", async () => {
|
||||
const csrf = await getCSRF();
|
||||
// post() sets origin: baseURL by default; override to omit it.
|
||||
const r = await fetch(`${baseURL}/api/autosync`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer s3cret", "x-csrf-token": csrf.token, cookie: csrf.cookie },
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
expect(r.status).toBe(403);
|
||||
expect((await r.json() as { error: string }).error).toBe("origin required");
|
||||
});
|
||||
|
||||
it("missing X-CSRF-Token → 403 missing or invalid csrf token", async () => {
|
||||
const csrf = await getCSRF();
|
||||
const r = await post({ cookie: csrf.cookie }); // no x-csrf-token
|
||||
expect(r.code).toBe(403);
|
||||
expect((r.body as { error: string }).error).toBe("missing or invalid csrf token");
|
||||
});
|
||||
|
||||
it("invalid X-CSRF-Token (mismatch) → 403", async () => {
|
||||
const csrf = await getCSRF();
|
||||
const r = await post({ "x-csrf-token": "wrong" + csrf.token, cookie: csrf.cookie });
|
||||
expect(r.code).toBe(403);
|
||||
expect((r.body as { error: string }).error).toBe("missing or invalid csrf token");
|
||||
});
|
||||
|
||||
it("missing csrf cookie (but valid-looking header) → 403", async () => {
|
||||
const csrf = await getCSRF();
|
||||
const r = await post({ "x-csrf-token": csrf.token }); // no cookie
|
||||
expect(r.code).toBe(403);
|
||||
});
|
||||
|
||||
it("auth runs BEFORE CSRF — no Bearer → 401 (not 403)", async () => {
|
||||
const csrf = await getCSRF();
|
||||
const r = await fetch(`${baseURL}/api/autosync`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: baseURL, "x-csrf-token": csrf.token, cookie: csrf.cookie },
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
expect(r.status).toBe(401); // auth fails first (no Authorization)
|
||||
});
|
||||
|
||||
it("GET route (requireCSRF:false) is CSRF-exempt — /api/auth/status passes without CSRF", async () => {
|
||||
const r = await fetch(`${baseURL}/api/auth/status`);
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
it("flag off → CSRF no-op (POST passes without Origin/X-CSRF-Token)", async () => {
|
||||
const wasEnabled = __authState.enabled;
|
||||
__authState.enabled = false;
|
||||
try {
|
||||
const r = await fetch(`${baseURL}/api/autosync`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" }, // no auth, no CSRF
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
expect(r.status).toBe(200); // flag off → no auth, no CSRF → setEnabled(false) → 200
|
||||
} finally { __authState.enabled = wasEnabled; }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user