DEV Community

jaryn
jaryn

Posted on

Block SSRF Redirect Chains Before Your URL Fetcher Reaches Private Networks

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
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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)