Skip to content

Server Actions CSRF validation should be case insensitive #88599

@geeknik

Description

@geeknik

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:

  1. False positive CSRF blocks that reject legitimate same-origin requests when hostname case doesn't match
  2. Inconsistent security behavior across different deployment configurations
  3. 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

  1. Clone Next.js repository
  2. Create a test application with Server Actions
  3. 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 main

Which 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:

  1. The correct approach is already implemented elsewhere in the codebase
  2. The vulnerability is an inconsistency, not a design flaw
  3. The fix aligns with existing Next.js security patterns

References

  1. RFC 1123 - Requirements for Internet Hosts
    https://tools.ietf.org/html/rfc1123#section-2.1

  2. RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax
    https://tools.ietf.org/html/rfc3986#section-3.2.2

  3. WHATWG URL Standard - Host Parsing
    https://url.spec.whatwg.org/#host-parsing

  4. OWASP CSRF Prevention Cheat Sheet
    https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

  5. Next.js Server Actions Documentation
    https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

  6. CWE-178: Improper Handling of Case Sensitivity
    https://cwe.mitre.org/data/definitions/178.html

  7. 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()
      : undefined

Note: 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions