diff --git a/docs/01-app/04-api-reference/05-config/01-next-config-js/allowedDevOrigins.mdx b/docs/01-app/04-api-reference/05-config/01-next-config-js/allowedDevOrigins.mdx index 680fc1ec2fbb0..028f426b11319 100644 --- a/docs/01-app/04-api-reference/05-config/01-next-config-js/allowedDevOrigins.mdx +++ b/docs/01-app/04-api-reference/05-config/01-next-config-js/allowedDevOrigins.mdx @@ -5,6 +5,8 @@ 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 `Content` 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. + 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. `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: @@ -14,5 +16,3 @@ module.exports = { allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev'], } ``` - -Cross-origin requests are blocked by default to prevent unauthorized requesting of internal assets/endpoints which are available in development mode. This behavior is similar to other dev servers like `webpack-dev-middleware` to ensure the same protection. diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 91920133420fd..4fb7dac5e8cf2 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -1144,7 +1144,7 @@ export const defaultConfig: NextConfig = { output: !!process.env.NEXT_PRIVATE_STANDALONE ? 'standalone' : undefined, modularizeImports: undefined, outputFileTracingRoot: process.env.NEXT_PRIVATE_OUTPUT_TRACE_ROOT || '', - allowedDevOrigins: [], + allowedDevOrigins: undefined, experimental: { nodeMiddleware: false, cacheLife: { diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts index 7cb2b720fbbb4..e39c71e66a619 100644 --- a/packages/next/src/server/lib/router-server.ts +++ b/packages/next/src/server/lib/router-server.ts @@ -172,15 +172,6 @@ export async function initialize(opts: { ;(globalThis as any)[Symbol.for('@next/middleware-subrequest-id')] = middlewareSubrequestId - const allowedOrigins = [ - '*.localhost', - 'localhost', - ...(config.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) { @@ -332,7 +323,15 @@ export async function initialize(opts: { // handle hot-reloader first if (developmentBundler) { - if (blockCrossSite(req, res, allowedOrigins, `${opts.port}`)) { + if ( + blockCrossSite( + req, + res, + config.allowedDevOrigins, + opts.hostname, + `${opts.port}` + ) + ) { return } const origUrl = req.url || '/' @@ -698,7 +697,15 @@ export async function initialize(opts: { }) if (opts.dev && developmentBundler && req.url) { - if (blockCrossSite(req, socket, allowedOrigins, `${opts.port}`)) { + if ( + blockCrossSite( + req, + socket, + config.allowedDevOrigins, + opts.hostname, + `${opts.port}` + ) + ) { return } const { basePath, assetPrefix } = config diff --git a/packages/next/src/server/lib/router-utils/block-cross-site.ts b/packages/next/src/server/lib/router-utils/block-cross-site.ts index 4287d290afbab..ccdefa728cbff 100644 --- a/packages/next/src/server/lib/router-utils/block-cross-site.ts +++ b/packages/next/src/server/lib/router-utils/block-cross-site.ts @@ -5,13 +5,54 @@ import net from 'net' 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 +} + export const blockCrossSite = ( req: IncomingMessage, res: ServerResponse | Duplex, - allowedOrigins: string[], + allowedDevOrigins: string[] | undefined, + hostname: string | undefined, activePort: string ): boolean => { - // only process _next URLs + // 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 _next URLs when if (!req.url?.includes('/_next')) { return false } @@ -21,14 +62,7 @@ export const blockCrossSite = ( req.headers['sec-fetch-mode'] === 'no-cors' && req.headers['sec-fetch-site'] === 'cross-site' ) { - if ('statusCode' in res) { - res.statusCode = 403 - } - res.end('Unauthorized') - warnOnce( - `Blocked cross-origin request to /_next/*. Cross-site requests are blocked in "no-cors" mode.` - ) - return true + return warnOrBlockRequest(res, undefined, mode) } // ensure websocket requests from allowed origin @@ -49,14 +83,7 @@ export const blockCrossSite = ( !(isIpRequest && isMatchingPort) && !isCsrfOriginAllowed(originLowerCase, allowedOrigins) ) { - if ('statusCode' in res) { - res.statusCode = 403 - } - res.end('Unauthorized') - warnOnce( - `Blocked cross-origin request from ${originLowerCase}. To allow this, configure "allowedDevOrigins" in next.config\nRead more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins` - ) - return true + return warnOrBlockRequest(res, originLowerCase, mode) } } } diff --git a/test/development/basic/allowed-dev-origins.test.ts b/test/development/basic/allowed-dev-origins.test.ts new file mode 100644 index 0000000000000..ee94826929328 --- /dev/null +++ b/test/development/basic/allowed-dev-origins.test.ts @@ -0,0 +1,277 @@ +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' + +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, + '/_next/static/chunks/pages/_app.js' + ) + expect(asset.status).toBe(200) + }) + }) + afterAll(() => next.destroy()) + + it('should warn about WebSocket from cross-site', async () => { + let server = http.createServer((req, res) => { + res.end(` + + + testing cross-site + + + + `) + }) + try { + const port = await findPort() + await new Promise((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( + '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 not allow loading scripts from cross-site', async () => { + let server = http.createServer((req, res) => { + res.end(` + + + testing cross-site + + + + `) + }) + try { + const port = await findPort() + await new Promise((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( + '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() + } + }) + }) + + 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, + '/_next/static/chunks/pages/_app.js' + ) + expect(asset.status).toBe(200) + }) + }) + afterAll(() => next.destroy()) + + it('should not allow dev WebSocket from cross-site', async () => { + let server = http.createServer((req, res) => { + res.end(` + + + testing cross-site + + + + `) + }) + try { + const port = await findPort() + await new Promise((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(` + + + testing cross-site + + + + `) + }) + try { + const port = await findPort() + await new Promise((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() + } + }) + }) + } +) diff --git a/test/development/basic/misc.test.ts b/test/development/basic/misc.test.ts index 37a0773aa7375..ddb24527c11a4 100644 --- a/test/development/basic/misc.test.ts +++ b/test/development/basic/misc.test.ts @@ -1,10 +1,9 @@ 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, findPort, renderViaHTTP, retry } from 'next-test-utils' +import { fetchViaHTTP, renderViaHTTP } from 'next-test-utils' describe.each([[''], ['/docs']])( 'misc basic dev tests, basePath: %p', @@ -43,107 +42,6 @@ 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(` - - - testing cross-site - - - - `) - }) - try { - const port = await findPort() - await new Promise((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(` - - - testing cross-site - - - - `) - }) - try { - const port = await findPort() - await new Promise((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',