Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,7 @@ public/worker-*.js
public/swe-worker-*.js
worker/

# temp test artifacts
server-output.txt
test-ssr.mjs

47 changes: 47 additions & 0 deletions e2e/csrf.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
85 changes: 85 additions & 0 deletions src/lib/csrf.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
20 changes: 18 additions & 2 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import {
isAuthSensitivePath,
AUTH_LIMIT,
} from "@/lib/auth-rate-limit";
import {
isStateChangingMethod,
isCsrfExempt,
validateCsrf,
} from "@/lib/csrf";

export const runtime = "nodejs";

Expand Down Expand Up @@ -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}/`)
Expand All @@ -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;
Expand Down Expand Up @@ -281,4 +297,4 @@ export const config = {
"/settings/:path*",
"/api/:path*",
],
};
};
Loading