DEV Community

Callie Schneider
Callie Schneider

Posted on • Originally published at abundanceapis.com

Your CORS proxy is an SSRF engine: how to harden a URL fetcher

I build and run Abundance APIs, a catalog of small utility APIs. This is a writeup of the security design behind the CORS proxy — the part I'd want reviewed. Disclosure: the proxy in the examples is mine; free tier, no signup.

A CORS proxy sounds trivial: take a url param, fetch it server-side, return the body with Access-Control-Allow-Origin: *. In reality a naive version is a gift to attackers — it's a Server-Side Request Forgery (SSRF) engine running on your infrastructure, and it will happily fetch http://169.254.169.254/latest/meta-data/ (cloud credentials) or http://10.0.0.5/internal-admin on your behalf.

Here's how the proxy is actually hardened. None of these steps are optional.

1. Scheme + host allow-listing

Only http/https. Reject everything else outright — file://, gopher://, ftp://, data: are all SSRF vectors or worse. Then resolve the hostname and reject the request if it points anywhere private.

2. Block every private/reserved IP range

The obvious one is 127.0.0.1, but the real list is long:

  • Loopback (127.0.0.0/8, ::1)
  • Link-local incl. the cloud metadata address 169.254.169.254
  • RFC1918 (10/8, 172.16/12, 192.168/16)
  • CGNAT (100.64/10)
  • Multicast, IPv6 ULA (fc00::/7), IPv6 link-local (fe80::/10)
  • IPv4-mapped IPv6 (::ffff:10.0.0.1) — easy to forget, trivially bypasses a naive IPv4-only check

3. Defeat DNS rebinding (the step most proxies skip)

Here's the subtle attack. You validate evil.com → resolves to 1.2.3.4 (public, looks fine) → then you call fetch(), which resolves the hostname again, and this time it returns 169.254.169.254. This is a TOCTOU (time-of-check-to-time-of-use) gap: the DNS answer changed between your check and the actual connection.

The fix is to resolve once, validate that specific IP, then pin the connection to it so the fetch can't re-resolve to something else. With Node's undici you do this with a custom lookup:

import { Agent } from "undici";
import dns from "node:dns/promises";

async function resolvePublicIp(hostname) {
  const { address } = (await dns.lookup(hostname))[0] ?? {};
  if (!address || isPrivateIp(address)) {
    throw new Error("Blocked: resolves to a private or reserved IP");
  }
  return address;
}

// Pin the validated IP so the actual socket can't re-resolve elsewhere.
const agent = new Agent({
  connect: {
    lookup: (hostname, opts, cb) => {
      resolvePublicIp(hostname)
        .then((ip) => cb(null, ip, opts.family === 6 ? 6 : 4))
        .catch(cb);
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

4. Re-validate every redirect hop

https://evil.com/start returns 302 → http://169.254.169.254/. If you follow redirects automatically, the library will happily chase it — straight past your original check. So: follow redirects manually, and run the full IP validation again on every Location before each hop.

let url = startUrl, hops = 0;
while (hops++ < MAX_REDIRECTS) {
  await resolvePublicIp(new URL(url).hostname); // re-check each hop
  const res = await fetch(url, { dispatcher: agent, redirect: "manual" });
  if (res.status >= 300 && res.status < 400 && res.headers.get("location")) {
    url = new URL(res.headers.get("location"), url).toString();
    continue;
  }
  return res;
}
Enter fullscreen mode Exit fullscreen mode

5. Bound everything

An open fetcher is also a DoS amplifier and a memory bomb. Cap it:

  • Timeout on the upstream request (e.g. 15s)
  • Max response size — stream and abort past the cap (e.g. 10 MB), don't buffer unbounded
  • Max redirects (e.g. 5)

Testing it

The bugs here are the ones that pass a happy-path test and fail in the wild. I verify against: loopback, each RFC1918 range, the cloud metadata IP, IPv4-mapped IPv6, and — the important one — a real hostname that resolves to loopback (localtest.me resolves to 127.0.0.1), which is exactly what catches a validator that checks the string instead of the resolved IP.

Takeaway

If you're building anything that fetches a user-supplied URL server-side — a proxy, a webhook tester, a link-preview/OG scraper, an avatar-uploader-by-URL — you're building an SSRF surface whether you meant to or not. Resolve once, validate the IP, pin the connection, re-check every redirect, and bound the whole thing.

I run this as a hosted CORS proxy + link-metadata/header-inspector API if you'd rather not maintain it yourself — abundanceapis.com, with a free in-browser tester (no signup). But mostly: if you've got a URL-fetching endpoint in prod, go check how it handles 169.254.169.254 right now.

Top comments (0)