Skip to content
← all posts

Building a public URL checker without becoming an SSRF proxy

2026-06-11·3 min read·0 views

I built a small tool: you paste a Solana RPC URL, and my server fetches and checks it. Sounds simple — but "my server fetches a user-supplied URL" is the textbook setup for SSRF (Server-Side Request Forgery). Get it wrong and someone can point my server inward: localhost, the cloud metadata endpoint, internal services. Here's how I closed it.

The threat: my server makes the request, not the user's browser

Unlike a fetch from the browser, here my server runs the request. So if a user passes http://169.254.169.254/... (the cloud metadata endpoint) or http://127.0.0.1:9090 (an internal service), my server is the one reaching it — from inside my network. That can leak credentials, hit internal services, scan ports. The user controls the URL; I make the request. Dangerous.

Rule #1: don't trust the URL. Check the resolved IP.

The tempting first move is to block bad hostnames (localhost, 127.0.0.1). That's whack-a-mole — trivially bypassed. The right move: resolve the host to an IP yourself, then check the IP.

// resolve yourself (off the async worker), reject if ANY IP is internal
for ip in resolved_ips {
    if is_blocked_ip(ip) || is_my_own_public_ip(ip) {
        return Err("Blocked: resolves to a private/internal address");
    }
}

is_blocked_ip rejects loopback (127/8, ::1), private ranges (10/8, 172.16/12, 192.168/16), link-local + metadata 169.254.169.254, CGNAT (100.64/10), reserved, unspecified, multicast — plus the IPv6 forms and IPv4-mapped (::ffff:127.0.0.1).

I also block my own box's public IPsis_blocked_ip only covers private ranges, but I don't want the tool reaching back at my server's public address either.

Rule #2: pin the IP (beat DNS rebinding)

Checking the IP isn't enough. There's DNS rebinding: the attacker's domain answers a public IP when I check, then flips to 127.0.0.1 when I actually fetch — a fraction of a second apart.

The fix: pin the IP I already validated for the request. Don't resolve again.

let client = reqwest::Client::builder()
    .resolve(&host, pinned_ip)          // use the IP I checked, not a fresh lookup
    .redirect(redirect::Policy::none()) // no redirecting me to something internal
    .no_proxy()
    .timeout(Duration::from_secs(5))
    .build()?;

Redirects off (so it can't bounce me to an internal address), proxy off, hard timeout.

Rule #3: cap everything that can go wrong

  • https only, no credentials in the URL, length-capped — cheap rejects before touching the network.
  • Fixed JSON-RPC methods — I only ever send getHealth / getVersion / getSlot, never a user-supplied method.
  • Response read capped at 64KB + a bounded JSON parse (depth/size/node limits) — a malicious endpoint can't blow my memory or stack.

The fun part: I attacked it myself

Defenses are easy to claim, hard to prove. So I tried the bypasses one by one:

AttackResult
2130706433 (decimal 127.0.0.1)🛡️ blocked
0x7f000001 (hex)🛡️ blocked
0177.0.0.1 (octal)🛡️ blocked
127.1 (short form)🛡️ blocked
127.0.0.1.nip.io (public DNS → loopback)🛡️ blocked
169.254.169.254 (cloud metadata)🛡️ blocked
IPv4-mapped IPv6 (::ffff:127.0.0.1)🛡️ blocked

Why did they all bounce? Because I resolve to an IP first, then check the IP. Encodings (decimal/hex/octal) and DNS tricks (nip.io) all collapse into a real IP before the check — so none of them can sneak past as a string.

Takeaway

If you build anything where your server fetches a user-supplied URL:

  1. Don't validate the URL string (blocking hostnames is a losing race). Resolve to an IP and check the IP.
  2. Pin the checked IP for the request — beat DNS rebinding.
  3. Fail closed — when in doubt, block.
  4. Kill redirects + proxy, set a timeout, cap the response, fix the method.

The tool is live at /tools/rpc-check — the guard leans on a couple of my reliakit crates (bulkhead to cap concurrent fetches, json for the bounded parse). Go ahead, paste http://127.0.0.1 into it. 😏