Security context
LowGHSA-jcc7-9wpm-mj36 CVE-2026-27977CWE-1385Published Mar 17, 2026

Next.js: null origin can bypass dev HMR websocket CSRF checks

Research this vulnerability

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

16.0.1 → fixed in 16.1.7

Details

## Summary In `next dev`, cross-site protections for internal development endpoints could treat `Origin: null` as a bypass case even when [`allowedDevOrigins`](https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins) is configured. This could allow privacy-sensitive or opaque browser contexts, such as sandboxed documents, to access privileged internal dev-server functionality unexpectedly. ## Impact If a developer visits attacker-controlled content while running an affected `next dev` server with [`allowedDevOrigins`](https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins) configured, attacker-controlled browser code may be able to connect to internal development endpoints and interact with sensitive dev-server functionality that should have remained blocked. This issue affects development mode only. It does not affect `next start`, and it does not expose internal debugging functionality to the network by default. ## Patches Fixed by validating `Origin: null` through the same cross-site origin-allowance checks used for other origins on internal development endpoints. ## Workarounds If upgrade is not immediately possible: - Do not expose `next dev` to untrusted networks. - If you use [`allowedDevOrigins`](https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins), reject requests and websocket upgrades with `Origin: null` for internal dev endpoints at your proxy.

The fix

Allow blocking cross-site dev-only websocket connections from

Zack Tanner· Mar 17, 2026, 12:42 AM+7221862f9b9bb4
packages/next/src/server/lib/router-server.ts+3 3
@@ -48,7 +48,7 @@ import { normalizedAssetPrefix } from '../../shared/lib/normalized-asset-prefix'
import { NEXT_PATCH_SYMBOL } from './patch-fetch'
import type { ServerInitResult } from './render-server'
import { filterInternalHeaders } from './server-ipc/utils'
-import { blockCrossSite } from './router-utils/block-cross-site'
+import { blockCrossSiteDEV } from './router-utils/block-cross-site-dev'
import { traceGlobals } from '../../trace/shared'
import { NoFallbackError } from '../../shared/lib/no-fallback-error.external'
import {
@@ -355,7 +355,7 @@ export async function initialize(opts: {
// handle hot-reloader first
if (development) {
if (
- blockCrossSite(
+ blockCrossSiteDEV(
req,
res,
development.config.allowedDevOrigins,
@@ -815,7 +815,7 @@ export async function initialize(opts: {
if (opts.dev && development && req.url) {
if (
- blockCrossSite(
+ blockCrossSiteDEV(
req,
socket,
development.config.allowedDevOrigins,
packages/next/src/server/lib/router-utils/block-cross-site-dev.ts+19 18
@@ -31,7 +31,7 @@ function warnOrBlockRequest(
return true
}
-function isInternalDevEndpoint(req: IncomingMessage): boolean {
+function isInternalEndpoint(req: IncomingMessage): boolean {
if (!req.url) return false
try {
@@ -50,7 +50,7 @@ function isInternalDevEndpoint(req: IncomingMessage): boolean {
}
}
-export const blockCrossSite = (
+export const blockCrossSiteDEV = (
req: IncomingMessage,
res: ServerResponse | Duplex,
allowedDevOrigins: string[] | undefined,
@@ -70,9 +70,10 @@ export const blockCrossSite = (
}
// only process internal URLs/middleware
- if (!isInternalDevEndpoint(req)) {
+ if (!isInternalEndpoint(req)) {
return false
}
+
// block non-cors request from cross-site e.g. script tag on
// different host
if (
@@ -82,20 +83,20 @@ export const blockCrossSite = (
return warnOrBlockRequest(res, undefined, mode)
}
- // ensure websocket requests from allowed origin
+ // ensure websocket requests are only fulfilled from allowed origin
const rawOrigin = req.headers['origin']
-
- if (rawOrigin && rawOrigin !== 'null') {
- const parsedOrigin = parseUrl(rawOrigin)
-
- if (parsedOrigin) {
- const originLowerCase = parsedOrigin.hostname.toLowerCase()
-
- if (!isCsrfOriginAllowed(originLowerCase, allowedOrigins)) {
- return warnOrBlockRequest(res, originLowerCase, mode)
- }
- }
- }
-
- return false
+ const parsedOrigin =
+ rawOrigin && rawOrigin !== 'null' ? parseUrl(rawOrigin) : rawOrigin
+
+ const originLowerCase =
+ parsedOrigin === undefined || typeof parsedOrigin === 'string'
+ ? parsedOrigin
+ : parsedOrigin.hostname.toLowerCase()
+
+ // Allow requests with no origin since those are just GET requests from same-site
+ return (
+ originLowerCase !== undefined &&
+ !isCsrfOriginAllowed(originLowerCase, allowedOrigins) &&
+ warnOrBlockRequest(res, originLowerCase, mode)
+ )
}
test/development/basic/allowed-dev-origins.test.ts+50 0
@@ -373,6 +373,56 @@ describe.each([['', '/docs']])(
server.close()
}
})
+
+ it('blocks cross-site requests from privacy-sensitive origins', async () => {
+ const server = http.createServer((req, res) => {
+ res.appendHeader('Content-Security-Policy', 'sandbox allow-scripts')
+ res.end(`
+ <html>
+ <head>
+ <title>testing cross-site privacy-sensitive</title>
+ </head>
+ <body>
+ <script>
+ (() => {
+ const statusEl = document.createElement('p')
+ statusEl.id = 'status'
+ document.querySelector('body').appendChild(statusEl)
+
+ const ws = new WebSocket("${next.url}/_next/webpack-hmr")
+
+ ws.addEventListener('error', (err) => {
+ statusEl.innerText = 'error'
+ })
+ ws.addEventListener('open', () => {
+ statusEl.innerText = 'connected'
+ })
+ })()
+ </script>
+ </body>
+ </html>
+ `)
+ })
+
+ const port = await findPort()
+ await new Promise<void>((res) => {
+ server.listen(port, () => res())
+ })
+
+ try {
+ const browser = await webdriver(`http://127.0.0.1:${port}`, '/')
+
+ await retry(async () => {
+ expect(await browser.elementByCss('#status').text()).toBe('error')
+ })
+ } finally {
+ await new Promise<void>((res) => {
+ server.close(() => {
+ res()
+ })
+ })
+ }
+ })
})
}
)

References