Inside shuvonsec/nextjs-ssrf-poc: the Next.js image optimizer trap that turns redirects into data leaks

A tiny local lab shows how an allowlisted image URL, a trusted redirect, and BMP passthrough combine into a much nastier SSRF than a blocked request.

8 min read • View on GitHub • More from shuvonsec

A wide editorial scene shows a polished image gateway that looks safe on the surface, then bends through a hidden redirect sign toward a locked internal door. A thin trail of bytes slips back out, illustrating how an approved request can still leak data after the first trust check.
A trusted source is not the same thing as a trusted destination.
Key Takeaways

Most SSRF demos end at the blocked request. This one starts where the guardrail says yes. Next.js approves the source host, follows a redirect it did not re-evaluate, then hands the result to an image pipeline that can preserve more bytes than a normal re-encode would.

The exploit works because the first check validates the URL, but the later hop changes the destination and the body survives the trip.

Why the redirect defeats the allowlist

The important detail is timing. The allowlist in next.config.mjs says which source host is acceptable when the request begins, not where the request is allowed to end up after the server follows redirects. That is why this PoC is interesting. The front door looks strict, but the back half of the journey still moves.

export default {
  images: {
    remotePatterns: [{ protocol: 'http', hostname: 'localhost', port: '4444' }],
    dangerouslyAllowLocalIP: true,
  },
}

In other words, the policy is written for the first hop, while the exploit lives in the second. The mock redirect server sits inside the approved zone, then hands the optimizer a new destination that was never part of the original trust decision.

The BMP trick turns blind SSRF into exfiltration

A plain SSRF proof usually stops at reachability. This repo goes further by choosing a response type that can preserve raw bytes. The internal service returns a valid BMP header and appends extra data after the image body, so the optimizer can end up relaying more than just pixels.

A close-up shows a BMP file as a sealed crate with a simple front face and a hidden tail of bytes folded behind it. The image explains how a format that looks harmless can carry extra data through the optimizer.
A format check can become a payload check when the body is preserved byte for byte.
const bmpHeader = Buffer.from([
  0x42, 0x4d,
  /* valid 1x1 BMP header bytes */
])

const hiddenTail = Buffer.from('{"secret":"redacted"}')

res.setHeader('Content-Type', 'image/bmp')
res.end(Buffer.concat([bmpHeader, pixelData, hiddenTail]))

That is the real upgrade. A blind server-side request becomes a response that can smuggle bytes back to the browser. The attack is no longer just "can the server reach it?" It becomes "can the server be made to return it intact?"

The lab is tiny on purpose

The repository is built like a miniature network diagram, not a full application. That is the point. Each file represents one trust zone, and the orchestration script makes the whole chain repeatable enough to inspect without guesswork.

Why this PoC stands apart from simpler SSRF demos

CaseEntry pointTrust checkWhere the request ends upWhy it matters
Direct SSRF attemptImage requestBlocks or rejects the URL up frontNever leaves the approved boundaryUseful as a baseline, but not the interesting failure mode.
Allowlisted redirect bypassAllowed image hostChecks only the first hopFollows a redirect to a forbidden targetShows that validation can be correct and still be too early.
BMP passthrough exfiltrationAllowed host plus redirectTrust decision happens before the body is normalizedHidden bytes survive back to the browserTurns reachability into visible data leakage.
The left side shows a direct request stopped by a wall, while the right side shows the same request routed through an allowed host, redirected, and returned with extra bytes intact. The contrast makes the difference between a blocked SSRF and a successful data leak obvious at a glance.
The interesting boundary is not access alone. It is access plus preserved response bytes.

That is why this repository is more than a one-off bug demo. It is a compact lesson in how modern frameworks can fail at the handoff between validation, transport, and response handling. The code is small, but the security story is not.

What the repo teaches framework authors

Security controls that only inspect the first hop are fragile by default. Redirect handling, body normalization, and format-specific shortcuts all deserve the same scrutiny as the initial allowlist. This PoC is useful because it makes that failure mode concrete, reproducible, and hard to dismiss as theory.