Repository: vercel/next.js
Pull request: block disallowed dev origins by default

This removes the warn-only default behavior and enforces the dev-origin guard by default. Cross-origin requests to internal dev resources now block unless they match the built-in local allowlist or an explicit `allowedDevOrigins` entry. The tests are expanded to cover default blocking, configured-but-not-allowlisted hosts, missing Referer in the no-cors path, and same-site requests without an Origin, and the docs are updated to match the new behavior.

Changed files:
filename: docs/01-app/03-api-reference/05-config/01-next-config-js/allowedDevOrigins.mdx
patch: @@ -5,11 +5,11 @@ description: Use `allowedDevOrigins` to configure additional origins that can re
 
 {/* The content of this doc is shared between the app and pages router. You can use the `<PagesOnly>Content</PagesOnly>` component to add content that is specific to the Pages Router. Any shared content should not be wrapped in a component. */}
 
-Next.js does not automatically block cross-origin requests during development, but will block by default in a future major version of Next.js to prevent unauthorized requesting of internal assets/endpoints that are available in development mode.
+Next.js blocks cross-origin requests to dev-only assets and endpoints during development by default to prevent unauthorized access.
 
-To configure a Next.js application to allow requests from origins other than the hostname the server was initialized with (`localhost` by default) you can use the `allowedDevOrigins` config option.
+To configure a Next.js application to allow requests from origins other than the hostname the server was initialized with (`localhost` by default), use the `allowedDevOrigins` config option.
 
-`allowedDevOrigins` allows you to set additional origins that can be used in development mode. For example, to use `local-origin.dev` instead of only `localhost`, open `next.config.js` and add the `allowedDevOrigins` config:
+`allowedDevOrigins` lets you set additional origins that can request the dev server in development mode. For example, to use `local-origin.dev` instead of only `localhost`, open `next.config.js` and add the `allowedDevOrigins` config:
 
 ```js filename="next.config.js"
 module.exports = {

filename: packages/next/src/server/lib/router-utils/block-cross-site-dev.ts
patch: @@ -4,20 +4,11 @@ import { parseUrl } from '../../../lib/url'
 import { warnOnce } from '../../../build/output/log'
 import { isCsrfOriginAllowed } from '../../app-render/csrf-protection'
 
-function warnOrBlockRequest(
+function blockRequest(
   res: ServerResponse | Duplex,
-  origin: string | undefined,
-  mode: 'warn' | 'block'
+  origin: string | undefined
 ): 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`
   )
@@ -69,14 +60,10 @@ export const blockCrossSiteDEV = (
   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 || []),
+    ...(allowedDevOrigins ?? []),
   ]
   if (hostname) {
     allowedOrigins.push(hostname)
@@ -104,7 +91,7 @@ export const blockCrossSiteDEV = (
       return false
     }
 
-    return warnOrBlockRequest(res, refererHostname, mode)
+    return blockRequest(res, refererHostname)
   }
 
   // ensure websocket requests are only fulfilled from allowed origin
@@ -124,6 +111,6 @@ export const blockCrossSiteDEV = (
   return (
     originLowerCase !== undefined &&
     !isCsrfOriginAllowed(originLowerCase, allowedOrigins) &&
-    warnOrBlockRequest(res, originLowerCase, mode)
+    blockRequest(res, originLowerCase)
   )
 }

filename: test/development/basic/allowed-dev-origins.test.ts
patch: @@ -42,15 +42,15 @@ function getImageOptimizerPath(basePath: string) {
 function requestInternalDevScript(
   appPort: string | number,
   basePath: string,
-  referer: string
+  options: { referer?: string } = {}
 ) {
   return fetchViaHTTP(
     appPort,
     withBasePath(basePath, '/_next/static/chunks/pages/_app.js'),
     undefined,
     {
       headers: {
-        referer,
+        ...(options.referer ? { referer: options.referer } : {}),
         'sec-fetch-mode': 'no-cors',
         'sec-fetch-site': 'cross-site',
       },
@@ -83,7 +83,7 @@ describe.each(['', '/docs'])(
   (basePath: string) => {
     let next: NextInstance
 
-    describe('warn mode', () => {
+    describe('default blocking', () => {
       beforeAll(async () => {
         next = await createNext({
           files: {
@@ -112,7 +112,7 @@ describe.each(['', '/docs'])(
       })
       afterAll(() => next.destroy())
 
-      it('should warn about WebSocket from cross-site', async () => {
+      it('should block WebSocket from cross-site', async () => {
         const { server, port } = await createHostServer()
         try {
           const websocketSnippet = `(() => {
@@ -134,64 +134,132 @@ describe.each(['', '/docs'])(
           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'
-            )
+            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(
-              'connected'
-            )
+            expect(await browser.elementByCss('#status').text()).toBe('error')
           })
 
-          expect(next.cliOutput).toContain('Cross origin request detected from')
+          expect(next.cliOutput).toContain('Blocked cross-origin request from')
         } finally {
           server.close()
         }
       })
 
-      it('should warn about loading scripts from cross-site', async () => {
+      it('should block loading scripts from cross-site', async () => {
         const port = await findPort()
 
         const mismatchedPortRes = await requestInternalDevScript(
           next.appPort,
           basePath,
-          `http://127.0.0.1:${port}/about`
+          {
+            referer: `http://127.0.0.1:${port}/about`,
+          }
         )
-        expect(mismatchedPortRes.status).toBe(200)
+        expect(mismatchedPortRes.status).toBe(403)
 
         const differentHostRes = await requestInternalDevScript(
           next.appPort,
           basePath,
-          'https://example.vercel.sh/about'
+          {
+            referer: 'https://example.vercel.sh/about',
+          }
         )
-        expect(differentHostRes.status).toBe(200)
+        expect(differentHostRes.status).toBe(403)
 
-        expect(next.cliOutput).toContain('Cross origin request detected from')
+        expect(next.cliOutput).toContain('Blocked cross-origin request from')
       })
 
-      it('should warn about loading internal middleware from cross-site', async () => {
+      it('should block loading internal middleware from cross-site', async () => {
         const port = await findPort()
 
         const mismatchedPortRes = await requestInternalDevMiddleware(
           next.appPort,
           basePath,
           `http://127.0.0.1:${port}`
         )
-        expect(mismatchedPortRes.status).toBe(204)
+        expect(mismatchedPortRes.status).toBe(403)
 
         const differentHostRes = await requestInternalDevMiddleware(
           next.appPort,
           basePath,
           'https://example.vercel.sh'
         )
-        expect(differentHostRes.status).toBe(204)
+        expect(differentHostRes.status).toBe(403)
+      })
 
-        expect(next.cliOutput).toContain('Cross origin request detected from')
+      it('should allow same-site requests without an origin header', async () => {
+        const res = await fetchViaHTTP(
+          next.appPort,
+          withBasePath(basePath, '/_next/static/chunks/pages/_app.js')
+        )
+        expect(res.status).toBe(200)
+      })
+    })
+
+    describe('configured but not allowlisted origins', () => {
+      beforeAll(async () => {
+        next = await createNext({
+          files: {
+            pages: new FileRef(join(__dirname, 'misc/pages')),
+            public: new FileRef(join(__dirname, 'misc/public')),
+          },
+          nextConfig: {
+            basePath,
+            allowedDevOrigins: ['127.0.0.1'],
+          },
+        })
+
+        await next.render(withBasePath(basePath, '/404'))
+
+        await retry(async () => {
+          const res = await fetchViaHTTP(
+            next.appPort,
+            withBasePath(basePath, '/_next/static/chunks/pages/_app.js')
+          )
+          expect(res.status).toBe(200)
+        })
+      })
+      afterAll(() => next.destroy())
+
+      it('should block websocket requests from configured but non-allowlisted hosts', 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}${withBasePath(basePath, '/_next/webpack-hmr')}")
+
+              ws.addEventListener('error', () => {
+                statusEl.innerText = 'error'
+              })
+              ws.addEventListener('open', () => {
+                statusEl.innerText = 'connected'
+              })
+            })()`
+
+          const browser = await webdriver(`http://127.0.0.1:${port}`, '/about')
+          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 block no-cors requests from configured but non-allowlisted hosts', async () => {
+        const res = await requestInternalDevScript(next.appPort, basePath, {
+          referer: 'https://example.vercel.sh/about',
+        })
+        expect(res.status).toBe(403)
       })
     })
 
@@ -271,18 +339,27 @@ describe.each(['', '/docs'])(
         const mismatchedPortRes = await requestInternalDevScript(
           next.appPort,
           basePath,
-          `http://127.0.0.1:${port}/about`
+          {
+            referer: `http://127.0.0.1:${port}/about`,
+          }
         )
         expect(mismatchedPortRes.status).toBe(200)
 
         const differentHostRes = await requestInternalDevScript(
           next.appPort,
           basePath,
-          'https://example.vercel.sh/about'
+          {
+            referer: 'https://example.vercel.sh/about',
+          }
         )
         expect(differentHostRes.status).toBe(200)
       })
 
+      it('should block no-cors requests without a referer even when origins are configured', async () => {
+        const res = await requestInternalDevScript(next.appPort, basePath)
+        expect(res.status).toBe(403)
+      })
+
       it('should allow loading internal middleware from configured cross-site', async () => {
         const port = await findPort()