-
Notifications
You must be signed in to change notification settings - Fork 30.3k
Description
Link to the code that reproduces this issue
https://github.com/geeknik/nextjs-csrf-case-sensitivity-repro
To Reproduce
Summary
Next.js Server Actions implement CSRF protection by comparing the Origin header against the Host or x-forwarded-host header to ensure same-origin requests. However, this comparison is case-sensitive for the host header while the origin domain is normalized to lowercase by the URL API, violating RFC 1123 which mandates that hostnames are case-insensitive.
This inconsistency can lead to:
- False positive CSRF blocks that reject legitimate same-origin requests when hostname case doesn't match
- Inconsistent security behavior across different deployment configurations
- RFC 1123 compliance violation treating semantically identical hostnames as different
The vulnerability exists in packages/next/src/server/app-render/action-handler.ts where parseHostHeader() preserves the original case of the Host/x-forwarded-host headers, while originDomain from the Origin header is always lowercase (normalized by JavaScript's URL API).
Root Cause:
// Origin is lowercased by URL API
const originDomain = new URL(originHeader).host // → "example.com"
// Host header case is preserved
const host = parseHostHeader(req.headers) // → "Example.com"
// Case-sensitive comparison causes mismatch
if (originDomain !== host.value) { // "example.com" !== "Example.com" → TRUE
// False positive CSRF block or bypass
}Steps to Reproduce
Environment Setup
- Clone Next.js repository
- Create a test application with Server Actions
- Configure deployment with a hostname containing uppercase letters (e.g.,
Example.com)
Reproduction Steps
Step 1: Create a Next.js app with a Server Action
// app/actions.ts
'use server'
export async function testAction() {
return { success: true, message: 'Action executed' }
}// app/page.tsx
import { testAction } from './actions'
export default function Page() {
return (
<form action={testAction}>
<button type="submit">Test Action</button>
</form>
)
}Step 2: Configure reverse proxy to set x-forwarded-host with uppercase letters
# nginx.conf
proxy_set_header X-Forwarded-Host "Example.com";Step 3: Send a legitimate same-origin request with lowercase origin
curl -X POST http://localhost:3000/_next/data/action \
-H "Origin: http://example.com" \
-H "x-forwarded-host: Example.com" \
-H "Content-Type: application/json" \
-d '{"actionId":"abc123"}'Current vs. Expected behavior
Expected Result: Request should succeed (same origin)
Actual Result: Request fails with "Invalid Server Actions request" error
This inconsistency means:
- Dev endpoints (
/__nextjs_*) have correct CSRF protection - Server Actions have broken CSRF protection
- Different code paths behave differently for the same security check
RFC 1123 Section 2.1 states:
"Domain names are case-insensitive"
The hostnames Example.com, example.com, and EXAMPLE.COM are semantically identical according to DNS and HTTP specifications.
Implications:
- Violates web standards
- Unpredictable behavior across different clients/proxies
- Different proxies may normalize case differently (Apache, Nginx, Cloudflare, etc.)
Provide environment information
Fedora 43
Next.js mainWhich area(s) are affected? (Select all that apply)
Server Actions
Which stage(s) are affected? (Select all that apply)
Other (Deployed)
Additional context
Additional Context
Comparison with Correct Implementation
The Next.js codebase already has a correct implementation of this check in packages/next/src/server/lib/router-utils/block-cross-site.ts:
// CORRECT IMPLEMENTATION (block-cross-site.ts:86-94)
const rawOrigin = req.headers['origin']
if (rawOrigin && rawOrigin !== 'null') {
const parsedOrigin = parseUrl(rawOrigin)
if (parsedOrigin) {
const originLowerCase = parsedOrigin.hostname.toLowerCase() // ✅ LOWERCASED
if (!isCsrfOriginAllowed(originLowerCase, allowedOrigins)) {
return warnOrBlockRequest(res, originLowerCase, mode)
}
}
}This demonstrates:
- The correct approach is already implemented elsewhere in the codebase
- The vulnerability is an inconsistency, not a design flaw
- The fix aligns with existing Next.js security patterns
References
-
RFC 1123 - Requirements for Internet Hosts
https://tools.ietf.org/html/rfc1123#section-2.1 -
RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax
https://tools.ietf.org/html/rfc3986#section-3.2.2 -
WHATWG URL Standard - Host Parsing
https://url.spec.whatwg.org/#host-parsing -
OWASP CSRF Prevention Cheat Sheet
https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html -
Next.js Server Actions Documentation
https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations -
CWE-178: Improper Handling of Case Sensitivity
https://cwe.mitre.org/data/definitions/178.html -
CVSSv4.0 Specification
https://www.first.org/cvss/v4.0/specification-document
Recommended Fix
Normalize all hostname values to lowercase before comparison, consistent with RFC 1123 and the existing implementation in block-cross-site.ts.
Patch 1: Normalize Origin Domain (Primary Fix)
File: packages/next/src/server/app-render/action-handler.ts
Lines 613-617:
const originHeader = req.headers['origin']
const originDomain =
typeof originHeader === 'string' && originHeader !== 'null'
- ? new URL(originHeader).host
+ ? new URL(originHeader).host.toLowerCase()
: undefinedNote: This line already normalizes to lowercase via URL API, but making it explicit improves clarity.
Patch 2: Normalize Host Headers (Required Fix)
File: packages/next/src/server/app-render/action-handler.ts
Lines 474-506 (in parseHostHeader function):
export function parseHostHeader(
headers: IncomingHttpHeaders,
originDomain?: string
) {
const forwardedHostHeader = headers['x-forwarded-host']
const forwardedHostHeaderValue =
forwardedHostHeader && Array.isArray(forwardedHostHeader)
- ? forwardedHostHeader[0]
+ ? forwardedHostHeader[0]?.toLowerCase()
- : forwardedHostHeader?.split(',')?.[0]?.trim()
+ : forwardedHostHeader?.split(',')?.[0]?.trim()?.toLowerCase()
- const hostHeader = headers['host']
+ const hostHeader = headers['host']?.toLowerCase()
if (originDomain) {
return forwardedHostHeaderValue === originDomain
? {
type: HostType.XForwardedHost,
value: forwardedHostHeaderValue,
}
: hostHeader === originDomain
? {
type: HostType.Host,
value: hostHeader,
}
: undefined
}
return forwardedHostHeaderValue
? {
type: HostType.XForwardedHost,
value: forwardedHostHeaderValue,
}
: hostHeader
? {
type: HostType.Host,
value: hostHeader,
}
: undefined
}Patch 3: Normalize in Wildcard Matcher (Defense in Depth)
File: packages/next/src/server/app-render/csrf-protection.ts
Lines 6-8:
function matchWildcardDomain(domain: string, pattern: string) {
- const domainParts = domain.split('.')
- const patternParts = pattern.split('.')
+ const domainParts = domain.toLowerCase().split('.')
+ const patternParts = pattern.toLowerCase().split('.')Complete Patch File
diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts
index abc123..def456 100644
--- a/packages/next/src/server/app-render/action-handler.ts
+++ b/packages/next/src/server/app-render/action-handler.ts
@@ -471,11 +471,11 @@ export function parseHostHeader(
) {
const forwardedHostHeader = headers['x-forwarded-host']
const forwardedHostHeaderValue =
forwardedHostHeader && Array.isArray(forwardedHostHeader)
- ? forwardedHostHeader[0]
- : forwardedHostHeader?.split(',')?.[0]?.trim()
- const hostHeader = headers['host']
+ ? forwardedHostHeader[0]?.toLowerCase()
+ : forwardedHostHeader?.split(',')?.[0]?.trim()?.toLowerCase()
+ const hostHeader = headers['host']?.toLowerCase()
if (originDomain) {
return forwardedHostHeaderValue === originDomain
? {
diff --git a/packages/next/src/server/app-render/csrf-protection.ts b/packages/next/src/server/app-render/csrf-protection.ts
index abc123..def456 100644
--- a/packages/next/src/server/app-render/csrf-protection.ts
+++ b/packages/next/src/server/app-render/csrf-protection.ts
@@ -5,8 +5,8 @@
// https://nextjs.org/docs/app/api-reference/components/image#remotepatterns
// TODO - retrofit micromatch to work in edge and use that instead
function matchWildcardDomain(domain: string, pattern: string) {
- const domainParts = domain.split('.')
- const patternParts = pattern.split('.')
+ const domainParts = domain.toLowerCase().split('.')
+ const patternParts = pattern.toLowerCase().split('.')
if (patternParts.length < 1) {
// pattern is empty and therefore invalid to match against