October 31, 2026
localhost Is Blocked. But What About localhost.? π
One tiny dot, one SSRF-protection bypass in Next.js @vercel/og. A story about hostname normalization and how a single trailing dot can slipβ¦
By Jegann
4 min read
One tiny dot, one SSRF-protection bypass in Next.js @vercel/og. A story about hostname normalization and how a single trailing dot can slip past URL validation.
1. The beginning: security filters have trust issues π
Imagine you are building a security guard. You tell the guard: "Never allow localhost inside. It is dangerous."
Someone arrives as localhost. Guard: BLOCKED. Another arrives as 127.0.0.1. Guard: BLOCKED. Everything looks perfect.
Then someone arrives as localhost. and the guard says: "I don't see any problem here. Welcome!" π
That is essentially the behavior I investigated in Next.js's bundled image-rendering. Sometimes a vulnerability does not need a complicated payload. Sometimes all it needs is a dot.
2. A little background: what is SSRF?
Server-Side Request Forgery (SSRF) happens when an attacker can influence a server into making requests to destinations that should not be reachable. An application accepts an image URL, the server downloads it. Normal, until the URL points at an internal service instead of a public image.
The server might then reach loopback services, internal HTTP endpoints, private network resources, or other internal infrastructure. That is why anything fetching a user-controlled URL needs strong destination validation.
3. Where I found the issue
During source-code analysis I looked at the URL-safety validation in Satori, used by the bundled @vercel/og renderer in Next.js. The tested environment:
Component Version
Next.js 16.4.3
@vercel/og 1.0.1
Satori 0.29.0Component Version
Next.js 16.4.3
@vercel/og 1.0.1
Satori 0.29.0The logic was meant to stop image fetching from dangerous destinations, including localhost and private addresses. But the way it classified hostnames turned out to be interesting: it checked the hostname literally.
4. The actual finding: meet localhost.
The hostname-checking logic looked like this:
let host = url.hostname.toLowerCase();
if (host.startsWith('[') && host.endsWith(']')) {
host = host.slice(1, -1);
}
if (host === 'localhost' || host.endsWith('.localhost')) {
return true;
}
if (host.endsWith('.local')) {
return true;
}let host = url.hostname.toLowerCase();
if (host.startsWith('[') && host.endsWith(']')) {
host = host.slice(1, -1);
}
if (host === 'localhost' || host.endsWith('.localhost')) {
return true;
}
if (host.endsWith('.local')) {
return true;
}At a glance it looks reasonable. But consider:
new URL('http://localhost.:3000/').hostname;
// => 'localhost.'new URL('http://localhost.:3000/').hostname;
// => 'localhost.'The validator checks for localhost, but never normalizes the trailing DNS root dot first. localhost. and localhost are different strings, so the trailing-dot form slips past the classification in the tested version.
5. Reproducing the behavior
I built a local reproduction with a loopback-only HTTP service and compared how the renderer handled different representations of the same local destination:
Input Result
http://127.0.0.1:PORT/ Blocked
http://localhost:PORT/ Blocked
http://localhost.:PORT/ BypassedInput Result
http://127.0.0.1:PORT/ Blocked
http://localhost:PORT/ Blocked
http://localhost.:PORT/ BypassedThe reproduction output:
[blocked-v4] Image source resolves to a blocked address
[plain-localhost] Image source resolves to a blocked address
[trailing-dot] RENDERED bytes=296 newServerHits=1[blocked-v4] Image source resolves to a blocked address
[plain-localhost] Image source resolves to a blocked address
[trailing-dot] RENDERED bytes=296 newServerHits=1The loopback listener confirmed the request actually reached it. The URL passed the security check and hit the local service.
6. Beyond a direct library test
I did not want to stop at a unit-level test, so I built a local Next.js 16.4.3 app with an App Router route handler using ImageResponse that forwarded an image URL to the renderer, then ran it in production mode:
GET /og?src=127.0.0.1 -> Blocked
GET /og?src=localhost -> Blocked
GET /og?src=localhost. -> HTTP 200 image/pngGET /og?src=127.0.0.1 -> Blocked
GET /og?src=localhost -> Blocked
GET /og?src=localhost. -> HTTP 200 image/pngThe internal service returned an SVG with a magenta canary, and the generated PNG contained that same canary. That showed the full local data flow:
attacker-controlled image URL
|
Next.js route
|
ImageResponse
|
Satori image fetch
|
local HTTP service
|
SVG response
|
generated PNGattacker-controlled image URL
|
Next.js route
|
ImageResponse
|
Satori image fetch
|
local HTTP service
|
SVG response
|
generated PNGEverything was run on my own local infrastructure. No Vercel-hosted service or customer infrastructure was contacted.
7. Why it matters
An app that passes attacker-controlled image URLs into ImageResponse may let an attacker influence server-side requests, and depending on the network position reach loopback services, internal HTTP services, or other private resources. Satori inlining SVG can also make some internal responses observable through the generated image.
But an important limit: not every Next.js app is automatically vulnerable. The app has to expose an image-fetching sink that accepts attacker-controlled input. That distinction matters when judging real-world exploitability.
8. The fix: normalize before validating
The upstream fix was simple. Satori 0.29.1 added trailing-dot normalization:
let host = url.hostname
.toLowerCase()
.replace(/\.+$/, '');let host = url.hostname
.toLowerCase()
.replace(/\.+$/, '');So localhost. becomes localhost before classification, which closes the representation bypass. Good defensive measures also include: normalize hostnames before validation, validate the resolved IP, guard against redirects to blocked destinations, consider DNS rebinding and connect-time IP pinning, add regression tests for odd hostname forms, and apply network-level egress restrictions.
Upstream references: Satori PR #777, Satori 0.29.1 release, Next.js PR #97276.
9. The disclosure journey
I submitted this through HackerOne as report #4147141. It was closed as a duplicate of #3979006. That is part of research too: not every technically valid finding is a unique accepted vulnerability. Sometimes it was already reported, the root cause is known, or the dependency already has a fix.
So this is best understood as an independently reproduced technical investigation, not a claim of a new accepted Vercel vulnerability.
10. What I learned
- String comparison is not security validation. Normalize a hostname before you classify it.
- Security filters need edge-case testing. Testing only localhost and 127.0.0.1 is not enough.
- Library-level testing is only the start. Showing it through a real app route is stronger evidence.
- Understand the sink. A library bug does not mean every app using it is exploitable.
- Document duplicate status honestly. Reproduction and novelty are different things.
Final thoughts
I started on SSRF protections and ended up staring at one character. Not a payload, not an exploit chain, just localhost. β a tiny dot that exposed a gap in hostname normalization. Sometimes security research is about complex bugs, and sometimes it is about remembering that even a dot deserves a review.
Disclosure note: this was submitted as HackerOne #4147141 and closed as a duplicate of #3979006. All tests described here were run locally on researcher-controlled infrastructure. No live Vercel customer infrastructure was tested.