Skip to content

Conversation

@jaffarkeikei
Copy link
Contributor

@jaffarkeikei jaffarkeikei commented Jan 27, 2026

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

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

Copy link
Member

@bgw bgw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At a high level: Yes, this looks like a good change.

I'm a bit concerned about unicode support though, so I'm requesting changes. See my inline comment.

Comment on lines 8 to 9
const normalizedDomain = domain.toLowerCase().replace(/\.$/, '')
const normalizedPattern = pattern.toLowerCase().replace(/\.$/, '')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this handle punycode domain names? Is it possible that this gets a unicode-encoded domain and tries to lowercase that, because that would be a security vulnerability as RFC 1035 only covers 7-bit ASCII. It's hard for me to know for certain if this function gets the punycode representation or the unicode representation.

You can address this in one of two ways:

  • Write your own toLowerCase implementation that's careful to only modify A-Z.
  • Add an e2e test to tests/e2e that proves this does not try to lower-case unicode characters. I'm not sure how you'd mock the DNS lookup though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Implemented custom asciiLowerCase() that only modifies A-Z (65-90), leaving all other characters unchanged per RFC 1035.

Added tests for unicode/punycode handling. Also updated baseline-browser-mapping to 2.9.19 to fix the test failures.

@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Jan 29, 2026

Allow CI Workflow Run

  • approve CI run for commit: 6eebd5f

Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer

@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Jan 29, 2026

Tests Passed

@jaffarkeikei
Copy link
Contributor Author

The test failures appear to be unrelated to the CSRF changes in this PR. All failing tests are showing baseline-browser-mapping deprecation warnings in stderr, which causes tests expecting empty stderr to fail.

The warning appears in multiple test suites:

  • Production Custom Build Directory
  • config-output-export
  • webpack-require-hook
  • server-side dev errors

Would you like me to update baseline-browser-mapping to the latest version as part of this PR, or should this be handled separately?

Copy link
Member

@bgw bgw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the trailing dot: After some further research, it does look like browsers do sometimes treat it as a separate, non-equivalent, hostname:

The example.com and example.com. domains are not equivalent and typically treated as distinct.

-- https://url.spec.whatwg.org/#concept-host

Though apparently the whole spec is a mess:

Image

Because this is security critical code where being overly restrictive isn't a security issue, but not being restrictive enough could result in a CVE, let's be conservative treat the trailing dot as a separate host/domain, like browsers do.

I don't see any practical way the trailing dot could be abused (and I had a chat with Claude to double-check), but I also don't think anyone was running into problems with that. I can believe people were running into case sensitivity issues, but that's more straightforward because all the specs are clear and agree about ASCII case-insensitive matching.


Would you like me to update baseline-browser-mapping to the latest version as part of this PR, or should this be handled separately?

No thanks, just rebase on top of canary. #89175 should've fixed that issue.

Comment on lines 112 to 114
// ASCII portion should still be case-insensitive
expect(isCsrfOriginAllowed('münchen.COM', ['münchen.com'])).toBe(true)
expect(isCsrfOriginAllowed('MÜNCHEN.COM', ['MÜNCHEN.com'])).toBe(true)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this does a better job of testing what the comment suggests:

Suggested change
// ASCII portion should still be case-insensitive
expect(isCsrfOriginAllowed('münchen.COM', ['münchen.com'])).toBe(true)
expect(isCsrfOriginAllowed('MÜNCHEN.COM', ['MÜNCHEN.com'])).toBe(true)
// ASCII portion should still be case-insensitive
expect(isCsrfOriginAllowed('MüNCHEN.COM', ['münchen.com'])).toBe(true)
expect(isCsrfOriginAllowed('mÜnchen.COM', ['MÜNCHEN.com'])).toBe(true)

"@next/env": "16.2.0-canary.13",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"baseline-browser-mapping": "^2.9.19",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert this. The CI failures should be fixed by #89175

@bgw
Copy link
Member

bgw commented Jan 29, 2026

Looks like you need to run pnpm prettier-fix or pnpm prettier --write packages/next/src/server/app-render/csrf-protection.test.ts

Fixes RFC 1035 compliance by making DNS name matching case-insensitive.

Uses ASCII-only character replacement instead of toLowerCase() to avoid
potential unicode normalization issues.

Tests added for case-insensitive matching across exact matches and wildcard
patterns.
@jaffarkeikei jaffarkeikei force-pushed the fix/csrf-case-insensitive branch from 19df7c6 to 6eebd5f Compare January 29, 2026 22:09
@jaffarkeikei
Copy link
Contributor Author

Updated PR based on feedback:

✅ Rebased on latest canary
✅ Removed trailing dot handling (keeping conservative approach)
✅ Kept case-insensitive matching with ASCII-only character replacement
✅ Removed trailing dot test cases

The PR now only addresses case-insensitive DNS name matching per RFC 1035, using ASCII-only character replacement to avoid unicode issues.

@bgw bgw changed the title fix: CSRF origin matching should be case-insensitive and handle trailing dots fix: CSRF origin matching should be case-insensitive Jan 29, 2026
@bgw bgw enabled auto-merge (squash) January 29, 2026 23:22
@bgw bgw merged commit d67d0e5 into vercel:canary Jan 29, 2026
231 of 247 checks passed
@bgw
Copy link
Member

bgw commented Jan 29, 2026

Thanks for the contribution, @jaffarkeikei! Sorry about all the extra back-and-forth. Because this is code is security-related, I had to be careful that we're not screwing anything up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants