From 30b4e783ab8bb9ac65e332f90a43916ff20b4f3f Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sun, 18 Jan 2026 22:45:44 +0900 Subject: [PATCH 01/21] test(e2e): add next app that reproduces case-sensitive CSRF validation for server action - use proxy to set a lowercase origin and a mixed-case x-forwarded-host to reproduce case-sensitive validation - use fixed origin and x-forwarded-host values so the test reproduces consistently across environments - include minimal app-dir fixtures (layout/page/action/form/const) - cover scenario where server action CSRF validation incorrectly depends on header casing --- .../app/action.js | 10 +++++ .../app/client-form.js | 40 +++++++++++++++++++ .../app/const.js | 1 + .../app/layout.js | 8 ++++ .../app/page.js | 10 +++++ .../next.config.js | 7 ++++ .../csrf-validation-case-insensitive/proxy.js | 40 +++++++++++++++++++ 7 files changed, 116 insertions(+) create mode 100644 test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/action.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/client-form.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/const.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/layout.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/page.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/next.config.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/proxy.js diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/action.js b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/action.js new file mode 100644 index 00000000000000..5cee5834f13b31 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/action.js @@ -0,0 +1,10 @@ +'use server' + +export async function testCsrfActionLog(_prevState, formData) { + const message = formData.get('message') + + return { + success: true, + message, + } +} diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/client-form.js b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/client-form.js new file mode 100644 index 00000000000000..a0c0b6274dd952 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/client-form.js @@ -0,0 +1,40 @@ +'use client' + +import { useActionState } from 'react' +import { DEFAULT_MESSAGE } from './const' + +export function ClientForm({ action }) { + const [state, formAction, isPending] = useActionState(action, null) + + return ( + <> +
+

+ If you submit this form, +
+ you can check if the server action csrf validation is case-insensitive +

+
+ + +
+
+
Result:
+
+ {state ? (state.success ? 'Success' : 'Failure') : '(empty)'} +
+
+ Server Action executed successfully with the message:{' '} +
+
{state ? state.message : '(empty)'}
+ + ) +} diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/const.js b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/const.js new file mode 100644 index 00000000000000..24c8540c4f9ddd --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/const.js @@ -0,0 +1 @@ +export const DEFAULT_MESSAGE = 'Testing server action validation' diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/layout.js b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/layout.js new file mode 100644 index 00000000000000..6d7e1ed5858620 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/layout.js @@ -0,0 +1,8 @@ +export default function RootLayout({ children }) { + return ( + + + {children} + + ) +} diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/page.js b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/page.js new file mode 100644 index 00000000000000..0df2a9ad5f0729 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/page.js @@ -0,0 +1,10 @@ +import { testCsrfActionLog } from './action' +import { ClientForm } from './client-form' + +export default function Page() { + return ( +
+ +
+ ) +} diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/next.config.js b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/next.config.js new file mode 100644 index 00000000000000..903009cede3521 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/next.config.js @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + productionBrowserSourceMaps: true, + logging: { + fetches: {}, + }, +} diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/proxy.js b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/proxy.js new file mode 100644 index 00000000000000..ba867788e25daa --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/proxy.js @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server' + +export async function proxy(request) { + if (request.method !== 'POST') { + return NextResponse.next() + } + + const isServerAction = request.headers.get('next-action') + if (!isServerAction) { + return NextResponse.next() + } + + const requestHeaders = new Headers(request.headers) + + // To keep E2E tests consistent, set the origin to a fixed domain + // fixed domain is example.com + requestHeaders.set('origin', 'https://example-domain.com:443') + + // Production proxies (e.g. Nginx/Cloudflare) can send a mis-cased host. + // This proxy intentionally reproduces that by setting x-forwarded-host with caps. + requestHeaders.set('x-forwarded-host', 'Example-Domain.com') + + const origin = request.headers.get('origin') + const host = request.headers.get('host') + const xForwardedHost = request.headers.get('x-forwarded-host') + console.log( + 'origin:', + origin, + 'host:', + host, + 'x-forwarded-host:', + xForwardedHost + ) + + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }) +} From 23b35e16907173ea1aa7a0bac7b28c43dcb04f7e Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sun, 18 Jan 2026 22:56:06 +0900 Subject: [PATCH 02/21] test(e2e): verify server action response successfully - trigger the server action via a button click - capture the server action response and assert the response status is 200 - get the submitted message from the action response and render it in the DOM - verify the DOM message matches the original payload to confirm the action executed successfully --- ...p-csrf-validation-case-insensitive.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 test/e2e/app-dir/actions-allowed-origins/app-csrf-validation-case-insensitive.test.ts diff --git a/test/e2e/app-dir/actions-allowed-origins/app-csrf-validation-case-insensitive.test.ts b/test/e2e/app-dir/actions-allowed-origins/app-csrf-validation-case-insensitive.test.ts new file mode 100644 index 00000000000000..b81297c2d59bb7 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/app-csrf-validation-case-insensitive.test.ts @@ -0,0 +1,40 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' +import { join } from 'path' + +describe('app-dir action csrf validation is case insensitive', () => { + const { next, skipped } = nextTestSetup({ + files: join(__dirname, 'csrf-validation-case-insensitive'), + skipDeployment: true, + dependencies: { + 'server-only': 'latest', + }, + }) + + if (skipped) return + + it('should allow server action when x-forwarded-host matches origin case-insensitively', async () => { + const browser = await next.browser('/') + let actionRequestStatus: number | undefined + + browser.on('response', async (res) => { + const request = res.request() + if (request.method() !== 'POST') return + + const headers = await request.allHeaders() + if (!headers['next-action']) return + + actionRequestStatus = res.status() + }) + + await browser.elementById('submit-button').click() + + await retry(async () => { + expect(actionRequestStatus).toBe(200) + expect(await browser.elementById('result-status').text()).toBe('Success') + expect(await browser.elementById('result-message').text()).toBe( + 'Testing server action validation' + ) + }) + }) +}) From 339be02925ca501d2432844472002bfcbae40d9a Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sat, 24 Jan 2026 13:10:21 +0900 Subject: [PATCH 03/21] test(e2e): rename csrf-validation-case-insensitive to host-match-case-insensitive - Clarifies that this test validates case-insensitive Origin vs Host matching --- .../app/action.js | 0 .../app/client-form.js | 0 .../app/const.js | 0 .../app/layout.js | 0 .../app/page.js | 0 .../next.config.js | 0 .../proxy.js | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename test/e2e/app-dir/actions-allowed-origins/{csrf-validation-case-insensitive => host-match-case-insensitive}/app/action.js (100%) rename test/e2e/app-dir/actions-allowed-origins/{csrf-validation-case-insensitive => host-match-case-insensitive}/app/client-form.js (100%) rename test/e2e/app-dir/actions-allowed-origins/{csrf-validation-case-insensitive => host-match-case-insensitive}/app/const.js (100%) rename test/e2e/app-dir/actions-allowed-origins/{csrf-validation-case-insensitive => host-match-case-insensitive}/app/layout.js (100%) rename test/e2e/app-dir/actions-allowed-origins/{csrf-validation-case-insensitive => host-match-case-insensitive}/app/page.js (100%) rename test/e2e/app-dir/actions-allowed-origins/{csrf-validation-case-insensitive => host-match-case-insensitive}/next.config.js (100%) rename test/e2e/app-dir/actions-allowed-origins/{csrf-validation-case-insensitive => host-match-case-insensitive}/proxy.js (100%) diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/action.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/action.js similarity index 100% rename from test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/action.js rename to test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/action.js diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/client-form.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/client-form.js similarity index 100% rename from test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/client-form.js rename to test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/client-form.js diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/const.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/const.js similarity index 100% rename from test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/const.js rename to test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/const.js diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/layout.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/layout.js similarity index 100% rename from test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/layout.js rename to test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/layout.js diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/page.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/page.js similarity index 100% rename from test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/app/page.js rename to test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/page.js diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/next.config.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/next.config.js similarity index 100% rename from test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/next.config.js rename to test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/next.config.js diff --git a/test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/proxy.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js similarity index 100% rename from test/e2e/app-dir/actions-allowed-origins/csrf-validation-case-insensitive/proxy.js rename to test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js From cff97bead7d7cb360e96dcc2daf554c47d6aaac9 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sat, 24 Jan 2026 13:49:31 +0900 Subject: [PATCH 04/21] test(e2e): add domain constants for case-insensitive origin matching - Introduced ORIGIN_DOMAIN and X_FORWARDED_HOST constants to support case-insensitive checks in the ClientForm component. - Updated ClientForm to display current origin and X-Forwarded-Host values. --- .../host-match-case-insensitive/app/client-form.js | 4 +++- .../host-match-case-insensitive/domain.js | 3 +++ .../host-match-case-insensitive/proxy.js | 5 +++-- 3 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/client-form.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/client-form.js index a0c0b6274dd952..a327af962af689 100644 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/client-form.js +++ b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/client-form.js @@ -2,10 +2,10 @@ import { useActionState } from 'react' import { DEFAULT_MESSAGE } from './const' +import { ORIGIN_DOMAIN, X_FORWARDED_HOST } from '../domain' export function ClientForm({ action }) { const [state, formAction, isPending] = useActionState(action, null) - return ( <>
@@ -14,6 +14,8 @@ export function ClientForm({ action }) {
you can check if the server action csrf validation is case-insensitive

+

Current Origin: {ORIGIN_DOMAIN}

+

Current X-Forwarded-Host: {X_FORWARDED_HOST}

Date: Sat, 24 Jan 2026 14:16:50 +0900 Subject: [PATCH 05/21] test(e2e): rename host-origin case-insensitive test for consistency --- ...test.ts => app-action-host-match-case-insensitive.test.ts} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename test/e2e/app-dir/actions-allowed-origins/{app-csrf-validation-case-insensitive.test.ts => app-action-host-match-case-insensitive.test.ts} (88%) diff --git a/test/e2e/app-dir/actions-allowed-origins/app-csrf-validation-case-insensitive.test.ts b/test/e2e/app-dir/actions-allowed-origins/app-action-host-match-case-insensitive.test.ts similarity index 88% rename from test/e2e/app-dir/actions-allowed-origins/app-csrf-validation-case-insensitive.test.ts rename to test/e2e/app-dir/actions-allowed-origins/app-action-host-match-case-insensitive.test.ts index b81297c2d59bb7..6bc8a891d1c0f4 100644 --- a/test/e2e/app-dir/actions-allowed-origins/app-csrf-validation-case-insensitive.test.ts +++ b/test/e2e/app-dir/actions-allowed-origins/app-action-host-match-case-insensitive.test.ts @@ -2,9 +2,9 @@ import { nextTestSetup } from 'e2e-utils' import { retry } from 'next-test-utils' import { join } from 'path' -describe('app-dir action csrf validation is case insensitive', () => { +describe('app-dir action case-insensitive host-origin comparison', () => { const { next, skipped } = nextTestSetup({ - files: join(__dirname, 'csrf-validation-case-insensitive'), + files: join(__dirname, 'host-match-case-insensitive'), skipDeployment: true, dependencies: { 'server-only': 'latest', From 7165bf5ffcf4b9690facb9ce083cedfbf4ee27b8 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sat, 24 Jan 2026 14:57:41 +0900 Subject: [PATCH 06/21] fix(e2e): update ORIGIN_DOMAIN to include protocol in host matching - Changed ORIGIN_DOMAIN to include 'https://' for accurate origin header setting. - Updated proxy function to use the modified ORIGIN_DOMAIN directly for consistency in origin handling. --- .../host-match-case-insensitive/domain.js | 2 +- .../host-match-case-insensitive/proxy.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js index fc89d7b8276b0c..5871748040da9d 100644 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js +++ b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js @@ -1,3 +1,3 @@ -export const ORIGIN_DOMAIN = 'example-domain.com' +export const ORIGIN_DOMAIN = 'https://example-domain.com' export const X_FORWARDED_HOST = 'Example-Domain.com' diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js index fd20f86895bb5c..59c1c7d269ad69 100644 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js +++ b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js @@ -15,7 +15,7 @@ export async function proxy(request) { // To keep E2E tests consistent, set the origin to a fixed domain // fixed domain is example.com - requestHeaders.set('origin', `https://${ORIGIN_DOMAIN}`) + requestHeaders.set('origin', ORIGIN_DOMAIN) // Production proxies (e.g. Nginx/Cloudflare) can send a mis-cased host. // This proxy intentionally reproduces that by setting x-forwarded-host with caps. From 21239772c15d3ed5067c76cedf61123a988068e8 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sat, 24 Jan 2026 15:02:07 +0900 Subject: [PATCH 07/21] chore(e2e): remove redundant logging of headers in proxy function - Eliminated console logging of 'origin', 'host', and 'x-forwarded-host' to streamline the proxy function and reduce unnecessary output. --- .../host-match-case-insensitive/proxy.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js index 59c1c7d269ad69..684415db855f80 100644 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js +++ b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js @@ -21,18 +21,6 @@ export async function proxy(request) { // This proxy intentionally reproduces that by setting x-forwarded-host with caps. requestHeaders.set('x-forwarded-host', X_FORWARDED_HOST) - const origin = request.headers.get('origin') - const host = request.headers.get('host') - const xForwardedHost = request.headers.get('x-forwarded-host') - console.log( - 'origin:', - origin, - 'host:', - host, - 'x-forwarded-host:', - xForwardedHost - ) - return NextResponse.next({ request: { headers: requestHeaders, From e2b569afc1d5ac6fc5e997bd7716118c96e1251e Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sat, 24 Jan 2026 15:44:34 +0900 Subject: [PATCH 08/21] test(e2e): use IANA reserved domain (example.com) for origin testing - Replace example-domain.com with example.com (RFC 2606) to ensure the test domain never conflicts with a real website. --- .../host-match-case-insensitive/domain.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js index 5871748040da9d..3ceaa10b80078e 100644 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js +++ b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js @@ -1,3 +1,3 @@ -export const ORIGIN_DOMAIN = 'https://example-domain.com' +export const ORIGIN_DOMAIN = 'https://example.com' -export const X_FORWARDED_HOST = 'Example-Domain.com' +export const X_FORWARDED_HOST = 'Example.com' From d08c9b80c4aeba943e77e767dd3f16344634ef53 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sun, 18 Jan 2026 12:14:02 +0900 Subject: [PATCH 09/21] fix(action-handler): correct typo in comment about missing origin header case - Describe missing origin header case (not host) in action handler. --- packages/next/src/server/app-render/action-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 7d0b4ff0626efc..674d1382795ca2 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -631,7 +631,7 @@ 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) { From a86e5fadedebfff6a0b4d600d756fd8b39225784 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sun, 18 Jan 2026 12:25:00 +0900 Subject: [PATCH 10/21] refactor(action-handler): make origin host lowercase explicit --- packages/next/src/server/app-render/action-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 674d1382795ca2..c107bfd184f244 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -617,7 +617,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) From c0142aacadbe2782721bd4f662aff0be11a75091 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sun, 18 Jan 2026 18:32:06 +0900 Subject: [PATCH 11/21] test(action-handler): add tests to verify parseHostHeader lowercases hosts for normalization --- .../src/server/app-render/action-handler.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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..c293e90b265c4d 100644 --- a/packages/next/src/server/app-render/action-handler.test.ts +++ b/packages/next/src/server/app-render/action-handler.test.ts @@ -88,4 +88,19 @@ 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' }) + }) }) From 31ab426ac1b6be864514fc1bab79269e9756803d Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sun, 18 Jan 2026 15:09:37 +0900 Subject: [PATCH 12/21] fix(action-handler): ensure case-insensitive matching for Host and X-Forwarded-Host headers - Convert Host and X-Forwarded-Host header values to lowercase for consistent comparison --- packages/next/src/server/app-render/action-handler.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index c107bfd184f244..b2d494659cc804 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 From c0bdc7ec404a5778cdfaae2bb74e2521bf97fc17 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sun, 18 Jan 2026 14:56:57 +0900 Subject: [PATCH 13/21] refactor(action-handler): extract origin/host match helper for testability - Separate origin/host matching logic into a helper to make it easier to test - Keep explicit `!host` check in the conditional for readability even though the helper also guards --- packages/next/src/server/app-render/action-handler.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index b2d494659cc804..7b0783d421b1ba 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -527,6 +527,14 @@ type HandleActionResult = /** The request turned out not to be a server action. */ | null +export function isOriginMatchingHost(originDomain: string, host: Host) { + if (!host) { + return false + } + + return originDomain === host.value +} + export async function handleAction({ req, res, @@ -634,7 +642,7 @@ export async function handleAction({ // 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). From d216aed7bf4f3b8fe704da4f813ad0312c350f37 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sun, 18 Jan 2026 14:58:03 +0900 Subject: [PATCH 14/21] test(action-handler): add tests for isOriginMatchingHost function - Cover case-insensitive origin/host matching for Host and X-Forwarded-Host headers - Include a negative case where the host does not match the origin --- .../server/app-render/action-handler.test.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) 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 c293e90b265c4d..8a6df977cc622c 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', () => { @@ -104,3 +104,35 @@ describe('parseHostHeader', () => { ).toEqual({ type: 'x-forwarded-host', value: 'example.com' }) }) }) + +describe('isOriginMatchingHost', () => { + it('should match origin case-insensitively', () => { + expect( + isOriginMatchingHost( + 'example.com', + parseHostHeader({ + host: 'Example.com', + }) + ) + ).toBe(true) + + expect( + isOriginMatchingHost( + 'example.com', + parseHostHeader({ + host: 'www.foo.com', + 'x-forwarded-host': 'Example.com', + }) + ) + ).toBe(true) + + expect( + isOriginMatchingHost( + 'example.com', + parseHostHeader({ + host: 'other.com', + }) + ) + ).toBe(false) + }) +}) From 131ecaf73e1ce569949ead697eff66861a710bb4 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Mon, 19 Jan 2026 00:05:49 +0900 Subject: [PATCH 15/21] test(action-handler): add defensive origin/host case-insensitive checks - add case-insensitive matching for origin vs host - add case-insensitive matching for origin vs x-forwarded-host - organize existing mismatch case under a named test case --- .../server/app-render/action-handler.test.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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 8a6df977cc622c..e9f07cdc6b302a 100644 --- a/packages/next/src/server/app-render/action-handler.test.ts +++ b/packages/next/src/server/app-render/action-handler.test.ts @@ -106,7 +106,7 @@ describe('parseHostHeader', () => { }) describe('isOriginMatchingHost', () => { - it('should match origin case-insensitively', () => { + it('matches origin to host case-insensitively', () => { expect( isOriginMatchingHost( 'example.com', @@ -116,6 +116,17 @@ describe('isOriginMatchingHost', () => { ) ).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', @@ -126,6 +137,18 @@ describe('isOriginMatchingHost', () => { ) ).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', From b3e5fab8538b18411b98736e40d249b3f747119c Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Mon, 19 Jan 2026 00:19:04 +0900 Subject: [PATCH 16/21] fix(action-handler): refactor and make isOriginMatchingHost defensive - allow host to be undefined to avoid type errors since it is already guarded upstream - keep defensive case-insensitive comparison in origin/host matching --- .../next/src/server/app-render/action-handler.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 7b0783d421b1ba..243ff39c59dbeb 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -527,12 +527,16 @@ type HandleActionResult = /** The request turned out not to be a server action. */ | null -export function isOriginMatchingHost(originDomain: string, host: Host) { - if (!host) { - return false - } - - return originDomain === host.value +/** + * 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({ From a2d6510bce59f82e3f122be8cd049452d4b5ad0c Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sat, 24 Jan 2026 15:56:09 +0900 Subject: [PATCH 17/21] test(e2e): verify allowedOrigins config performs case-insensitive comparison - Add e2e test to ensure that the `serverActions.allowedOrigins` config option matches origins case-insensitively. - Set the origin header to 'https://example.com' while configuring allowedOrigins as ['Example.COM'] to verify that different cases are treated as matching. --- ...tion-config-match-case-insensitive.test.ts | 40 +++++++++++++++++ .../app/action.js | 10 +++++ .../app/client-form.js | 44 +++++++++++++++++++ .../app/const.js | 2 + .../app/layout.js | 8 ++++ .../config-match-case-insensitive/app/page.js | 10 +++++ .../config-match-case-insensitive/domain.js | 6 +++ .../next.config.js | 14 ++++++ .../config-match-case-insensitive/proxy.js | 26 +++++++++++ 9 files changed, 160 insertions(+) create mode 100644 test/e2e/app-dir/actions-allowed-origins/app-action-config-match-case-insensitive.test.ts create mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/action.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/client-form.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/const.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/layout.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/page.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/domain.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/next.config.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/proxy.js diff --git a/test/e2e/app-dir/actions-allowed-origins/app-action-config-match-case-insensitive.test.ts b/test/e2e/app-dir/actions-allowed-origins/app-action-config-match-case-insensitive.test.ts new file mode 100644 index 00000000000000..3caeab73cd151f --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/app-action-config-match-case-insensitive.test.ts @@ -0,0 +1,40 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' +import { join } from 'path' + +describe('app-dir action case-insensitive origin and allowed origins (config) comparison', () => { + const { next, skipped } = nextTestSetup({ + files: join(__dirname, 'config-match-case-insensitive'), + skipDeployment: true, + dependencies: { + 'server-only': 'latest', + }, + }) + + if (skipped) return + + it('should allow server action when origin matches allowed origins (config) case-insensitively', async () => { + const browser = await next.browser('/') + let actionRequestStatus: number | undefined + + browser.on('response', async (res) => { + const request = res.request() + if (request.method() !== 'POST') return + + const headers = await request.allHeaders() + if (!headers['next-action']) return + + actionRequestStatus = res.status() + }) + + await browser.elementById('submit-button').click() + + await retry(async () => { + expect(actionRequestStatus).toBe(200) + expect(await browser.elementById('result-status').text()).toBe('Success') + expect(await browser.elementById('result-message').text()).toBe( + 'Testing allowedOrigins case-insensitive validation' + ) + }) + }) +}) diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/action.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/action.js new file mode 100644 index 00000000000000..f2f711c9cc94d6 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/action.js @@ -0,0 +1,10 @@ +'use server' + +export async function testAllowedOriginAction(_prevState, formData) { + const message = formData.get('message') + + return { + success: true, + message, + } +} diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/client-form.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/client-form.js new file mode 100644 index 00000000000000..573d7115e2f787 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/client-form.js @@ -0,0 +1,44 @@ +'use client' + +import { useActionState } from 'react' +import { DEFAULT_MESSAGE } from './const' +import { ORIGIN_DOMAIN, CONFIG_ALLOWED_ORIGINS } from '../domain' + +export function ClientForm({ action }) { + const [state, formAction, isPending] = useActionState(action, null) + + return ( + <> + +

+ If you submit this form, +
+ you can check if the server action allowedOrigins validation is + case-insensitive +

+

Current Origin: {ORIGIN_DOMAIN}

+

Current Allowed Origins: {CONFIG_ALLOWED_ORIGINS.join(', ')}

+
+ + +
+ +
Result:
+
+ {state ? (state.success ? 'Success' : 'Failure') : '(empty)'} +
+
+ Server Action executed successfully with the message:{' '} +
+
{state ? state.message : '(empty)'}
+ + ) +} diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/const.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/const.js new file mode 100644 index 00000000000000..e8a52ca2a2ebe3 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/const.js @@ -0,0 +1,2 @@ +export const DEFAULT_MESSAGE = + 'Testing allowedOrigins case-insensitive validation' diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/layout.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/layout.js new file mode 100644 index 00000000000000..6d7e1ed5858620 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/layout.js @@ -0,0 +1,8 @@ +export default function RootLayout({ children }) { + return ( + + + {children} + + ) +} diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/page.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/page.js new file mode 100644 index 00000000000000..ba381a235921ea --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/page.js @@ -0,0 +1,10 @@ +import { testAllowedOriginAction } from './action' +import { ClientForm } from './client-form' + +export default function Page() { + return ( +
+ +
+ ) +} diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/domain.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/domain.js new file mode 100644 index 00000000000000..a6158b8bcace47 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/domain.js @@ -0,0 +1,6 @@ +// Using example.com - a domain reserved by IANA (RFC 2606) for documentation and testing. +// This domain will never resolve to a real website. +export const ORIGIN_DOMAIN = 'https://example.com' + +// Different case to test case-insensitive matching +export const CONFIG_ALLOWED_ORIGINS = ['Example.COM'] diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/next.config.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/next.config.js new file mode 100644 index 00000000000000..cc752a2ca8b96f --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/next.config.js @@ -0,0 +1,14 @@ +const { CONFIG_ALLOWED_ORIGINS } = require('./domain') + +/** @type {import('next').NextConfig} */ +module.exports = { + productionBrowserSourceMaps: true, + logging: { + fetches: {}, + }, + experimental: { + serverActions: { + allowedOrigins: CONFIG_ALLOWED_ORIGINS, + }, + }, +} diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/proxy.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/proxy.js new file mode 100644 index 00000000000000..88eac5de5debef --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/proxy.js @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' +import { ORIGIN_DOMAIN } from './domain' + +export async function proxy(request) { + if (request.method !== 'POST') { + return NextResponse.next() + } + + const isServerAction = request.headers.get('next-action') + if (!isServerAction) { + return NextResponse.next() + } + + const requestHeaders = new Headers(request.headers) + + // To keep E2E tests consistent, set the origin to a fixed domain + // Set origin header to lowercase 'example.com' to test case-insensitive matching + // against CONFIG_ALLOWED_ORIGINS which uses different casing ('Example.COM'). + requestHeaders.set('origin', ORIGIN_DOMAIN) + + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }) +} From 94e16ff4d6399055dbe6db44bf44b8d773b96576 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sat, 24 Jan 2026 16:00:06 +0900 Subject: [PATCH 18/21] test(csrf-protection): add case-insensitive checks for allowedOrigins - Ensure that variations in case for both the origin and allowedOrigins are correctly recognized as matches. --- .../next/src/server/app-render/csrf-protection.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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) + }) }) From cb275dd70d8e40eb1c0342c26acff6189f656ef7 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sat, 24 Jan 2026 16:02:31 +0900 Subject: [PATCH 19/21] fix(csrf-protection): implement case-insensitive origin matching - Normalize both the origin and allowedOrigins to lowercase for consistent comparison. --- .../src/server/app-render/csrf-protection.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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) + ) + }) } From 019b486c5a199e4c0ae79c85739aca27d2c092ce Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sun, 25 Jan 2026 12:04:20 +0900 Subject: [PATCH 20/21] chore(e2e): remove case-insensitive host-origin comparison tests and related files --- ...action-host-match-case-insensitive.test.ts | 40 ------------------ .../host-match-case-insensitive/app/action.js | 10 ----- .../app/client-form.js | 42 ------------------- .../host-match-case-insensitive/app/const.js | 1 - .../host-match-case-insensitive/app/layout.js | 8 ---- .../host-match-case-insensitive/app/page.js | 10 ----- .../host-match-case-insensitive/domain.js | 3 -- .../next.config.js | 7 ---- .../host-match-case-insensitive/proxy.js | 29 ------------- 9 files changed, 150 deletions(-) delete mode 100644 test/e2e/app-dir/actions-allowed-origins/app-action-host-match-case-insensitive.test.ts delete mode 100644 test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/action.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/client-form.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/const.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/layout.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/page.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/next.config.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js diff --git a/test/e2e/app-dir/actions-allowed-origins/app-action-host-match-case-insensitive.test.ts b/test/e2e/app-dir/actions-allowed-origins/app-action-host-match-case-insensitive.test.ts deleted file mode 100644 index 6bc8a891d1c0f4..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/app-action-host-match-case-insensitive.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { nextTestSetup } from 'e2e-utils' -import { retry } from 'next-test-utils' -import { join } from 'path' - -describe('app-dir action case-insensitive host-origin comparison', () => { - const { next, skipped } = nextTestSetup({ - files: join(__dirname, 'host-match-case-insensitive'), - skipDeployment: true, - dependencies: { - 'server-only': 'latest', - }, - }) - - if (skipped) return - - it('should allow server action when x-forwarded-host matches origin case-insensitively', async () => { - const browser = await next.browser('/') - let actionRequestStatus: number | undefined - - browser.on('response', async (res) => { - const request = res.request() - if (request.method() !== 'POST') return - - const headers = await request.allHeaders() - if (!headers['next-action']) return - - actionRequestStatus = res.status() - }) - - await browser.elementById('submit-button').click() - - await retry(async () => { - expect(actionRequestStatus).toBe(200) - expect(await browser.elementById('result-status').text()).toBe('Success') - expect(await browser.elementById('result-message').text()).toBe( - 'Testing server action validation' - ) - }) - }) -}) diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/action.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/action.js deleted file mode 100644 index 5cee5834f13b31..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/action.js +++ /dev/null @@ -1,10 +0,0 @@ -'use server' - -export async function testCsrfActionLog(_prevState, formData) { - const message = formData.get('message') - - return { - success: true, - message, - } -} diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/client-form.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/client-form.js deleted file mode 100644 index a327af962af689..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/client-form.js +++ /dev/null @@ -1,42 +0,0 @@ -'use client' - -import { useActionState } from 'react' -import { DEFAULT_MESSAGE } from './const' -import { ORIGIN_DOMAIN, X_FORWARDED_HOST } from '../domain' - -export function ClientForm({ action }) { - const [state, formAction, isPending] = useActionState(action, null) - return ( - <> -
-

- If you submit this form, -
- you can check if the server action csrf validation is case-insensitive -

-

Current Origin: {ORIGIN_DOMAIN}

-

Current X-Forwarded-Host: {X_FORWARDED_HOST}

-
- - -
-
-
Result:
-
- {state ? (state.success ? 'Success' : 'Failure') : '(empty)'} -
-
- Server Action executed successfully with the message:{' '} -
-
{state ? state.message : '(empty)'}
- - ) -} diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/const.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/const.js deleted file mode 100644 index 24c8540c4f9ddd..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/const.js +++ /dev/null @@ -1 +0,0 @@ -export const DEFAULT_MESSAGE = 'Testing server action validation' diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/layout.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/layout.js deleted file mode 100644 index 6d7e1ed5858620..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/layout.js +++ /dev/null @@ -1,8 +0,0 @@ -export default function RootLayout({ children }) { - return ( - - - {children} - - ) -} diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/page.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/page.js deleted file mode 100644 index 0df2a9ad5f0729..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/app/page.js +++ /dev/null @@ -1,10 +0,0 @@ -import { testCsrfActionLog } from './action' -import { ClientForm } from './client-form' - -export default function Page() { - return ( -
- -
- ) -} diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js deleted file mode 100644 index 3ceaa10b80078e..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/domain.js +++ /dev/null @@ -1,3 +0,0 @@ -export const ORIGIN_DOMAIN = 'https://example.com' - -export const X_FORWARDED_HOST = 'Example.com' diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/next.config.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/next.config.js deleted file mode 100644 index 903009cede3521..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/next.config.js +++ /dev/null @@ -1,7 +0,0 @@ -/** @type {import('next').NextConfig} */ -module.exports = { - productionBrowserSourceMaps: true, - logging: { - fetches: {}, - }, -} diff --git a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js b/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js deleted file mode 100644 index 684415db855f80..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/host-match-case-insensitive/proxy.js +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse } from 'next/server' -import { ORIGIN_DOMAIN, X_FORWARDED_HOST } from './domain' - -export async function proxy(request) { - if (request.method !== 'POST') { - return NextResponse.next() - } - - const isServerAction = request.headers.get('next-action') - if (!isServerAction) { - return NextResponse.next() - } - - const requestHeaders = new Headers(request.headers) - - // To keep E2E tests consistent, set the origin to a fixed domain - // fixed domain is example.com - requestHeaders.set('origin', ORIGIN_DOMAIN) - - // Production proxies (e.g. Nginx/Cloudflare) can send a mis-cased host. - // This proxy intentionally reproduces that by setting x-forwarded-host with caps. - requestHeaders.set('x-forwarded-host', X_FORWARDED_HOST) - - return NextResponse.next({ - request: { - headers: requestHeaders, - }, - }) -} From 9d440a58b8d65d1c91ff7d0d67c0befa32ac8552 Mon Sep 17 00:00:00 2001 From: GyuriKim Date: Sun, 25 Jan 2026 12:10:34 +0900 Subject: [PATCH 21/21] chore(e2e): remove case-insensitive origin matching tests and related server action config --- ...tion-config-match-case-insensitive.test.ts | 40 ----------------- .../app/action.js | 10 ----- .../app/client-form.js | 44 ------------------- .../app/const.js | 2 - .../app/layout.js | 8 ---- .../config-match-case-insensitive/app/page.js | 10 ----- .../config-match-case-insensitive/domain.js | 6 --- .../next.config.js | 14 ------ .../config-match-case-insensitive/proxy.js | 26 ----------- 9 files changed, 160 deletions(-) delete mode 100644 test/e2e/app-dir/actions-allowed-origins/app-action-config-match-case-insensitive.test.ts delete mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/action.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/client-form.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/const.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/layout.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/page.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/domain.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/next.config.js delete mode 100644 test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/proxy.js diff --git a/test/e2e/app-dir/actions-allowed-origins/app-action-config-match-case-insensitive.test.ts b/test/e2e/app-dir/actions-allowed-origins/app-action-config-match-case-insensitive.test.ts deleted file mode 100644 index 3caeab73cd151f..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/app-action-config-match-case-insensitive.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { nextTestSetup } from 'e2e-utils' -import { retry } from 'next-test-utils' -import { join } from 'path' - -describe('app-dir action case-insensitive origin and allowed origins (config) comparison', () => { - const { next, skipped } = nextTestSetup({ - files: join(__dirname, 'config-match-case-insensitive'), - skipDeployment: true, - dependencies: { - 'server-only': 'latest', - }, - }) - - if (skipped) return - - it('should allow server action when origin matches allowed origins (config) case-insensitively', async () => { - const browser = await next.browser('/') - let actionRequestStatus: number | undefined - - browser.on('response', async (res) => { - const request = res.request() - if (request.method() !== 'POST') return - - const headers = await request.allHeaders() - if (!headers['next-action']) return - - actionRequestStatus = res.status() - }) - - await browser.elementById('submit-button').click() - - await retry(async () => { - expect(actionRequestStatus).toBe(200) - expect(await browser.elementById('result-status').text()).toBe('Success') - expect(await browser.elementById('result-message').text()).toBe( - 'Testing allowedOrigins case-insensitive validation' - ) - }) - }) -}) diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/action.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/action.js deleted file mode 100644 index f2f711c9cc94d6..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/action.js +++ /dev/null @@ -1,10 +0,0 @@ -'use server' - -export async function testAllowedOriginAction(_prevState, formData) { - const message = formData.get('message') - - return { - success: true, - message, - } -} diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/client-form.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/client-form.js deleted file mode 100644 index 573d7115e2f787..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/client-form.js +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import { useActionState } from 'react' -import { DEFAULT_MESSAGE } from './const' -import { ORIGIN_DOMAIN, CONFIG_ALLOWED_ORIGINS } from '../domain' - -export function ClientForm({ action }) { - const [state, formAction, isPending] = useActionState(action, null) - - return ( - <> -
-

- If you submit this form, -
- you can check if the server action allowedOrigins validation is - case-insensitive -

-

Current Origin: {ORIGIN_DOMAIN}

-

Current Allowed Origins: {CONFIG_ALLOWED_ORIGINS.join(', ')}

-
- - -
-
-
Result:
-
- {state ? (state.success ? 'Success' : 'Failure') : '(empty)'} -
-
- Server Action executed successfully with the message:{' '} -
-
{state ? state.message : '(empty)'}
- - ) -} diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/const.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/const.js deleted file mode 100644 index e8a52ca2a2ebe3..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/const.js +++ /dev/null @@ -1,2 +0,0 @@ -export const DEFAULT_MESSAGE = - 'Testing allowedOrigins case-insensitive validation' diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/layout.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/layout.js deleted file mode 100644 index 6d7e1ed5858620..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/layout.js +++ /dev/null @@ -1,8 +0,0 @@ -export default function RootLayout({ children }) { - return ( - - - {children} - - ) -} diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/page.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/page.js deleted file mode 100644 index ba381a235921ea..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/app/page.js +++ /dev/null @@ -1,10 +0,0 @@ -import { testAllowedOriginAction } from './action' -import { ClientForm } from './client-form' - -export default function Page() { - return ( -
- -
- ) -} diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/domain.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/domain.js deleted file mode 100644 index a6158b8bcace47..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/domain.js +++ /dev/null @@ -1,6 +0,0 @@ -// Using example.com - a domain reserved by IANA (RFC 2606) for documentation and testing. -// This domain will never resolve to a real website. -export const ORIGIN_DOMAIN = 'https://example.com' - -// Different case to test case-insensitive matching -export const CONFIG_ALLOWED_ORIGINS = ['Example.COM'] diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/next.config.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/next.config.js deleted file mode 100644 index cc752a2ca8b96f..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/next.config.js +++ /dev/null @@ -1,14 +0,0 @@ -const { CONFIG_ALLOWED_ORIGINS } = require('./domain') - -/** @type {import('next').NextConfig} */ -module.exports = { - productionBrowserSourceMaps: true, - logging: { - fetches: {}, - }, - experimental: { - serverActions: { - allowedOrigins: CONFIG_ALLOWED_ORIGINS, - }, - }, -} diff --git a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/proxy.js b/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/proxy.js deleted file mode 100644 index 88eac5de5debef..00000000000000 --- a/test/e2e/app-dir/actions-allowed-origins/config-match-case-insensitive/proxy.js +++ /dev/null @@ -1,26 +0,0 @@ -import { NextResponse } from 'next/server' -import { ORIGIN_DOMAIN } from './domain' - -export async function proxy(request) { - if (request.method !== 'POST') { - return NextResponse.next() - } - - const isServerAction = request.headers.get('next-action') - if (!isServerAction) { - return NextResponse.next() - } - - const requestHeaders = new Headers(request.headers) - - // To keep E2E tests consistent, set the origin to a fixed domain - // Set origin header to lowercase 'example.com' to test case-insensitive matching - // against CONFIG_ALLOWED_ORIGINS which uses different casing ('Example.COM'). - requestHeaders.set('origin', ORIGIN_DOMAIN) - - return NextResponse.next({ - request: { - headers: requestHeaders, - }, - }) -}