The “fetch this URL” feature can read your server from the inside
Link previews, avatar imports, webhooks, PDF renderers — at some point every app fetches a URL a user gave it. If that fetch trusts the URL too much, the caller is not talking to the internet. They are talking to your server, from the inside.
September 2026 · Zoltra · No account needed to use this fix
Server-side request forgery has a reputation as an exotic bug. It is not. It is the logical consequence of a normal feature: your server can reach places your users cannot. Load balancers, internal admin panels, cloud metadata endpoints, databases on private ports. All of it sits behind the firewall, and your server is inside it.
A URL parameter that goes straight into fetch() is a remote control for that reach. The caller picks the destination. You might be thinking nobody malicious would find your little preview endpoint. That is exactly the endpoint an automated agent tries first, because it is cheap to try and the payoff is enormous.
What Zoltra actually tests
Our probe is deliberately narrow, because SSRF exploits go bad fast. Two findings come out of it, and the difference between them matters:
SSRF-class endpoint reaches an internal loopback service (high)
The parameter was pointed at a loopback address using alternate encodings, and the response showed a live service answering. That is the direct case. The server fetched something only reachable from inside its own host.
SSRF-class endpoint triggers an OAST callback (medium)
The parameter caused the server to make an outbound request to a URL we control, but did not reach anything internal. The fetch behavior is confirmed; whether it can see your private network is not. Medium, not high — but still worth fixing, because the reachable-by-request part is exactly the part attackers chain.
Both findings name the endpoint and the parameter. That is the important part: you know precisely which feature to fix, not just that the class exists somewhere in the app.
Why naive fixes fail
The instinct is to check the string. Reject anything containing localhost, 127.0.0.1, or 169.254.169.254. This fails, and the reason is worth understanding before you write the real fix:
There are many notations for the same address. 127.0.0.1 can be written as 2130706433 (decimal), 0x7f000001 (hex), 0177.0.0.1 (octal-ish variants), or with dots that resolve differently. Hostnames you do not control can resolve to private addresses ("DNS rebinding"), and a perfectly public URL can redirect to an internal one after your check passed. String matching loses to all of this.
The rule that survives: resolve the hostname first, check the resolved IP against private and reserved ranges, and only then make the request. And pin the connection to the address you checked, or a rebind between check and connect undoes everything.
The fix, for a Node/Next.js server
Three layers. The allowance check, the resolved-address check, and a redirect policy.
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const ALLOWED_HOSTS = new Set(["images.example.com", "cdn.example.com"]);
function isPrivateAddress(ip: string): boolean {
if (isIP(ip) === 6) {
const v6 = ip.toLowerCase();
return (
v6 === "::1" ||
v6.startsWith("fc") || v6.startsWith("fd") || v6.startsWith("fe80") ||
v6 === "::ffff:127.0.0.1"
);
}
const [a, b] = ip.split(".").map(Number);
return (
a === 10 || a === 127 || a === 0 ||
(a === 169 && b === 254) || // link-local + metadata
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
a >= 224 // multicast/reserved
);
}
export async function safeFetch(raw: string) {
const url = new URL(raw);
if (url.protocol !== "https:") throw new Error("https only");
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error("host not allowed");
const { address } = await lookup(url.hostname);
if (isPrivateAddress(address)) throw new Error("private address");
// The IP you checked is the IP you use: resolve once and pin it.
const res = await fetch(url, { redirect: "manual" });
if (res.status >= 300 && res.status < 400) throw new Error("no redirects");
return res;
}Two notes on that snippet. The hostname allowlist is the strongest layer — if your feature only ever needs a handful of known hosts, the rest is belt and braces. And redirect: "manual" matters: a public URL that redirects to http://127.0.0.1:8080 defeats the address check if the fetch helper follows redirects for you.
Two honest caveats, because this class punishes overconfidence. The private-address list above covers the common ranges, not every reserved allocation — use a maintained library if you want the complete set. And Node's fetch re-resolves the hostname when it connects, so between your check and the request there is a small window. For a fixed allowlist that window does not matter. If you truly must fetch arbitrary hosts, use a client that lets you pin the resolved address.
If your runtime gives you a helper for this, use it. Cloudflare Workers has an fetch that does not reach loopback, AWS tooling has IMDSv2 hardening. Use the platform guard where it exists, and keep the allowlist on top.
How to verify the fix
# 1. the direct loopback attempt must be refused, not fetched curl -s "https://your-app.example/api/preview?url=http://127.0.0.1:3000/" # 2. an alternate encoding must be refused too curl -s "https://your-app.example/api/preview?url=http://2130706433/" # 3. a public host that redirects to loopback must be refused # (use your own test redirect, not a third party) # 4. the legitimate use still works curl -s "https://your-app.example/api/preview?url=https://images.example.com/a.png"
Write those four checks down. They are the regression suite for this feature, and they take a minute to run.
How this drifts back
New features arrive with their own fetch helpers. A PDF renderer, a screenshot service, an import tool. Each one is written by someone who was not in this conversation, using a library that follows redirects by default. The old endpoint stays fixed; the new one has the same shape as the old bug.
The sustainable version is not one careful check. It is one shared safeFetch helper that every feature uses, plus a test that greps for raw fetch(userInput) in review. Boring, but it is the difference between a fixed bug and a fixed class.
What this finding does not mean
We do not exploit the endpoint. The probe confirms reachability with non-destructive evidence and stops. And we do not know every fetch path in your app — we know the ones reachable from the public surface we are authorized to test. If your finding says high, treat it as the confirmed example of a pattern, and go look for its siblings.
You can still ship this fix yourself. With GitHub connected, Zoltra can also prepare the repair as a pull request, leave it for your review, and verify the live result after deployment.
Related: Close your open API routes — the sibling finding where an endpoint does not even need a clever URL.