Files
obsidian-foundry-sync/tests/e7-1-dispatch.test.ts
Kaysser Kayyali 4ae4876695 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>
2026-06-23 01:28:27 +00:00

140 lines
6.9 KiB
TypeScript

// E7.1c — dispatch auth-gating integration tests.
//
// Starts the REAL server (startServer) on an ephemeral port with an empty
// temp journal LevelDB, then hits it via fetch to verify the ROUTES table is
// consulted: read routes pass without a token; mutation routes 401 without a
// token / 401 with a wrong token / pass with a valid Bearer. Flag off → no auth.
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { ClassicLevel } from "classic-level";
import { mkdtemp, mkdir, rm, writeFile } 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;
const realFetch = globalThis.fetch;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), "e7dispatch-"));
await mkdir(join(dir, "refined"), { recursive: true });
await mkdir(join(dir, "cc"), { recursive: true });
// Create an empty journal LevelDB so JournalDb.open (readOnly) succeeds.
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
await jdb.open();
await jdb.close();
const { server: srv } = await startServer({
journal: join(dir, "journal"),
refinedDir: join(dir, "refined"),
ccDir: join(dir, "cc"),
outDir: join(dir, "out"),
mode: "dev", // dev mode: POST /api/autosync {enabled:true} → 400 apply-gate (no relay needed)
port: 0,
host: "127.0.0.1",
});
server = srv;
const addr = server.address();
if (!addr || typeof addr === "object" && !("port" in addr)) throw new Error("no port");
baseURL = `http://127.0.0.1:${(addr 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 });
});
beforeEach(() => { __authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1"; });
afterEach(() => { __authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1"; });
async function req(path: string, init: RequestInit = {}): Promise<{ code: number; body: unknown }> {
const r = await fetch(`${baseURL}${path}`, init);
const text = await r.text();
let body: unknown = text;
try { body = text ? JSON.parse(text) : null; } catch { /* non-JSON */ }
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;
const r = await req("/api/status");
expect(r.code).toBe(200);
});
it("POST /api/autosync (mutation) passes without a token (dev-mode apply-gate 400, not 401)", async () => {
__authState.enabled = false;
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: true }) });
expect(r.code).toBe(400); // apply-mode gate (dev), NOT an auth 401
expect((r.body as { error: string }).error).toMatch(/requires --apply mode/);
});
it("/favicon.ico → 204", async () => {
const r = await req("/favicon.ico");
expect(r.code).toBe(204);
});
it("unknown route → 404", async () => {
const r = await req("/api/nope");
expect(r.code).toBe(404);
});
});
describe("E7.1c dispatch (flag on + token set)", () => {
// E7.2: enforcement only on a public bind (0.0.0.0). The server binds 127.0.0.1
// (safe for the test), so override __authState.bound to simulate a public bind.
beforeEach(() => { __authState.enabled = true; __authState.token = "s3cret-token"; __authState.bound = "0.0.0.0"; });
it("GET /api/auth/status (always-open) passes WITHOUT a token", async () => {
const r = await req("/api/auth/status");
expect(r.code).toBe(200);
});
it("GET /api/status (gated — E7.3) → 401 WITHOUT a token in public mode", async () => {
const r = await req("/api/status");
expect(r.code).toBe(401); // E7.3: /api/status leaks dir paths → gated
});
it("GET /api/autosync (gated — E7.3) → 401 WITHOUT a token in public mode", async () => {
const r = await req("/api/autosync");
expect(r.code).toBe(401);
});
it("POST /api/autosync (mutation) WITHOUT a token → 401", async () => {
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: false }) });
expect(r.code).toBe(401);
});
it("POST /api/autosync with a WRONG token → 401", async () => {
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer wrong" }, body: JSON.stringify({ enabled: false }) });
expect(r.code).toBe(401);
});
it("POST /api/autosync with a malformed Authorization → 401 bad auth header", async () => {
const r = await req("/api/autosync", { method: "POST", headers: { "content-type": "application/json", authorization: "Basic abc" }, body: JSON.stringify({ enabled: false }) });
expect(r.code).toBe(401);
expect((r.body as { error: string }).error).toMatch(/bad auth header/);
});
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 + 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/);
});
});