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 (
+ <>
+
+ 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 (
<>