Repository: vercel/next.js
Pull request: improve allowedDevOrigins error

This improves the blocked-request warning so it names the actual dev resource being requested and gives clearer guidance on how to allow it. When the source host is known, the message includes an inline `allowedDevOrigins` config snippet; when the source is missing or opaque, it explains why Next.js cannot infer a host to allow. 

Changed files:
filename: packages/next/src/server/lib/router-utils/block-cross-site-dev.ts
patch: @@ -4,14 +4,72 @@ import { parseUrl } from '../../../lib/url'
 import { warnOnce } from '../../../build/output/log'
 import { isCsrfOriginAllowed } from '../../app-render/csrf-protection'
 
+const allowedDevOriginsDocs =
+  'https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins'
+
+function getBlockedResourcePath(req: IncomingMessage): string {
+  return parseUrl(req.url ?? '')?.pathname ?? req.url ?? '/_next/*'
+}
+
+function formatBlockedCrossSiteMessage(
+  source: string | undefined,
+  resourcePath: string
+): string {
+  const lines = [
+    `Blocked cross-origin request to Next.js dev resource ${resourcePath}${getBlockedSourceDescription(source)}.`,
+    'Cross-origin access to Next.js dev resources is blocked by default for safety.',
+  ]
+
+  // `source` has 3 meanings here:
+  // - `'null'`: browser explicitly sent `Origin: null` for an opaque/sandboxed origin
+  // - hostname string: we parsed an allowlistable host from Origin/Referer
+  // - `undefined` (and effectively empty string): the request did not include a usable host
+  if (source === 'null') {
+    lines.push(
+      '',
+      'This request came from a privacy-sensitive or opaque origin, so Next.js cannot determine which host to allow.',
+      'If you need it to succeed, load the dev server from a normal origin and add that host to "allowedDevOrigins".'
+    )
+  } else if (source) {
+    lines.push(
+      '',
+      'To allow this host in development, add it to "allowedDevOrigins" in next.config.js and restart the dev server:',
+      '',
+      '// next.config.js',
+      'module.exports = {',
+      `  allowedDevOrigins: ['${source}'],`,
+      '}'
+    )
+  } else {
+    lines.push(
+      '',
+      'This request did not include an allowlistable source host.',
+      'If you need it to succeed, make sure the browser sends an Origin or Referer from a host listed in "allowedDevOrigins".'
+    )
+  }
+
+  lines.push('', `Read more: ${allowedDevOriginsDocs}`)
+  return lines.join('\n')
+}
+
+function getBlockedSourceDescription(source: string | undefined): string {
+  if (source === 'null') {
+    return ' from a privacy-sensitive or opaque origin'
+  }
+
+  if (source) {
+    return ` from "${source}"`
+  }
+
+  return ' from an unknown source'
+}
+
 function blockRequest(
+  req: IncomingMessage,
   res: ServerResponse | Duplex,
-  origin: string | undefined
+  source: string | undefined
 ): boolean {
-  const originString = origin ? `from ${origin}` : ''
-  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`
-  )
+  warnOnce(formatBlockedCrossSiteMessage(source, getBlockedResourcePath(req)))
 
   if ('statusCode' in res) {
     res.statusCode = 403
@@ -91,7 +149,7 @@ export const blockCrossSiteDEV = (
       return false
     }
 
-    return blockRequest(res, refererHostname)
+    return blockRequest(req, res, refererHostname)
   }
 
   // ensure websocket requests are only fulfilled from allowed origin
@@ -111,6 +169,6 @@ export const blockCrossSiteDEV = (
   return (
     originLowerCase !== undefined &&
     !isCsrfOriginAllowed(originLowerCase, allowedOrigins) &&
-    blockRequest(res, originLowerCase)
+    blockRequest(req, res, originLowerCase)
   )
 }

filename: test/development/basic/allowed-dev-origins.test.ts
patch: @@ -78,6 +78,44 @@ function requestInternalDevMiddleware(
   )
 }
 
+function expectBlockedDevResourceMessage(
+  output: string,
+  options: {
+    resourcePath: string
+    source?: string
+    suggestionHost?: string
+    unknownSource?: true
+    opaqueOrigin?: true
+  }
+) {
+  expect(output).toContain(options.resourcePath)
+  expect(output).toContain(
+    'Cross-origin access to Next.js dev resources is blocked by default for safety.'
+  )
+
+  if (options.opaqueOrigin) {
+    expect(output).toContain('from a privacy-sensitive or opaque origin')
+    expect(output).not.toContain("allowedDevOrigins: ['null']")
+    return
+  }
+
+  if (options.unknownSource) {
+    expect(output).toContain('from an unknown source')
+    expect(output).toContain(
+      'This request did not include an allowlistable source host.'
+    )
+    return
+  }
+
+  expect(output).toContain(`from "${options.source}"`)
+  expect(output).toContain(
+    'To allow this host in development, add it to "allowedDevOrigins" in next.config.js and restart the dev server:'
+  )
+  expect(output).toContain(
+    `allowedDevOrigins: ['${options.suggestionHost ?? options.source}']`
+  )
+}
+
 describe.each(['', '/docs'])(
   'allowed-dev-origins, basePath: %p',
   (basePath: string) => {
@@ -144,7 +182,10 @@ describe.each(['', '/docs'])(
             expect(await browser.elementByCss('#status').text()).toBe('error')
           })
 
-          expect(next.cliOutput).toContain('Blocked cross-origin request from')
+          expectBlockedDevResourceMessage(next.cliOutput, {
+            resourcePath: withBasePath(basePath, '/_next/webpack-hmr'),
+            source: 'example.vercel.sh',
+          })
         } finally {
           server.close()
         }
@@ -171,7 +212,13 @@ describe.each(['', '/docs'])(
         )
         expect(differentHostRes.status).toBe(403)
 
-        expect(next.cliOutput).toContain('Blocked cross-origin request from')
+        expectBlockedDevResourceMessage(next.cliOutput, {
+          resourcePath: withBasePath(
+            basePath,
+            '/_next/static/chunks/pages/_app.js'
+          ),
+          source: 'example.vercel.sh',
+        })
       })
 
       it('should block loading internal middleware from cross-site', async () => {
@@ -190,6 +237,11 @@ describe.each(['', '/docs'])(
           'https://example.vercel.sh'
         )
         expect(differentHostRes.status).toBe(403)
+
+        expectBlockedDevResourceMessage(next.cliOutput, {
+          resourcePath: withBasePath(basePath, '/__nextjs_error_feedback'),
+          source: 'example.vercel.sh',
+        })
       })
 
       it('should allow same-site requests without an origin header', async () => {
@@ -358,6 +410,14 @@ describe.each(['', '/docs'])(
       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)
+
+        expectBlockedDevResourceMessage(next.cliOutput, {
+          resourcePath: withBasePath(
+            basePath,
+            '/_next/static/chunks/pages/_app.js'
+          ),
+          unknownSource: true,
+        })
       })
 
       it('should allow loading internal middleware from configured cross-site', async () => {
@@ -450,6 +510,11 @@ describe.each(['', '/docs'])(
           await retry(async () => {
             expect(await browser.elementByCss('#status').text()).toBe('error')
           })
+
+          expectBlockedDevResourceMessage(next.cliOutput, {
+            resourcePath: withBasePath(basePath, '/_next/webpack-hmr'),
+            opaqueOrigin: true,
+          })
         } finally {
           await new Promise<void>((res) => {
             server.close(() => {