Repository: vercel/next.js
Pull request: [backport]: config.allowedDevOrigins

Backports:
- #76880
- #77044
- #77053
- #77395
- #77414


Changed files:
filename: packages/next/src/server/config-schema.ts
patch: @@ -125,6 +125,7 @@ const zTurboRuleConfigItemOrShortcut: zod.ZodType<TurboRuleConfigItemOrShortcut>
 
 export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
   z.strictObject({
+    allowedDevOrigins: z.array(z.string()).optional(),
     amp: z
       .object({
         canonicalBase: z.string().optional(),

filename: packages/next/src/server/config-shared.ts
patch: @@ -465,6 +465,8 @@ export type ExportPathMap = {
  * Read more: [Next.js Docs: `next.config.js`](https://nextjs.org/docs/api-reference/next.config.js/introduction)
  */
 export interface NextConfig extends Record<string, any> {
+  allowedDevOrigins?: string[]
+
   exportPathMap?: (
     defaultMap: ExportPathMap,
     ctx: {
@@ -877,6 +879,7 @@ export const defaultConfig: NextConfig = {
   swcMinify: true,
   output: !!process.env.NEXT_PRIVATE_STANDALONE ? 'standalone' : undefined,
   modularizeImports: undefined,
+  allowedDevOrigins: undefined,
   experimental: {
     multiZoneDraftMode: false,
     prerenderEarlyExit: false,

filename: packages/next/src/server/lib/router-server.ts
patch: @@ -42,6 +42,7 @@ import { getHostname } from '../../shared/lib/get-hostname'
 import { detectDomainLocale } from '../../shared/lib/i18n/detect-domain-locale'
 import { normalizedAssetPrefix } from '../../shared/lib/normalized-asset-prefix'
 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) =>
@@ -301,6 +302,10 @@ export async function initialize(opts: {
 
       // handle hot-reloader first
       if (developmentBundler) {
+        if (blockCrossSite(req, res, config.allowedDevOrigins, opts.hostname)) {
+          return
+        }
+
         const origUrl = req.url || '/'
 
         if (config.basePath && pathHasPrefix(origUrl, config.basePath)) {
@@ -656,6 +661,11 @@ export async function initialize(opts: {
       })
 
       if (opts.dev && developmentBundler && req.url) {
+        if (
+          blockCrossSite(req, socket, config.allowedDevOrigins, opts.hostname)
+        ) {
+          return
+        }
         const { basePath, assetPrefix } = config
 
         let hmrPrefix = basePath

filename: packages/next/src/server/lib/router-utils/block-cross-site.ts
patch: @@ -0,0 +1,101 @@
+import type { Duplex } from 'stream'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { parseUrl } from '../../../lib/url'
+import { warnOnce } from '../../../build/output/log'
+import { isCsrfOriginAllowed } from '../../app-render/csrf-protection'
+
+function warnOrBlockRequest(
+  res: ServerResponse | Duplex,
+  origin: string | undefined,
+  mode: 'warn' | 'block'
+): boolean {
+  const originString = origin ? `from ${origin}` : ''
+  if (mode === 'warn') {
+    warnOnce(
+      `Cross origin request detected ${originString} to /_next/* resource. In a future major version of Next.js, you will need to explicitly configure "allowedDevOrigins" in next.config to allow this.\nRead more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins`
+    )
+
+    return false
+  }
+
+  warnOnce(
+    `Blocked cross-origin request ${originString} to /_next/* resource. To allow this, configure "allowedDevOrigins" in next.config\nRead more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins`
+  )
+
+  if ('statusCode' in res) {
+    res.statusCode = 403
+  }
+
+  res.end('Unauthorized')
+
+  return true
+}
+
+function isInternalDevEndpoint(req: IncomingMessage): boolean {
+  if (!req.url) return false
+
+  try {
+    // TODO: We should standardize on a single prefix for this
+    const isMiddlewareRequest = req.url.includes('/__nextjs')
+    const isInternalAsset = req.url.includes('/_next')
+    // Static media requests are excluded, as they might be loaded via CSS and would fail
+    // CORS checks.
+    const isIgnoredRequest =
+      req.url.includes('/_next/image') ||
+      req.url.includes('/_next/static/media')
+
+    return !isIgnoredRequest && (isInternalAsset || isMiddlewareRequest)
+  } catch (err) {
+    return false
+  }
+}
+
+export const blockCrossSite = (
+  req: IncomingMessage,
+  res: ServerResponse | Duplex,
+  allowedDevOrigins: string[] | undefined,
+  hostname: string | undefined
+): boolean => {
+  // in the future, these will be blocked by default when allowed origins aren't configured.
+  // for now, we warn when allowed origins aren't configured
+  const mode = typeof allowedDevOrigins === 'undefined' ? 'warn' : 'block'
+
+  const allowedOrigins = [
+    '*.localhost',
+    'localhost',
+    ...(allowedDevOrigins || []),
+  ]
+  if (hostname) {
+    allowedOrigins.push(hostname)
+  }
+
+  // only process internal URLs/middleware
+  if (!isInternalDevEndpoint(req)) {
+    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'
+  ) {
+    return warnOrBlockRequest(res, undefined, mode)
+  }
+
+  // ensure websocket requests from allowed origin
+  const rawOrigin = req.headers['origin']
+
+  if (rawOrigin) {
+    const parsedOrigin = parseUrl(rawOrigin)
+
+    if (parsedOrigin) {
+      const originLowerCase = parsedOrigin.hostname.toLowerCase()
+
+      if (!isCsrfOriginAllowed(originLowerCase, allowedOrigins)) {
+        return warnOrBlockRequest(res, originLowerCase, mode)
+      }
+    }
+  }
+
+  return false
+}

filename: test/development/basic/allowed-dev-origins.test.ts
patch: @@ -0,0 +1,364 @@
+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, findPort, retry } from 'next-test-utils'
+
+async function createHostServer() {
+  const server = http.createServer((req, res) => {
+    res.end(`
+      <html>
+        <head>
+          <title>testing cross-site</title> 
+        </head>
+        <body></body>
+      </html>
+    `)
+  })
+
+  const port = await findPort()
+  await new Promise<void>((res) => {
+    server.listen(port, () => res())
+  })
+
+  return {
+    server,
+    port,
+  }
+}
+
+describe.each([['', '/docs']])(
+  'allowed-dev-origins, basePath: %p',
+  (basePath: string) => {
+    let next: NextInstance
+
+    describe('warn mode', () => {
+      beforeAll(async () => {
+        next = await createNext({
+          files: {
+            pages: new FileRef(join(__dirname, 'misc/pages')),
+            public: new FileRef(join(__dirname, 'misc/public')),
+          },
+          nextConfig: {
+            basePath,
+          },
+        })
+
+        await retry(async () => {
+          // make sure host server is running
+          const asset = await fetchViaHTTP(
+            next.appPort,
+            basePath + '/_next/static/chunks/pages/_app.js'
+          )
+          if (asset.status >= 300) {
+            throw new Error('Host server is not running')
+          }
+        })
+      })
+      afterAll(() => next.destroy())
+
+      it('should warn about WebSocket from cross-site', async () => {
+        const { server, port } = await createHostServer()
+        try {
+          const websocketSnippet = `(() => {
+              const statusEl = document.createElement('p')
+              statusEl.id = 'status'
+              document.querySelector('body').appendChild(statusEl)
+  
+              const ws = new WebSocket("${next.url.replace(
+                'http://',
+                'ws://'
+              )}/_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(
+              'connected'
+            )
+          })
+
+          // 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(
+              'connected'
+            )
+          })
+
+          expect(next.cliOutput).toContain('Cross origin request detected from')
+        } finally {
+          server.close()
+        }
+      })
+
+      it('should warn about loading scripts from cross-site', async () => {
+        const { server, port } = await createHostServer()
+
+        try {
+          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(
+              'connected'
+            )
+          })
+
+          // 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(
+              'connected'
+            )
+          })
+
+          expect(next.cliOutput).toContain('Cross origin request detected from')
+        } finally {
+          server.close()
+        }
+      })
+
+      it('should warn about loading internal middleware from cross-site', async () => {
+        const { server, port } = await createHostServer()
+        try {
+          const browser = await webdriver(`http://127.0.0.1:${port}`, '/about')
+
+          const middlewareSnippet = `(() => {
+            const statusEl = document.createElement('p')
+            statusEl.id = 'status'
+            document.querySelector('body').appendChild(statusEl)
+
+            const xhr = new XMLHttpRequest()
+            xhr.open('GET', '${next.url}/__nextjs_error_feedback?errorCode=0&wasHelpful=true', true)
+            xhr.send()
+
+            xhr.onload = () => {
+              statusEl.innerText = "OK"
+            }
+            xhr.onerror = () => {
+              statusEl.innerText = "Unauthorized"
+            }
+          })()`
+
+          await browser.eval(middlewareSnippet)
+
+          await retry(async () => {
+            // TODO: These requests seem to be blocked regardless of our handling only when running with Turbopack
+            // Investigate why this is the case
+            if (!process.env.TURBOPACK) {
+              expect(await browser.elementByCss('#status').text()).toBe('OK')
+            }
+
+            expect(next.cliOutput).toContain(
+              'Cross origin request detected from'
+            )
+          })
+        } finally {
+          server.close()
+        }
+      })
+    })
+
+    describe('block mode', () => {
+      beforeAll(async () => {
+        next = await createNext({
+          files: {
+            pages: new FileRef(join(__dirname, 'misc/pages')),
+            public: new FileRef(join(__dirname, 'misc/public')),
+          },
+          nextConfig: {
+            basePath,
+            allowedDevOrigins: ['localhost'],
+          },
+        })
+
+        await retry(async () => {
+          // make sure host server is running
+          const asset = await fetchViaHTTP(
+            next.appPort,
+            basePath + '/_next/static/chunks/pages/_app.js'
+          )
+          if (asset.status >= 300) {
+            throw new Error('Host server is not running')
+          }
+        })
+      })
+      afterAll(() => next.destroy())
+
+      it('should not allow dev WebSocket from cross-site', async () => {
+        const { server, port } = await createHostServer()
+        try {
+          const websocketSnippet = `(() => {
+              const statusEl = document.createElement('p')
+              statusEl.id = 'status'
+              document.querySelector('body').appendChild(statusEl)
+  
+              const ws = new WebSocket("${next.url.replace(
+                'http://',
+                'ws://'
+              )}/_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 () => {
+        const { server, port } = await createHostServer()
+        try {
+          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 loading internal middleware from cross-site', async () => {
+        const { server, port } = await createHostServer()
+        try {
+          const browser = await webdriver(`http://127.0.0.1:${port}`, '/about')
+
+          const middlewareSnippet = `(() => {
+            const statusEl = document.createElement('p')
+            statusEl.id = 'status'
+            document.querySelector('body').appendChild(statusEl)
+
+            const xhr = new XMLHttpRequest()
+            xhr.open('GET', '${next.url}/__nextjs_error_feedback?errorCode=0&wasHelpful=true', true)
+            xhr.send()
+
+            xhr.onload = () => {
+              statusEl.innerText = "OK"
+            }
+            xhr.onerror = () => {
+              statusEl.innerText = "Unauthorized"
+            }
+          })()`
+
+          await browser.eval(middlewareSnippet)
+
+          await retry(async () => {
+            expect(await browser.elementByCss('#status').text()).toBe(
+              'Unauthorized'
+            )
+          })
+        } finally {
+          server.close()
+        }
+      })
+
+      it('should load images regardless of allowed origins', async () => {
+        const { server, port } = await createHostServer()
+        try {
+          const browser = await webdriver(`http://127.0.0.1:${port}`, '/about')
+
+          const imageSnippet = `(() => {
+            const statusEl = document.createElement('p')
+            statusEl.id = 'status'
+            document.querySelector('body').appendChild(statusEl)
+
+            const image = document.createElement('img')
+            image.src = "${next.url}/_next/image?url=%2Fimage.png&w=256&q=75"
+            document.querySelector('body').appendChild(image)
+            image.onload = () => {
+              statusEl.innerText = 'OK'
+            }
+            image.onerror = () => {
+              statusEl.innerText = 'Unauthorized'
+            }
+          })()`
+
+          await browser.eval(imageSnippet)
+
+          await retry(async () => {
+            expect(await browser.elementByCss('#status').text()).toBe('OK')
+          })
+        } finally {
+          server.close()
+        }
+      })
+    })
+  }
+)