Repository: vercel/next.js
Pull request: fix: CSRF origin matching should be case-insensitive

## Summary

This PR fixes two bugs in the `isCsrfOriginAllowed` function used for Server Actions CSRF protection:

1. **Case sensitivity bug**: Domain matching was case-sensitive, but DNS names are case-insensitive per [RFC 1035](https://datatracker.ietf.org/doc/html/rfc1035#section-2.3.3). This caused legitimate requests to fail CSRF checks when the Origin header contained uppercase characters.

2. ~**Trailing dot bug**: FQDNs with trailing dots (e.g., `example.com.`) weren't matched. The trailing dot is valid DNS notation representing the root zone.~ Edit: Reverted, see comments on PR

## The Problem

Before this fix:
```javascript
isCsrfOriginAllowed('sub.VERCEL.com', ['*.vercel.com']) // false ❌
isCsrfOriginAllowed('sub.vercel.com.', ['*.vercel.com']) // false ❌
```

Both of these should return `true` because:
- DNS is case-insensitive (RFC 1035 §2.3.3)
- Trailing dots are valid FQDN notation

## The Fix

Normalize both domain and pattern before comparison:
- Convert to lowercase
- Strip trailing dots

## Testing

Added new test cases for:
- Case-insensitive matching (various case combinations)
- Trailing dot handling (in both domain and pattern)

All existing tests continue to pass.

## Related

- This affects users who configure `serverActions.allowedOrigins` in `next.config.js`
- The bug could cause legitimate Server Action requests to be rejected with CSRF errors if the browser sends an Origin header with different casing than the configured pattern

Changed files:
filename: packages/next/src/server/app-render/csrf-protection.test.ts
patch: @@ -77,4 +77,13 @@ describe('isCsrfOriginAllowed', () => {
     expect(isCsrfOriginAllowed('vercel.com', ['*'])).toBe(false)
     expect(isCsrfOriginAllowed('vercel.com', ['**'])).toBe(false)
   })
+
+  it('should match case-insensitively (RFC 1035)', () => {
+    // DNS names are case-insensitive per RFC 1035
+    expect(isCsrfOriginAllowed('sub.VERCEL.com', ['*.vercel.com'])).toBe(true)
+    expect(isCsrfOriginAllowed('SUB.vercel.COM', ['*.vercel.com'])).toBe(true)
+    expect(isCsrfOriginAllowed('VERCEL.COM', ['vercel.com'])).toBe(true)
+    expect(isCsrfOriginAllowed('vercel.com', ['VERCEL.COM'])).toBe(true)
+    expect(isCsrfOriginAllowed('Sub.Vercel.Com', ['**.vercel.com'])).toBe(true)
+  })
 })

filename: packages/next/src/server/app-render/csrf-protection.ts
patch: @@ -4,8 +4,13 @@
 // 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('.')
+  // DNS names are case-insensitive per RFC 1035
+  // Use ASCII-only toLowerCase to avoid unicode issues
+  const normalizedDomain = domain.replace(/[A-Z]/g, (c) => c.toLowerCase())
+  const normalizedPattern = pattern.replace(/[A-Z]/g, (c) => c.toLowerCase())
+
+  const domainParts = normalizedDomain.split('.')
+  const patternParts = normalizedPattern.split('.')
 
   if (patternParts.length < 1) {
     // pattern is empty and therefore invalid to match against
@@ -68,10 +73,22 @@ export const isCsrfOriginAllowed = (
   originDomain: string,
   allowedOrigins: string[] = []
 ): boolean => {
-  return allowedOrigins.some(
-    (allowedOrigin) =>
-      allowedOrigin &&
-      (allowedOrigin === originDomain ||
-        matchWildcardDomain(originDomain, allowedOrigin))
+  // DNS names are case-insensitive per RFC 1035
+  // Use ASCII-only toLowerCase to avoid unicode issues
+  const normalizedOrigin = originDomain.replace(/[A-Z]/g, (c) =>
+    c.toLowerCase()
   )
+
+  return allowedOrigins.some((allowedOrigin) => {
+    if (!allowedOrigin) return false
+
+    const normalizedAllowed = allowedOrigin.replace(/[A-Z]/g, (c) =>
+      c.toLowerCase()
+    )
+
+    return (
+      normalizedAllowed === normalizedOrigin ||
+      matchWildcardDomain(originDomain, allowedOrigin)
+    )
+  })
 }