Security

SSRF IP obfuscation: what gets past a CORS proxy

Any proxy that fetches a URL you give it is an SSRF surface: the usual defense is a filter that refuses literal private, loopback, and link-local addresses before the fetch happens. Security writeups usually point out that an attacker can write 127.0.0.1 a dozen other ways — as a plain integer, in hex, in octal, inside an IPv6 wrapper — hoping one of them slips past a filter that only recognizes dotted decimal. We ran each of those forms through the URL parser corsproxy.dev's filter actually sees, to find out which ones still look like 127.0.0.1 by the time the filter runs.

· ~7 min read

Why a CORS proxy has to worry about this at all

A CORS proxy exists to fetch a URL on your behalf and hand the response back with CORS headers attached. That's also the exact shape of an SSRF vulnerability: a server making outbound requests to an address someone else chose. We've covered the general threat model and the standard mitigation list elsewhere — see the SSRF glossary entry and how a CORS proxy works, and how it can be abused. This post is narrower: it's about one specific class of bypass attempt against the first layer of that defense, the check on a literal IP address typed directly into the url= parameter, before any DNS lookup happens.

The filter, and what it's supposed to block

corsproxy.dev refuses to proxy to a literal address in the usual reserved ranges: 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8 (loopback), 169.254.0.0/16 (link-local, including the 169.254.169.254 cloud metadata address), 172.16.0.0/12, 192.168.0.0/16, and 100.64.0.0/10 (carrier-grade NAT). It also rejects the hostname localhost and anything ending in .localhost, and the IPv6 equivalents: ::, ::1, unique-local addresses (fc00::/7), link-local addresses (fe80::/10), and an IPv4-mapped IPv6 address whose embedded IPv4 part falls in any of the ranges above. That matches the standard SSRF mitigation list: see the glossary entry for the full checklist and why each range matters.

All of that runs on the hostname as the request actually parses it, which is the detail the rest of this post is about.

The obfuscation tricks that are supposed to beat a filter like this

127.0.0.1 is one IP address with several string representations, and SSRF testing guides catalog them as ways to slip past a filter that only pattern-matches dotted-decimal octets. OWASP's Web Security Testing Guide lists several for the loopback address alone: the plain integer 2130706433, the octal form 017700000001, and the shortened two-part form 127.1 (see its SSRF testing guide). PortSwigger's URL validation bypass cheat sheet adds hex (0x7f000001) and IPv6-mapped forms (::ffff:127.0.0.1) to the same list. The idea behind all of them is the same: if the filter is a regular expression written against the raw string, anything that isn't four dot-separated decimal numbers walks straight through it.

What we actually ran, and what came back

The filter doesn't see the raw query string. It sees the hostname only after the target URL has already been parsed by the standard URL constructor, which every JavaScript runtime (Node, every browser, and the Cloudflare Workers runtime corsproxy.dev runs on) implements according to the WHATWG URL Standard. We ran the obfuscated forms above straight through that constructor in Node to see what hostname actually reaches the filter:

const tests = [
  "http://2130706433/",                 // decimal
  "http://0x7f000001/",                 // hex
  "http://0177.0.0.1/",                 // octal, one octet
  "http://017700000001/",               // octal, single integer
  "http://127.1/",                      // shorthand
  "http://[::ffff:127.0.0.1]/",         // IPv6-mapped, dotted
  "http://[::ffff:7f00:1]/",            // IPv6-mapped, hex
];
for (const t of tests) console.log(t, "->", new URL(t).hostname);
http://2130706433/         -> 127.0.0.1
http://0x7f000001/         -> 127.0.0.1
http://0177.0.0.1/         -> 127.0.0.1
http://017700000001/       -> 127.0.0.1
http://127.1/              -> 127.0.0.1
http://[::ffff:127.0.0.1]/ -> [::ffff:7f00:1]
http://[::ffff:7f00:1]/    -> [::ffff:7f00:1]

Every decimal, hex, octal, and shorthand form of the loopback address comes out of the constructor as plain 127.0.0.1 — the exact dotted-decimal string the filter already recognizes as loopback. The two IPv6-mapped forms both normalize to the same canonical hex notation, ::ffff:7f00:1, which is the form the filter checks for an embedded IPv4 address. None of these ever reach the filter in a shape it would need a special case for; the URL parser has already collapsed the obfuscation before the filter runs at all.

This isn't a corsproxy.dev-specific quirk. It's a property of the URL constructor itself, which is why the same test gives identical results in a browser console, in Node, or against any other code that parses its input with new URL() before inspecting the hostname. A filter has to actively avoid this normalization — by matching against the raw string before parsing it, or by rolling its own URL parsing — to be exposed to these particular tricks.

Where a check like this still has real limits

None of the above means literal-address filtering solves SSRF. It solves the one case where the attacker writes an IP address directly into the URL. Two cases it doesn't touch on its own: a hostname that only resolves to a private address, which needs a separate DNS lookup with its own caveats, and a redirect to a private address, which needs the redirect followed through the same check or not followed at all. We've written about both separately: why CORS proxies don't follow redirects for you covers the second case, and the DNS-resolution side has its own fail-open and timing caveats worth reading before assuming any proxy's SSRF defense is complete.

If you're writing this kind of filter yourself

The practical takeaway isn't "don't worry about IP obfuscation." It's where to put the check. Validate the hostname your platform's URL parser hands back, not the string a user typed before anything has parsed it. Decimal, hex, octal, and shorthand IPv4 all collapse to the same dotted-decimal string the moment something calls new URL() on them, and IPv6-mapped addresses collapse to one canonical hex form — so a filter that runs after parsing only ever has to recognize the canonical forms, not enumerate every way to spell them. A filter that runs against the raw query string instead, or that builds its own ad hoc URL parsing to avoid a dependency, is the one that has to reimplement that normalization by hand, and is exactly where these tricks still work.

Try it

Get a free corsproxy.dev key and the address check above runs on every request before it reaches your target, no extra configuration required.