From 880b97c6ddde847de9cac34cd733562bb81cee45 Mon Sep 17 00:00:00 2001 From: atul-upadhyay-7 Date: Fri, 3 Jul 2026 22:07:58 +0530 Subject: [PATCH] fix(security): validate port in CSRF origin check (#7212) - Add normalizePort() helper that resolves empty URL.port to the protocol default (443 for https, 80 for http) - Compare normalized ports in isValidOrigin so requests from mismatched ports (e.g. :9999) are rejected even when hostname and protocol match - Add 15 unit tests covering: port mismatch, default port normalization, env-based custom ports, referer fallback, malformed origin, and combined origin+referer scenarios --- lib/security/csrf.test.ts | 123 ++++++++++++++++++++++++++++++++++++++ lib/security/csrf.ts | 11 +++- 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 lib/security/csrf.test.ts diff --git a/lib/security/csrf.test.ts b/lib/security/csrf.test.ts new file mode 100644 index 000000000..2c77c1516 --- /dev/null +++ b/lib/security/csrf.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { validateCSRF } from './csrf'; + +function makeRequest(headers: Record): Request { + return new Request('https://commitpulse.vercel.app/api/test', { + method: 'POST', + headers, + }); +} + +describe('validateCSRF', () => { + const originalEnv = process.env.NEXT_PUBLIC_SITE_URL; + + beforeEach(() => { + process.env.NEXT_PUBLIC_SITE_URL = 'https://commitpulse.vercel.app'; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.NEXT_PUBLIC_SITE_URL; + } else { + process.env.NEXT_PUBLIC_SITE_URL = originalEnv; + } + }); + + it('allows requests with no origin and no referer (server-to-server)', () => { + const req = new Request('https://commitpulse.vercel.app/api/test', { method: 'POST' }); + expect(validateCSRF(req)).toBeNull(); + }); + + it('allows requests from the exact same origin', () => { + const req = makeRequest({ origin: 'https://commitpulse.vercel.app' }); + expect(validateCSRF(req)).toBeNull(); + }); + + it('allows requests from the same origin with referer header', () => { + const req = makeRequest({ referer: 'https://commitpulse.vercel.app/dashboard' }); + expect(validateCSRF(req)).toBeNull(); + }); + + it('rejects requests from a different port on the same hostname', () => { + const req = makeRequest({ origin: 'https://commitpulse.vercel.app:9999' }); + const res = validateCSRF(req); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('rejects requests from a different hostname', () => { + const req = makeRequest({ origin: 'https://evil.example.com' }); + const res = validateCSRF(req); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('rejects requests from a different protocol', () => { + const req = makeRequest({ origin: 'http://commitpulse.vercel.app' }); + const res = validateCSRF(req); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('allows requests from the same origin with explicit default port in origin', () => { + const req = makeRequest({ origin: 'https://commitpulse.vercel.app:443' }); + expect(validateCSRF(req)).toBeNull(); + }); + + it('allows requests from the same origin with explicit port in allowed origin env', () => { + process.env.NEXT_PUBLIC_SITE_URL = 'https://commitpulse.vercel.app:3000'; + const req = makeRequest({ origin: 'https://commitpulse.vercel.app:3000' }); + expect(validateCSRF(req)).toBeNull(); + }); + + it('rejects requests from a non-default port when allowed origin uses default port', () => { + const req = makeRequest({ origin: 'https://commitpulse.vercel.app:3000' }); + const res = validateCSRF(req); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('returns 403 with JSON error body on CSRF failure', () => { + const req = makeRequest({ origin: 'https://evil.example.com:443' }); + const res = validateCSRF(req); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('rejects requests with malformed origin', () => { + const req = makeRequest({ origin: 'not-a-url' }); + const res = validateCSRF(req); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('falls back to referer when origin is absent', () => { + const req = makeRequest({ referer: 'https://commitpulse.vercel.app/page' }); + expect(validateCSRF(req)).toBeNull(); + }); + + it('rejects referer from a different port', () => { + const req = makeRequest({ referer: 'https://commitpulse.vercel.app:8080/page' }); + const res = validateCSRF(req); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); + + it('allows either origin OR referer to be valid', () => { + const req = makeRequest({ + origin: 'https://commitpulse.vercel.app', + referer: 'https://evil.example.com:9999/page', + }); + expect(validateCSRF(req)).toBeNull(); + }); + + it('rejects when both origin and referer are invalid', () => { + const req = makeRequest({ + origin: 'https://evil.example.com', + referer: 'https://evil.example.com:9999/page', + }); + const res = validateCSRF(req); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + }); +}); diff --git a/lib/security/csrf.ts b/lib/security/csrf.ts index 40158cbee..89223819d 100644 --- a/lib/security/csrf.ts +++ b/lib/security/csrf.ts @@ -1,3 +1,8 @@ +function normalizePort(url: URL): string { + if (url.port) return url.port; + return url.protocol === 'https:' ? '443' : '80'; +} + export function validateCSRF(request: Request): Response | null { const origin = request.headers.get('origin'); const referer = request.headers.get('referer'); @@ -14,7 +19,11 @@ export function validateCSRF(request: Request): Response | null { const url = new URL(value); const allowed = new URL(allowedOrigin); - return url.protocol === allowed.protocol && url.hostname === allowed.hostname; + return ( + url.protocol === allowed.protocol && + url.hostname === allowed.hostname && + normalizePort(url) === normalizePort(allowed) + ); } catch { return false; }