A user submits https://example.invalid/report. The first DNS lookup returns a public address, so the application approves it. The server responds with a redirect to http://169.254.169.254/latest/meta-data/. A fetch client follows automatically, and the original allow decision silently becomes authority to contact a different host.
This is the SSRF boundary many URL-preview and import services miss: a URL is not one network destination. DNS answers can change, redirects introduce new destinations, and an apparently public hostname can resolve to private space.
The invariant should be stricter:
Every connection attempt must target an address allowed by policy, and every redirect must receive a new authorization decision.
This article builds that decision point. It is a defensive pattern, not a claim about a particular framework vulnerability.
The trust boundary
Treat these values as untrusted and distinct:
user URL
-> parsed URL
-> normalized hostname
-> DNS answer set
-> selected socket address
-> HTTP redirect target
-> next DNS answer set
Checking only the parsed hostname misses DNS. Checking only the first DNS answer misses multiple records. Checking only the initial request misses redirects. Checking the Host header but allowing the HTTP library to resolve independently creates a time-of-check/time-of-use gap.
A useful policy has four layers:
| Layer | Reject |
|---|---|
| Scheme | anything except explicitly supported https:/http:
|
| URL syntax | credentials, malformed ports, ambiguous hostnames |
| Resolution | loopback, private, link-local, multicast, unspecified addresses |
| Navigation | redirects that exceed the hop limit or fail the entire policy again |
A minimal address gate
Node's net.isIP() identifies syntax, but policy still needs CIDR classification. The example below covers high-risk IPv4 ranges and IPv4-mapped IPv6. Production code should use a maintained IP/CIDR library and include the complete IPv6 policy for the deployment.
import dns from "node:dns/promises";
import net from "node:net";
function blockedIPv4(address) {
const parts = address.split(".").map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
return true;
}
const [a, b] = parts;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127) ||
a >= 224
);
}
function addressAllowed(address) {
if (net.isIPv4(address)) return !blockedIPv4(address);
const normalized = address.toLowerCase();
if (normalized.startsWith("::ffff:")) {
return !blockedIPv4(normalized.slice(7));
}
// Fail closed here until the service has an explicit IPv6 CIDR policy.
return false;
}
async function authorize(urlText) {
const url = new URL(urlText);
if (!["https:", "http:"].includes(url.protocol)) throw new Error("scheme denied");
if (url.username || url.password) throw new Error("credentials denied");
const answers = await dns.lookup(url.hostname, { all: true, verbatim: true });
if (answers.length === 0) throw new Error("no addresses");
if (answers.some(({ address }) => !addressAllowed(address))) {
throw new Error("destination denied");
}
return { url, answers };
}
Why reject the hostname when any answer is forbidden? Because otherwise address selection becomes an implicit policy lottery. An attacker can influence record order, while runtimes and proxies may choose differently from your validator.
Disable automatic redirects
Authorization must own navigation. Use manual redirect handling, resolve each target, and set a small hop budget.
async function fetchAuthorized(start, maxRedirects = 3) {
let current = start;
for (let hop = 0; hop <= maxRedirects; hop++) {
await authorize(current);
const response = await fetch(current, { redirect: "manual" });
if (response.status < 300 || response.status >= 400) return response;
const location = response.headers.get("location");
if (!location) throw new Error("redirect without location");
if (hop === maxRedirects) throw new Error("redirect limit exceeded");
current = new URL(location, current).href;
}
}
This closes the obvious redirect bypass, but it does not fully bind validation to the socket. The HTTP client performs its own DNS lookup after authorize(). A DNS rebinding attacker may return a different answer between those operations.
Bind the approved address to the connection
The robust design resolves once per hop, selects an approved address, and supplies that exact address to the connector while retaining the original hostname for TLS Server Name Indication and certificate validation.
The implementation depends on the HTTP stack. In Node, use a custom lookup/dispatcher or an agent that receives the approved address. Do not solve rebinding by replacing the URL hostname with the IP: that can break TLS verification and virtual hosting in dangerous ways.
The connection record should preserve evidence:
{
"requestHost": "files.example.com",
"resolvedAddress": "203.0.113.18",
"family": 4,
"redirectHop": 1,
"policyVersion": "url-fetch-v3",
"outcome": "allowed"
}
Never log URL credentials or sensitive query strings.
Regression fixtures
A security gate needs negative fixtures, not only a successful public URL.
| Fixture | Expected result |
|---|---|
| public host, public address | allow |
literal 127.0.0.1
|
deny |
host resolving to 10.0.0.8
|
deny |
| public URL redirecting to link-local | deny at hop 1 |
| mixed public/private DNS answers | deny |
| four redirects with limit three | deny |
| hostname changes but remains public | re-authorize, then allow |
| DNS answer changes before connect | connector must use approved address |
Use a local fake resolver and HTTP server in tests. Do not probe cloud metadata endpoints to prove the policy.
Prevent, detect, recover
| Phase | Control |
|---|---|
| Prevent | egress firewall plus per-hop address authorization |
| Detect | log policy version, hop, hostname hash, selected address class, outcome |
| Recover | cancel fetch, invalidate cached preview, rotate any credential potentially exposed |
Application validation should not be the only barrier. Run URL-fetch workers in a network segment that cannot reach control planes, metadata services, databases, or internal admin interfaces. Then a parser mistake fails against an independent egress boundary.
Limits
The sample intentionally fails closed on IPv6 rather than pretending that three string checks cover IPv6 CIDRs. It also omits proxy behavior, compressed-response limits, content-type validation, response-size budgets, and connector-specific DNS pinning. Those are separate controls, not reasons to weaken destination authorization.
The core test is simple: can any redirect hop or DNS transition make the socket reach an address that the policy did not explicitly approve? If yes, the URL validator is advisory—not a security boundary.
Top comments (0)