Skip to content

Add handling of origin in dev mode #76880

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/server/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export interface LoggingConfig {
}

export interface ExperimentalConfig {
allowedDevOrigins?: string[]
nodeMiddleware?: boolean
cacheHandlers?: {
default?: string
Expand Down Expand Up @@ -1134,6 +1135,7 @@ export const defaultConfig: NextConfig = {
modularizeImports: undefined,
outputFileTracingRoot: process.env.NEXT_PRIVATE_OUTPUT_TRACE_ROOT || '',
experimental: {
allowedDevOrigins: [],
nodeMiddleware: false,
cacheLife: {
default: {
Expand Down
15 changes: 15 additions & 0 deletions packages/next/src/server/lib/router-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions packages/next/src/server/lib/router-utils/block-cross-site.ts
Original file line number Diff line number Diff line change
@@ -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
}
104 changes: 103 additions & 1 deletion test/development/basic/misc.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading