From 393057c2f2adf5ec749cea6e6a16eeae01a1ef8c Mon Sep 17 00:00:00 2001 From: Abhisumat Kashyap Date: Thu, 4 Jun 2026 00:11:44 +0530 Subject: [PATCH] feat(csrf): add Origin/Referer CSRF protection (#1878) --- .env.example | 9 +++++ .gitignore | 4 +++ e2e/csrf.spec.js | 47 ++++++++++++++++++++++++++ src/lib/csrf.ts | 85 +++++++++++++++++++++++++++++++++++++++++++++++ src/middleware.ts | 20 +++++++++-- 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 e2e/csrf.spec.js create mode 100644 src/lib/csrf.ts diff --git a/.env.example b/.env.example index 1e93c99bf..cb5327deb 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,15 @@ NEXTAUTH_URL=http://localhost:3000 # Must not have a trailing slash. # NEXT_PUBLIC_APP_URL=https://devtrack-delta.vercel.app +# ------------------------------------------------------- +# CSRF Allowed Origins (optional — used by CSRF middleware to validate Origin/Referer +# headers on state-changing POST/PUT/PATCH/DELETE API requests). +# Comma-separated list of origins that are allowed to make cross-origin requests. +# NEXTAUTH_URL and NEXT_PUBLIC_APP_URL are included automatically — you only need +# to add this if you have additional allowed origins (e.g. staging, custom domains). +# Example: ALLOWED_ORIGINS=https://staging.devtrack.app,https://devtrack.example.com +# ALLOWED_ORIGINS= + # Generate with: openssl rand -base64 32 NEXTAUTH_SECRET=your_nextauth_secret diff --git a/.gitignore b/.gitignore index 418741edb..93752afa1 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ public/worker-*.js public/swe-worker-*.js worker/ +# temp test artifacts +server-output.txt +test-ssr.mjs + diff --git a/e2e/csrf.spec.js b/e2e/csrf.spec.js new file mode 100644 index 000000000..0242d7a09 --- /dev/null +++ b/e2e/csrf.spec.js @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; + +test("POST with invalid origin returns 403", async ({ request }) => { + const res = await request.post("/api/goals", { + headers: { + "Content-Type": "application/json", + Origin: "https://evil.com", + }, + data: { title: "x", target: 1, unit: "commits", recurrence: "none" }, + }); + expect(res.status()).toBe(403); +}); + +test("POST with invalid referer returns 403", async ({ request }) => { + const res = await request.post("/api/goals", { + headers: { + "Content-Type": "application/json", + referer: "https://evil.com/fake", + }, + data: { title: "x", target: 1, unit: "commits", recurrence: "none" }, + }); + expect(res.status()).toBe(403); +}); + +test("GET is not blocked by CSRF", async ({ request }) => { + const res = await request.get("/api/goals"); + expect(res.status()).toBeGreaterThanOrEqual(200); + expect(res.status()).not.toBe(403); +}); + +test("webhook POST without origin is not blocked", async ({ request }) => { + const res = await request.post("/api/webhooks/github", { + data: { ref: "refs/heads/main" }, + }); + expect(res.status()).not.toBe(403); +}); + +test("same-origin POST reaches handler", async ({ request }) => { + const res = await request.post("/api/goals", { + headers: { + "Content-Type": "application/json", + Origin: "http://127.0.0.1:3002", + }, + data: { title: "x", target: 1, unit: "commits", recurrence: "none" }, + }); + expect(res.status()).not.toBe(403); +}); diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts new file mode 100644 index 000000000..d00bed823 --- /dev/null +++ b/src/lib/csrf.ts @@ -0,0 +1,85 @@ +import type { NextRequest } from "next/server"; + +const STATE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +const WEBHOOK_PREFIXES = [ + "/api/webhooks/github", + "/api/webhooks/custom", + "/api/webhooks/dispatch", +]; + +const RATE_LIMIT_API_PREFIXES = [ + "/api/metrics", + "/api/auth/signin", + "/api/auth/callback", +]; + +function getAllowedOrigins(): string[] { + const raw = process.env.ALLOWED_ORIGINS ?? ""; + const nextauthUrl = process.env.NEXTAUTH_URL; + const appUrl = process.env.NEXT_PUBLIC_APP_URL; + const origins: string[] = []; + for (const s of raw.split(",")) { + const t = s.trim(); + if (t) origins.push(t.replace(/\/+$/, "")); + } + if (nextauthUrl) origins.push(nextauthUrl.replace(/\/+$/, "")); + if (appUrl) origins.push(appUrl.replace(/\/+$/, "")); + const devOrigin = process.env.NODE_ENV === "development" + ? "http://localhost:3000" + : null; + if (devOrigin && !origins.some((o) => o === devOrigin)) { + origins.push(devOrigin); + } + return origins; +} + +function isWebhookRoute(pathname: string): boolean { + return WEBHOOK_PREFIXES.some((p) => pathname.startsWith(p)); +} + +function isRateLimitRoute(pathname: string): boolean { + return RATE_LIMIT_API_PREFIXES.some((p) => pathname.startsWith(p)); +} + +export function isStateChangingMethod(method: string): boolean { + return STATE_METHODS.has(method); +} + +export function isCsrfExempt(pathname: string): boolean { + return isWebhookRoute(pathname) || isRateLimitRoute(pathname); +} + +export function validateCsrf(req: NextRequest): { + valid: boolean; + reason?: string; +} { + const origin = req.headers.get("origin"); + const referer = req.headers.get("referer"); + + if (!origin && !referer) { + return { valid: true }; + } + + const allowed = getAllowedOrigins(); + if (allowed.length === 0) { + return { valid: true }; + } + + if (origin) { + const match = allowed.some((a) => origin === a || origin.startsWith(a + "/")); + if (!match) { + return { valid: false, reason: "Forbidden" }; + } + return { valid: true }; + } + + if (referer) { + const match = allowed.some((a) => referer.startsWith(a)); + if (!match) { + return { valid: false, reason: "Forbidden" }; + } + } + + return { valid: true }; +} diff --git a/src/middleware.ts b/src/middleware.ts index cb352cde9..dfd163a0e 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -5,6 +5,11 @@ import { isAuthSensitivePath, AUTH_LIMIT, } from "@/lib/auth-rate-limit"; +import { + isStateChangingMethod, + isCsrfExempt, + validateCsrf, +} from "@/lib/csrf"; export const runtime = "nodejs"; @@ -201,6 +206,18 @@ export async function middleware(req: NextRequest) { }); } + const isApiStateChange = + pathname.startsWith("/api/") && + isStateChangingMethod(req.method) && + !isCsrfExempt(pathname); + + if (isApiStateChange) { + const csrf = validateCsrf(req); + if (!csrf.valid) { + return NextResponse.json({ error: csrf.reason }, { status: 403 }); + } + } + const protectedRoutes = ["/dashboard", "/settings"]; const isProtectedRoute = protectedRoutes.some( (route) => pathname === route || pathname.startsWith(`${route}/`) @@ -216,7 +233,6 @@ export async function middleware(req: NextRequest) { return NextResponse.next(); } - // Authentication rate limiting if (isAuthSensitivePath(pathname)) { const ip = getIp(req); const authLimit = isDev ? 1000 : AUTH_LIMIT; @@ -281,4 +297,4 @@ export const config = { "/settings/:path*", "/api/:path*", ], -}; \ No newline at end of file +};