Repository: vercel/next.js
Pull request: Add handling of origin in dev mode

This ensures we don't allow cross-origin requests to be made to `/_next` resources in dev mode. This matches existing handling that webpack-dev-middleware does as well. 

Changed files:
filename: packages/next/src/server/config-schema.ts
patch: @@ -261,6 +261,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
     excludeDefaultMomentLocales: z.boolean().optional(),
     experimental: z
       .strictObject({
+        allowedDevOrigins: z.array(z.string()).optional(),
         nodeMiddleware: z.boolean().optional(),
         after: z.boolean().optional(),
         appDocumentPreloading: z.boolean().optional(),

filename: packages/next/src/server/config-shared.ts
patch: @@ -257,6 +257,7 @@ export interface LoggingConfig {
 }
 
 export interface ExperimentalConfig {
+  allowedDevOrigins?: string[]
   nodeMiddleware?: boolean
   cacheHandlers?: {
     default?: string
@@ -1134,6 +1135,7 @@ export const defaultConfig: NextConfig = {
   modularizeImports: undefined,
   outputFileTracingRoot: process.env.NEXT_PRIVATE_OUTPUT_TRACE_ROOT || '',
   experimental: {
+    allowedDevOrigins: [],
     nodeMiddleware: false,
     cacheLife: {
       default: {

filename: packages/next/src/server/lib/router-server.ts
patch: @@ -49,6 +49,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'
 
 const debug = setupDebug('next:router-server:main')
 const isNextFont = (pathname: string | null) =>
@@ -165,6 +166,14 @@ export async function initialize(opts: {
   renderServer.instance =
     require('./render-server') as typeof import('./render-server')
 
+  const allowedOrigins = [
+    'localhost',
+    ...(config.experimental.allowedDevOrigins || []),
+  ]
+  if (opts.hostname) {
+    allowedOrigins.push(opts.hostname)
+  }
+
   const requestHandlerImpl: WorkerRequestHandler = async (req, res) => {
     // internal headers should not be honored by the request handler
     if (!process.env.NEXT_PRIVATE_TEST_HEADERS) {
@@ -316,6 +325,9 @@ export async function initialize(opts: {
 
       // handle hot-reloader first
       if (developmentBundler) {
+        if (blockCrossSite(req, res, allowedOrigins, `${opts.port}`)) {
+          return
+        }
         const origUrl = req.url || '/'
 
         if (config.basePath && pathHasPrefix(origUrl, config.basePath)) {
@@ -679,6 +691,9 @@ export async function initialize(opts: {
       })
 
       if (opts.dev && developmentBundler && req.url) {
+        if (blockCrossSite(req, socket, allowedOrigins, `${opts.port}`)) {
+          return
+        }
         const { basePath, assetPrefix } = config
 
         let hmrPrefix = basePath

filename: packages/next/src/server/lib/router-utils/block-cross-site.ts
patch: @@ -0,0 +1,59 @@
+import type { Duplex } from 'stream'
+import type { IncomingMessage, ServerResponse } from 'webpack-dev-server'
+import { parseUrl } from '../../../lib/url'
+import net from 'net'
+
+export const blockCrossSite = (
+  req: IncomingMessage,
+  res: ServerResponse | Duplex,
+  allowedOrigins: string[],
+  activePort: string
+): boolean => {
+  // only process _next URLs
+  if (!req.url?.includes('/_next')) {
+    return false
+  }
+  // block non-cors request from cross-site e.g. script tag on
+  // different host
+  if (
+    req.headers['sec-fetch-mode'] === 'no-cors' &&
+    req.headers['sec-fetch-site'] === 'cross-site'
+  ) {
+    if ('statusCode' in res) {
+      res.statusCode = 403
+    }
+    res.end('Unauthorized')
+    return true
+  }
+
+  // ensure websocket requests from allowed origin
+  const rawOrigin = req.headers['origin']
+
+  if (rawOrigin) {
+    const parsedOrigin = parseUrl(rawOrigin)
+
+    if (parsedOrigin) {
+      const originLowerCase = parsedOrigin.hostname.toLowerCase()
+      const isMatchingPort = parsedOrigin.port === activePort
+      const isIpRequest =
+        net.isIPv4(originLowerCase) || net.isIPv6(originLowerCase)
+
+      if (
+        // allow requests if direct IP and matching port and
+        // allow if any of the allowed origins match
+        !(isIpRequest && isMatchingPort) &&
+        !allowedOrigins.some(
+          (allowedOrigin) => allowedOrigin === originLowerCase
+        )
+      ) {
+        if ('statusCode' in res) {
+          res.statusCode = 403
+        }
+        res.end('Unauthorized')
+        return true
+      }
+    }
+  }
+
+  return false
+}

filename: test/development/basic/misc.test.ts
patch: @@ -1,9 +1,10 @@
 import url from 'url'
+import http from 'http'
 import { join } from 'path'
 import webdriver from 'next-webdriver'
 import { createNext, FileRef } from 'e2e-utils'
 import { NextInstance } from 'e2e-utils'
-import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils'
+import { fetchViaHTTP, findPort, renderViaHTTP, retry } from 'next-test-utils'
 
 describe.each([[''], ['/docs']])(
   'misc basic dev tests, basePath: %p',
@@ -42,6 +43,107 @@ describe.each([[''], ['/docs']])(
     })
 
     describe('With Security Related Issues', () => {
+      it('should not allow dev WebSocket from cross-site', async () => {
+        let server = http.createServer((req, res) => {
+          res.end(`
+            <html>
+              <head>
+                <title>testing cross-site</title>
+              </head>
+              <body></body>
+            </html>
+          `)
+        })
+        try {
+          const port = await findPort()
+          await new Promise<void>((res) => {
+            server.listen(port, () => res())
+          })
+          const websocketSnippet = `(() => {
+            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'
+            })
+          })()`
+
+          // ensure direct port with mismatching port is blocked
+          const browser = await webdriver(`http://127.0.0.1:${port}`, '/about')
+          await browser.eval(websocketSnippet)
+          await retry(async () => {
+            expect(await browser.elementByCss('#status').text()).toBe('error')
+          })
+
+          // ensure different host is blocked
+          await browser.get(`https://example.vercel.sh/`)
+          await browser.eval(websocketSnippet)
+          await retry(async () => {
+            expect(await browser.elementByCss('#status').text()).toBe('error')
+          })
+        } finally {
+          server.close()
+        }
+      })
+
+      it('should not allow loading scripts from cross-site', async () => {
+        let server = http.createServer((req, res) => {
+          res.end(`
+            <html>
+              <head>
+                <title>testing cross-site</title>
+              </head>
+              <body></body>
+            </html>
+          `)
+        })
+        try {
+          const port = await findPort()
+          await new Promise<void>((res) => {
+            server.listen(port, () => res())
+          })
+          const scriptSnippet = `(() => {
+            const statusEl = document.createElement('p')
+            statusEl.id = 'status'
+            document.querySelector('body').appendChild(statusEl)
+
+            const script = document.createElement('script')
+            script.src = "${next.url}/_next/static/chunks/pages/_app.js"
+            
+            script.onerror = (err) => {
+              statusEl.innerText = 'error'
+            }
+            script.onload = () => {
+              statusEl.innerText = 'connected'
+            }
+            document.querySelector('body').appendChild(script)
+          })()`
+
+          // ensure direct port with mismatching port is blocked
+          const browser = await webdriver(`http://127.0.0.1:${port}`, '/about')
+          await browser.eval(scriptSnippet)
+          await retry(async () => {
+            expect(await browser.elementByCss('#status').text()).toBe('error')
+          })
+
+          // ensure different host is blocked
+          await browser.get(`https://example.vercel.sh/`)
+          await browser.eval(scriptSnippet)
+
+          await retry(async () => {
+            expect(await browser.elementByCss('#status').text()).toBe('error')
+          })
+        } finally {
+          server.close()
+        }
+      })
+
       it('should not allow accessing files outside .next/static and .next/server directory', async () => {
         const pathsToCheck = [
           basePath + '/_next/static/../BUILD_ID',