diff --git a/packages/next/src/server/app-render/action-handler.test.ts b/packages/next/src/server/app-render/action-handler.test.ts index 65d4408490cf5c..e9f07cdc6b302a 100644 --- a/packages/next/src/server/app-render/action-handler.test.ts +++ b/packages/next/src/server/app-render/action-handler.test.ts @@ -1,4 +1,4 @@ -import { parseHostHeader } from './action-handler' +import { isOriginMatchingHost, parseHostHeader } from './action-handler' describe('parseHostHeader', () => { it('should return correct host', () => { @@ -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) + }) }) diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 7d0b4ff0626efc..243ff39c59dbeb 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -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 @@ -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, @@ -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) @@ -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). diff --git a/packages/next/src/server/app-render/csrf-protection.test.ts b/packages/next/src/server/app-render/csrf-protection.test.ts index d6eeb48d8af4e5..a9df03620ba996 100644 --- a/packages/next/src/server/app-render/csrf-protection.test.ts +++ b/packages/next/src/server/app-render/csrf-protection.test.ts @@ -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) + }) }) diff --git a/packages/next/src/server/app-render/csrf-protection.ts b/packages/next/src/server/app-render/csrf-protection.ts index 0c10125c712c6b..307048ea7d750b 100644 --- a/packages/next/src/server/app-render/csrf-protection.ts +++ b/packages/next/src/server/app-render/csrf-protection.ts @@ -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) + ) + }) }