mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
6ba71046b0
Node.js ships with a built-in `fetch` now so `node-fetch` is no longer necessary. Mostly motivated by tracing Node.js deprecation warnings which originated from `node-fetch` by calling the deprecated `url.parse`. Call sites keep working through a compatibility type on `fetchViaHTTP` that translates node-fetch-only options: Instead of `agent` we pass to `http(s)` directly, `timeout` becomes `AbortSignal.timeout`, and Node.js readable streams are accepted as bodies with `duplex: 'half'` set automatically. The `abort-controller` polyfill is dropped since its signal type predates the current AbortSignal and undici would not honor it. `node-fetch` stays installed because `scripts/generate-release-log.mjs`, `scripts/reset-project.mjs`, and `scripts/update-google-fonts.js` still import it (follow-up material). Fixture apps will be migrated separately.
551 lines
17 KiB
TypeScript
551 lines
17 KiB
TypeScript
import http from 'http'
|
|
import { join } from 'path'
|
|
import { FileRef, NextInstance, nextTestSetup } from 'e2e-utils'
|
|
import { fetchViaHTTP, fetchViaRawHttp, 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,
|
|
}
|
|
}
|
|
|
|
function withBasePath(basePath: string, path: string) {
|
|
return `${basePath}${path}`
|
|
}
|
|
|
|
function getImageOptimizerPath(basePath: string) {
|
|
return withBasePath(
|
|
basePath,
|
|
`/_next/image?url=${encodeURIComponent(withBasePath(basePath, '/image.png'))}&w=256&q=75`
|
|
)
|
|
}
|
|
|
|
function requestInternalDevScript(
|
|
appPort: string | number,
|
|
basePath: string,
|
|
options: { referer?: string } = {}
|
|
) {
|
|
// Global fetch computes the Sec-Fetch-* headers itself and overrides user
|
|
// values, so these requests use a raw HTTP request.
|
|
return fetchViaRawHttp(
|
|
appPort,
|
|
withBasePath(basePath, '/_next/static/chunks/pages/_app.js'),
|
|
{
|
|
headers: {
|
|
...(options.referer ? { referer: options.referer } : {}),
|
|
'sec-fetch-mode': 'no-cors',
|
|
'sec-fetch-site': 'cross-site',
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
function requestInternalDevMiddleware(
|
|
appPort: string | number,
|
|
basePath: string,
|
|
origin: string
|
|
) {
|
|
return fetchViaHTTP(
|
|
appPort,
|
|
withBasePath(basePath, '/__nextjs_disable_dev_indicator'),
|
|
undefined,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
origin,
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
async function expectBlockedDevResourceMessage(
|
|
next: NextInstance,
|
|
options: {
|
|
resourcePath: string
|
|
source?: string
|
|
suggestionHost?: string
|
|
unknownSource?: true
|
|
opaqueOrigin?: true
|
|
}
|
|
) {
|
|
// I/O may not be flushed immediately, so retry until we see the message in the output.
|
|
await retry(() => {
|
|
expect(next.cliOutput).toContain(options.resourcePath)
|
|
})
|
|
const output = next.cliOutput
|
|
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) => {
|
|
describe('default blocking', () => {
|
|
const { next } = nextTestSetup({
|
|
files: {
|
|
pages: new FileRef(join(__dirname, 'misc/pages')),
|
|
public: new FileRef(join(__dirname, 'misc/public')),
|
|
},
|
|
nextConfig: {
|
|
basePath,
|
|
},
|
|
})
|
|
|
|
beforeAll(async () => {
|
|
// render 404 page to generate
|
|
// "/_next/static/chunks/pages/_app.js"
|
|
// we need this because not found static assets
|
|
// served as plain text 404 instead of HTML.
|
|
await next.render(withBasePath(basePath, '/404'))
|
|
|
|
await retry(async () => {
|
|
// make sure host server is running
|
|
const res = await fetchViaHTTP(
|
|
next.appPort,
|
|
withBasePath(basePath, '/_next/static/chunks/pages/_app.js')
|
|
)
|
|
expect(res.status).toBe(200)
|
|
})
|
|
})
|
|
|
|
it('should block 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}${withBasePath(basePath, '/_next/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 next.browser('/about', {
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
permissions: ['local-network-access'],
|
|
})
|
|
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')
|
|
})
|
|
|
|
await expectBlockedDevResourceMessage(next, {
|
|
resourcePath: withBasePath(basePath, '/_next/hmr'),
|
|
source: 'example.vercel.sh',
|
|
})
|
|
} finally {
|
|
server.close()
|
|
}
|
|
})
|
|
|
|
it('should block loading scripts from cross-site', async () => {
|
|
const port = await findPort()
|
|
|
|
const mismatchedPortRes = await requestInternalDevScript(
|
|
next.appPort,
|
|
basePath,
|
|
{
|
|
referer: `http://127.0.0.1:${port}/about`,
|
|
}
|
|
)
|
|
expect(mismatchedPortRes.status).toBe(403)
|
|
|
|
const differentHostRes = await requestInternalDevScript(
|
|
next.appPort,
|
|
basePath,
|
|
{
|
|
referer: 'https://example.vercel.sh/about',
|
|
}
|
|
)
|
|
expect(differentHostRes.status).toBe(403)
|
|
|
|
await expectBlockedDevResourceMessage(next, {
|
|
resourcePath: withBasePath(
|
|
basePath,
|
|
'/_next/static/chunks/pages/_app.js'
|
|
),
|
|
source: 'example.vercel.sh',
|
|
})
|
|
})
|
|
|
|
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(403)
|
|
|
|
const differentHostRes = await requestInternalDevMiddleware(
|
|
next.appPort,
|
|
basePath,
|
|
'https://example.vercel.sh'
|
|
)
|
|
expect(differentHostRes.status).toBe(403)
|
|
|
|
await expectBlockedDevResourceMessage(next, {
|
|
resourcePath: withBasePath(
|
|
basePath,
|
|
'/__nextjs_disable_dev_indicator'
|
|
),
|
|
source: 'example.vercel.sh',
|
|
})
|
|
})
|
|
|
|
it('should allow requests from multi-level localhost subdomains', async () => {
|
|
const res = await requestInternalDevMiddleware(
|
|
next.appPort,
|
|
basePath,
|
|
'https://sub.app.localhost'
|
|
)
|
|
expect(res.status).not.toBe(403)
|
|
})
|
|
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', () => {
|
|
const { next } = nextTestSetup({
|
|
files: {
|
|
pages: new FileRef(join(__dirname, 'misc/pages')),
|
|
public: new FileRef(join(__dirname, 'misc/public')),
|
|
},
|
|
nextConfig: {
|
|
basePath,
|
|
allowedDevOrigins: ['127.0.0.1'],
|
|
},
|
|
})
|
|
|
|
beforeAll(async () => {
|
|
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)
|
|
})
|
|
})
|
|
|
|
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/hmr')}")
|
|
|
|
ws.addEventListener('error', () => {
|
|
statusEl.innerText = 'error'
|
|
})
|
|
ws.addEventListener('open', () => {
|
|
statusEl.innerText = 'connected'
|
|
})
|
|
})()`
|
|
|
|
const browser = await next.browser('/about', {
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
permissions: ['local-network-access'],
|
|
})
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe('configured allowed origins', () => {
|
|
const { next } = nextTestSetup({
|
|
files: {
|
|
pages: new FileRef(join(__dirname, 'misc/pages')),
|
|
public: new FileRef(join(__dirname, 'misc/public')),
|
|
},
|
|
nextConfig: {
|
|
basePath,
|
|
allowedDevOrigins: ['127.0.0.1', 'example.vercel.sh'],
|
|
},
|
|
})
|
|
|
|
beforeAll(async () => {
|
|
// render 404 page to generate
|
|
// "/_next/static/chunks/pages/_app.js"
|
|
// since we haven't built any paths by this point
|
|
// causing this chunk to not be written to disk yet
|
|
await next.render(withBasePath(basePath, '/404'))
|
|
|
|
await retry(async () => {
|
|
// make sure host server is running
|
|
const res = await fetchViaHTTP(
|
|
next.appPort,
|
|
withBasePath(basePath, '/_next/static/chunks/pages/_app.js')
|
|
)
|
|
expect(res.status).toBe(200)
|
|
})
|
|
})
|
|
|
|
it('should allow dev WebSocket from configured 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}${withBasePath(basePath, '/_next/hmr')}")
|
|
|
|
ws.addEventListener('error', (err) => {
|
|
statusEl.innerText = 'error'
|
|
})
|
|
ws.addEventListener('open', () => {
|
|
statusEl.innerText = 'connected'
|
|
})
|
|
})()`
|
|
|
|
// ensure direct port with mismatching port is allowed when configured
|
|
const browser = await next.browser('/about', {
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
permissions: ['local-network-access'],
|
|
})
|
|
await browser.eval(websocketSnippet)
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('#status').text()).toBe(
|
|
'connected'
|
|
)
|
|
})
|
|
|
|
// ensure different host is allowed when configured
|
|
await browser.get(`https://example.vercel.sh/`)
|
|
await browser.eval(websocketSnippet)
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('#status').text()).toBe(
|
|
'connected'
|
|
)
|
|
})
|
|
} finally {
|
|
server.close()
|
|
}
|
|
})
|
|
|
|
it('should allow loading scripts from configured cross-site', async () => {
|
|
const port = await findPort()
|
|
|
|
const mismatchedPortRes = await requestInternalDevScript(
|
|
next.appPort,
|
|
basePath,
|
|
{
|
|
referer: `http://127.0.0.1:${port}/about`,
|
|
}
|
|
)
|
|
expect(mismatchedPortRes.status).toBe(200)
|
|
|
|
const differentHostRes = await requestInternalDevScript(
|
|
next.appPort,
|
|
basePath,
|
|
{
|
|
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)
|
|
|
|
await expectBlockedDevResourceMessage(next, {
|
|
resourcePath: withBasePath(
|
|
basePath,
|
|
'/_next/static/chunks/pages/_app.js'
|
|
),
|
|
unknownSource: true,
|
|
})
|
|
})
|
|
|
|
it('should allow loading internal middleware from configured 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)
|
|
|
|
const differentHostRes = await requestInternalDevMiddleware(
|
|
next.appPort,
|
|
basePath,
|
|
'https://example.vercel.sh'
|
|
)
|
|
expect(differentHostRes.status).toBe(204)
|
|
})
|
|
|
|
it('should load images regardless of allowed origins', async () => {
|
|
const { server, port } = await createHostServer()
|
|
try {
|
|
const browser = await next.browser('/about', {
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
permissions: ['local-network-access'],
|
|
})
|
|
|
|
const imageSnippet = `(() => {
|
|
const statusEl = document.createElement('p')
|
|
statusEl.id = 'status'
|
|
document.querySelector('body').appendChild(statusEl)
|
|
|
|
const image = document.createElement('img')
|
|
image.src = "${next.url}${getImageOptimizerPath(basePath)}"
|
|
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()
|
|
}
|
|
})
|
|
|
|
it('blocks cross-site requests from privacy-sensitive origins', async () => {
|
|
const server = http.createServer((req, res) => {
|
|
res.appendHeader('Content-Security-Policy', 'sandbox allow-scripts')
|
|
res.end(`
|
|
<html>
|
|
<head>
|
|
<title>testing cross-site privacy-sensitive</title>
|
|
</head>
|
|
<body>
|
|
<script>
|
|
(() => {
|
|
const statusEl = document.createElement('p')
|
|
statusEl.id = 'status'
|
|
document.querySelector('body').appendChild(statusEl)
|
|
|
|
const ws = new WebSocket("${next.url}${withBasePath(basePath, '/_next/hmr')}")
|
|
|
|
ws.addEventListener('error', (err) => {
|
|
statusEl.innerText = 'error'
|
|
})
|
|
ws.addEventListener('open', () => {
|
|
statusEl.innerText = 'connected'
|
|
})
|
|
})()
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`)
|
|
})
|
|
|
|
const port = await findPort()
|
|
await new Promise<void>((res) => {
|
|
server.listen(port, () => res())
|
|
})
|
|
|
|
try {
|
|
const browser = await next.browser('/', {
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
permissions: ['local-network-access'],
|
|
})
|
|
|
|
await retry(async () => {
|
|
expect(await browser.elementByCss('#status').text()).toBe('error')
|
|
})
|
|
|
|
await expectBlockedDevResourceMessage(next, {
|
|
resourcePath: withBasePath(basePath, '/_next/hmr'),
|
|
opaqueOrigin: true,
|
|
})
|
|
} finally {
|
|
await new Promise<void>((res) => {
|
|
server.close(() => {
|
|
res()
|
|
})
|
|
})
|
|
}
|
|
})
|
|
})
|
|
}
|
|
)
|