Next.js: null origin can bypass Server Actions CSRF checks
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
Details
## Summary `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. ## Impact An attacker could induce a victim browser to submit Server Actions from a sandboxed context, potentially executing state-changing actions with victim credentials (CSRF). ## Patches Fixed by treating `'null'` as an explicit origin value and enforcing host/origin checks unless `'null'` is explicitly allowlisted in `experimental.serverActions.allowedOrigins`. ## Workarounds If upgrade is not immediately possible: - Add CSRF tokens for sensitive Server Actions. - Prefer `SameSite=Strict` on sensitive auth cookies. - Do not allow `'null'` in `serverActions.allowedOrigins` unless intentionally required and additionally protected.
The fix
Disallow Server Action submissions from privacy-sensitive
packages/next/src/server/app-render/action-handler.ts+16 −9
@@ -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: undefinedconst 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'] : [],+},+},+}
References
- WEBhttps://github.com/vercel/next.js/security/advisories/GHSA-mq59-m269-xvcx
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2026-27978
- WEBhttps://github.com/vercel/next.js/commit/a27a11d78e748a8c7ccfd14b7759ad2b9bf097d8
- PACKAGEhttps://github.com/vercel/next.js
- WEBhttps://github.com/vercel/next.js/releases/tag/v16.1.7