Building a public URL checker without becoming an SSRF proxy
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
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 IPs — is_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 = builder
.resolve // use the IP I checked, not a fresh lookup
.redirect // no redirecting me to something internal
.no_proxy
.timeout
.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:
| Attack | Result |
|---|---|
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:
- Don't validate the URL string (blocking hostnames is a losing race). Resolve to an IP and check the IP.
- Pin the checked IP for the request — beat DNS rebinding.
- Fail closed — when in doubt, block.
- 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. 😏