Repository: vercel/next.js
Pull request: Allow blocking cross-site dev-only websocket connections from privacy sensitive origins

See: https://github.com/vercel/next.js/security/advisories/GHSA-jcc7-9wpm-mj36 and [16.1.7](https://github.com/vercel/next.js/releases/tag/v16.1.7)

Changed files:
filename: packages/next/src/server/lib/router-server.ts
patch: @@ -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,

filename: packages/next/src/server/lib/router-utils/block-cross-site-dev.ts
patch: @@ -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)
+  )
 }

filename: test/development/basic/allowed-dev-origins.test.ts
patch: @@ -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()
+            })
+          })
+        }
+      })
     })
   }
 )