Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
30b4e78
test(e2e): add next app that reproduces case-sensitive CSRF validatio…
Grit03 Jan 18, 2026
23b35e1
test(e2e): verify server action response successfully
Grit03 Jan 18, 2026
339be02
test(e2e): rename csrf-validation-case-insensitive to host-match-case…
Grit03 Jan 24, 2026
cff97be
test(e2e): add domain constants for case-insensitive origin matching
Grit03 Jan 24, 2026
5dd37aa
test(e2e): rename host-origin case-insensitive test for consistency
Grit03 Jan 24, 2026
7165bf5
fix(e2e): update ORIGIN_DOMAIN to include protocol in host matching
Grit03 Jan 24, 2026
2123977
chore(e2e): remove redundant logging of headers in proxy function
Grit03 Jan 24, 2026
e2b569a
test(e2e): use IANA reserved domain (example.com) for origin testing
Grit03 Jan 24, 2026
d08c9b8
fix(action-handler): correct typo in comment about missing origin hea…
Grit03 Jan 18, 2026
a86e5fa
refactor(action-handler): make origin host lowercase explicit
Grit03 Jan 18, 2026
c0142aa
test(action-handler): add tests to verify parseHostHeader lowercases …
Grit03 Jan 18, 2026
31ab426
fix(action-handler): ensure case-insensitive matching for Host and X-…
Grit03 Jan 18, 2026
c0bdc7e
refactor(action-handler): extract origin/host match helper for testab…
Grit03 Jan 18, 2026
d216aed
test(action-handler): add tests for isOriginMatchingHost function
Grit03 Jan 18, 2026
131ecaf
test(action-handler): add defensive origin/host case-insensitive checks
Grit03 Jan 18, 2026
b3e5fab
fix(action-handler): refactor and make isOriginMatchingHost defensive
Grit03 Jan 18, 2026
a2d6510
test(e2e): verify allowedOrigins config performs case-insensitive com…
Grit03 Jan 24, 2026
94e16ff
test(csrf-protection): add case-insensitive checks for allowedOrigins
Grit03 Jan 24, 2026
cb275dd
fix(csrf-protection): implement case-insensitive origin matching
Grit03 Jan 24, 2026
019b486
chore(e2e): remove case-insensitive host-origin comparison tests and …
Grit03 Jan 25, 2026
9d440a5
chore(e2e): remove case-insensitive origin matching tests and related…
Grit03 Jan 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion packages/next/src/server/app-render/action-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parseHostHeader } from './action-handler'
import { isOriginMatchingHost, parseHostHeader } from './action-handler'

describe('parseHostHeader', () => {
it('should return correct host', () => {
Expand Down Expand Up @@ -88,4 +88,74 @@ describe('parseHostHeader', () => {
)
).toEqual({ type: 'x-forwarded-host', value: 'www.bar.com' })
})

it('lowercases host headers', () => {
expect(
parseHostHeader({
host: 'Example.com',
})
).toEqual({ type: 'host', value: 'example.com' })

expect(
parseHostHeader({
host: 'www.foo.com',
'x-forwarded-host': 'Example.com',
})
).toEqual({ type: 'x-forwarded-host', value: 'example.com' })
})
})

describe('isOriginMatchingHost', () => {
it('matches origin to host case-insensitively', () => {
expect(
isOriginMatchingHost(
'example.com',
parseHostHeader({
host: 'Example.com',
})
)
).toBe(true)

expect(
isOriginMatchingHost(
'Example.com',
parseHostHeader({
host: 'example.com',
})
)
).toBe(true)
})

it('matches origin to x-forwarded-host case-insensitively', () => {
expect(
isOriginMatchingHost(
'example.com',
parseHostHeader({
host: 'www.foo.com',
'x-forwarded-host': 'Example.com',
})
)
).toBe(true)

expect(
isOriginMatchingHost(
'Example.com',
parseHostHeader({
host: 'www.foo.com',
'x-forwarded-host': 'example.com',
})
)
).toBe(true)
})

it('returns false when host does not match origin', () => {
expect(
isOriginMatchingHost(
'example.com',
parseHostHeader({
host: 'other.com',
})
)
).toBe(false)
})
})
24 changes: 18 additions & 6 deletions packages/next/src/server/app-render/action-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,9 +478,9 @@ 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
Expand Down Expand Up @@ -527,6 +527,18 @@ type HandleActionResult =
/** The request turned out not to be a server action. */
| null

/**
* Checks if the origin domain matches the host (case-insensitive),
* since parseHostHeader function and URL API both normalize to lowercase but this
* comparison is defensive.
*/
export function isOriginMatchingHost(
originDomain: string,
host: Host | undefined
) {
return originDomain.toLowerCase() === host?.value?.toLowerCase()
}

export async function handleAction({
req,
res,
Expand Down Expand Up @@ -617,7 +629,7 @@ export async function handleAction({
const originHeader = req.headers['origin']
const originDomain =
typeof originHeader === 'string' && originHeader !== 'null'
? new URL(originHeader).host
? new URL(originHeader).host.toLowerCase()
: undefined
const host = parseHostHeader(req.headers)

Expand All @@ -631,10 +643,10 @@ export async function handleAction({
// This is to prevent CSRF attacks. If `x-forwarded-host` is set, we need to
// ensure that the request is coming from the same host.
if (!originDomain) {
// This might be an old browser that doesn't send `host` header. We ignore
// This might be an old browser that doesn't send `origin` header. We ignore
// this case.
warning = 'Missing `origin` header from a forwarded Server Actions request.'
} else if (!host || originDomain !== host.value) {
} else if (!host || !isOriginMatchingHost(originDomain, host)) {
// If the customer sets a list of allowed origins, we'll allow the request.
// These are considered safe but might be different from forwarded host set
// by the infra (i.e. reverse proxies).
Expand Down
7 changes: 7 additions & 0 deletions packages/next/src/server/app-render/csrf-protection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,11 @@ describe('isCsrfOriginAllowed', () => {
expect(isCsrfOriginAllowed('vercel.com', ['*'])).toBe(false)
expect(isCsrfOriginAllowed('vercel.com', ['**'])).toBe(false)
})

it('is case-insensitive for allowedOrigins', () => {
expect(isCsrfOriginAllowed('Example.com', ['example.com'])).toBe(true)
expect(isCsrfOriginAllowed('example.com', ['EXAMPLE.COM'])).toBe(true)
expect(isCsrfOriginAllowed('Sub.Example.com', ['*.example.com'])).toBe(true)
expect(isCsrfOriginAllowed('sub.example.com', ['*.EXAMPLE.COM'])).toBe(true)
})
})
16 changes: 10 additions & 6 deletions packages/next/src/server/app-render/csrf-protection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,14 @@ export const isCsrfOriginAllowed = (
originDomain: string,
allowedOrigins: string[] = []
): boolean => {
return allowedOrigins.some(
(allowedOrigin) =>
allowedOrigin &&
(allowedOrigin === originDomain ||
matchWildcardDomain(originDomain, allowedOrigin))
)
const normalizedOrigin = originDomain.toLowerCase()

return allowedOrigins.some((allowedOrigin) => {
if (!allowedOrigin) return false
const normalizedAllowed = allowedOrigin.toLowerCase()
return (
normalizedAllowed === normalizedOrigin ||
matchWildcardDomain(normalizedOrigin, normalizedAllowed)
)
})
}