Next.js: null origin can bypass Server Actions CSRF checks
Description
Next.js is a React framework for building full-stack web applications. Starting in version 16.0.1 and prior to version 16.1.7, origin: null was treated as a "missing" origin during Server Action CSRF validation. As a result, requests from opaque contexts (such as sandboxed iframes) could bypass origin verification instead of being validated as cross-origin requests. An attacker could induce a victim browser to submit Server Actions from a sandboxed context, potentially executing state-changing actions with victim credentials (CSRF). This is fixed in version 16.1.7 by treating 'null' as an explicit origin value and enforcing host/origin checks unless 'null' is explicitly allowlisted in experimental.serverActions.allowedOrigins. If upgrading is not immediately possible, add CSRF tokens for sensitive Server Actions, prefer SameSite=Strict on sensitive auth cookies, and/or do not allow 'null' in serverActions.allowedOrigins unless intentionally required and additionally protected.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
nextnpm | >= 16.0.1, < 16.1.7 | 16.1.7 |
Affected products
1Patches
1a27a11d78e74Disallow Server Action submissions from privacy-sensitive contexts (#91478)
7 files changed · +159 −22
packages/next/src/server/app-render/action-handler.ts+16 −9 modified@@ -616,9 +616,14 @@ export async function handleAction({ workStore.fetchCache = 'default-no-store' const originHeader = req.headers['origin'] - const originDomain = - typeof originHeader === 'string' && originHeader !== 'null' - ? new URL(originHeader).host + const originHost = + typeof originHeader === 'string' + ? // 'null' is a valid origin e.g. from privacy-sensitive contexts like sandboxed iframes. + // However, these contexts can still send along credentials like cookies, + // so we need to check if they're allowed cross-origin requests. + originHeader === 'null' + ? 'null' + : new URL(originHeader).host : undefined const host = parseHostHeader(req.headers) @@ -631,15 +636,17 @@ 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 case. + if (!originHost) { + // This is a handcrafted request without an origin or a request from an unsafe browser. + // We'll let this through but log a warning. + // We can't guard against unsafe browsers and handcrafted requests can't contain + // user credentials that haven't been shared willingly. warning = 'Missing `origin` header from a forwarded Server Actions request.' - } else if (!host || originDomain !== host.value) { + } else if (!host || originHost !== host.value) { // 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). - if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) { + if (isCsrfOriginAllowed(originHost, serverActions?.allowedOrigins)) { // Ignore it } else { if (host) { @@ -650,7 +657,7 @@ export async function handleAction({ }\` header with value \`${limitUntrustedHeaderValueForLogs( host.value )}\` does not match \`origin\` header with value \`${limitUntrustedHeaderValueForLogs( - originDomain + originHost )}\` from a forwarded Server Actions request. Aborting the action.` ) } else {
test/e2e/app-dir/actions-allowed-origins/app-action-allowed-origins.test.ts+0 −13 modified@@ -26,17 +26,4 @@ describe('app-dir action allowed origins', () => { return await browser.elementByCss('#res').text() }, 'hi') }) - - it('should not crash for requests from privacy sensitive contexts', async function () { - const res = await next.fetch('/', { - method: 'POST', - headers: { - Origin: 'null', - 'Content-type': 'application/x-www-form-urlencoded', - 'Sec-Fetch-Site': 'same-origin', - }, - }) - - expect({ status: res.status }).toEqual({ status: 200 }) - }) })
test/e2e/app-dir/actions-allowed-origins/app-action-opaque-origin.test.ts+71 −0 added@@ -0,0 +1,71 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' +import { join } from 'path' + +describe('app-dir action allowed from opaque origins', () => { + const { next, skipped } = nextTestSetup({ + files: join(__dirname, 'opaque-origin'), + skipDeployment: true, + env: { + NEXT_TEST_ALLOW_OPAQUE_ORIGIN: '1', + }, + }) + + if (skipped) { + return + } + + it('should succeed on submission', async function () { + const browser = await next.browser('/sandboxed') + + await browser.elementByCss('input[type="submit"]').click() + + await retry(async () => { + expect(await browser.elementByCss('output').text()).toEqual( + 'Action Invoked' + ) + }) + }) +}) + +describe('app-dir action disallowed from opaque origins', () => { + const { isNextDev, next, skipped } = nextTestSetup({ + files: join(__dirname, 'opaque-origin'), + skipDeployment: true, + env: { + NEXT_TEST_ALLOW_OPAQUE_ORIGIN: '', + }, + }) + + if (skipped) { + return + } + + it('should fail on submission', async function () { + const browser = await next.browser('/sandboxed') + const beforeSubmissionLogOffset = (await browser.log()).length + + await browser.elementByCss('input[type="submit"]').click() + + await retry(async () => { + const logs = await browser.log() + const newLogs = logs.slice(beforeSubmissionLogOffset) + expect(newLogs).toEqual( + expect.arrayContaining([ + { + source: 'error', + message: + 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)', + }, + ]) + ) + }) + if (isNextDev) { + // page is borked at this point. Nothing interesting to assert on. + } else { + expect(await browser.elementByCss('body').text()).toEqual( + 'Internal Server Error' + ) + } + }) +})
test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/action.js+10 −0 added@@ -0,0 +1,10 @@ +'use server' + +import { cookies } from 'next/headers' + +export async function log() { + console.log('action invoked') + const cookieStore = await cookies() + cookieStore.set('log-action-invoked', '1') + return 'hi' +}
test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/layout.js+21 −0 added@@ -0,0 +1,21 @@ +import { Suspense } from 'react' + +export default function RootLayout({ children }) { + return ( + // Needs to be above html since we can't allow scripts in sandbox + <Suspense fallback={<div>Loading...</div>}> + <html> + <head /> + <body> + <ul> + {/* These need to be MPAs so that the appropriate headers are applied */} + <li> + <a href="/sandboxed">Sandboxed Page</a> + </li> + </ul> + {children} + </body> + </html> + </Suspense> + ) +}
test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/sandboxed/page.js+14 −0 added@@ -0,0 +1,14 @@ +import { cookies } from 'next/headers' +import { log } from '../action' + +export default async function Page() { + const cookieStore = await cookies() + const cookie = cookieStore.get('log-action-invoked') + const hasLogged = cookie?.value === '1' + return ( + <form action={log}> + <input type="submit" /> + <output>{hasLogged ? 'Action Invoked' : 'Action Not Invoked'}</output> + </form> + ) +}
test/e2e/app-dir/actions-allowed-origins/opaque-origin/next.config.js+27 −0 added@@ -0,0 +1,27 @@ +const allowOpaqueOrigin = process.env.NEXT_TEST_ALLOW_OPAQUE_ORIGIN === '1' + +/** @type {import('next').NextConfig} */ +module.exports = { + productionBrowserSourceMaps: true, + logging: { + fetches: {}, + }, + headers() { + return [ + { + source: '/sandboxed', + headers: [ + { + key: 'Content-Security-Policy', + value: 'sandbox allow-forms', + }, + ], + }, + ] + }, + experimental: { + serverActions: { + allowedOrigins: allowOpaqueOrigin ? ['null'] : [], + }, + }, +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-mq59-m269-xvcxghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27978ghsaADVISORY
- github.com/vercel/next.js/commit/a27a11d78e748a8c7ccfd14b7759ad2b9bf097d8ghsax_refsource_MISCWEB
- github.com/vercel/next.js/releases/tag/v16.1.7ghsax_refsource_MISCWEB
- github.com/vercel/next.js/security/advisories/GHSA-mq59-m269-xvcxghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.