An RSS Reader Is an SSRF Machine

An RSS Reader Is an SSRF Machine

Strip away the product language and emit is a machine that accepts URLs from strangers and fetches them from inside my infrastructure, on a schedule, forever. That’s the feature. It’s also the textbook setup for server-side request forgery, and every feed reader, link previewer, webhook tester, and “paste your URL here” tool ever built shares the same anatomy.

This post is about the guardrails on emit’s fetcher, mostly because I keep meeting SSRF in other people’s side projects, unhandled, and the fix is a page of code once you know what it has to cover.

What the attacker actually wants

The point of SSRF is that your server sits somewhere the attacker doesn’t: inside the VPC, behind the firewall, wearing the cloud instance’s identity. When they register http://169.254.169.254/latest/meta-data/ as their “RSS feed,” they’re asking your fetcher to visit the cloud metadata service and hand back instance credentials wrapped in a parse error. Other classics: localhost for whatever admin surface listens on loopback, 10.x addresses to poke around the internal network, a link-local address to reach the neighbors.

Your fetcher doesn’t need to display the response to be dangerous. Error messages, response timing, and “we found 0 items in this feed” versus “this URL didn’t parse as XML” all leak whether something answered. The bar isn’t “don’t show the content,” it’s “don’t make the request.”

The guardrails

Emit’s fetch path runs every outbound URL through a guard before any request happens. The layers, in order of importance:

Resolve first, then judge the IP. The naive check is a denylist of hostname strings, and it’s worthless, because localhost has infinite aliases. DNS is attacker-controlled: their domain can resolve to 127.0.0.1 or an internal address while looking perfectly civilian. So the guard does the DNS resolution itself and validates every returned address against the IP ranges that matter: loopback, RFC 1918 private space, link-local (which covers the metadata IP), and their IPv6 equivalents. Judge addresses, never names.

Refuse redirects. A URL that passes inspection can respond with a 302 to one that never would have. Following redirects transparently means only the first hop gets vetted. Emit’s fetcher doesn’t auto-follow; a redirect surfaces as a new URL that goes through the same guard from the top. Feeds redirect legitimately all the time (http to https, www to apex), so this can’t just be “block redirects,” it has to be “re-vet every hop.”

Constrain the rest. Schemes are limited to http and https (nobody’s newsletter is served over file://), responses have size caps and timeouts, and the parser treats the fetched bytes as hostile input rather than trusting them because the URL looked nice.

Restrict the port. This one I added later and would now put in from the start: only the default web ports are allowed. A feed served on port 9200 or 6379 is not a feed, it’s someone asking my fetcher to talk to Elasticsearch or Redis on my behalf. Legitimate feeds live on 80 and 443, so the allowlist costs nothing and closes off the entire category of internal service that happens to answer HTTP on a nonstandard port.

One guard, every fetcher

The other thing that changed since I first wrote this is how many places needed it. Feed polling was the obvious fetcher. Then came outbound webhooks, which take a customer-supplied delivery URL. Then a free feed-preview tool on the marketing site, which is a “paste your URL here” box exposed to the entire internet with no account required.

That third one is the one to be nervous about. A preview tool run in the browser is harmless, because the request comes from the visitor’s machine. The moment you move that fetch server-side, for CORS reasons or to add caching, you have published an unauthenticated SSRF endpoint on your infrastructure. It has to route through the same guard as everything else, and the fact that it’s a marketing page rather than a product feature makes it easier to forget, not less dangerous.

So the deliverable is one guard function that every outbound fetch calls, not a guard per fetcher. Same argument as any other cross-cutting check: three implementations means the newest one is the weakest.

The part where I admit the hole

Full honesty requires naming what this doesn’t fix: DNS rebinding. The guard resolves the hostname and approves the addresses, then the HTTP client resolves the same hostname again to connect, and a malicious DNS server with a zero-second TTL can answer differently the second time. Check passes on the good answer, connection goes to the bad one.

The complete fix is to pin the connection to the exact vetted IP, which means reaching into the HTTP client’s connection layer. Emit mitigates rather than eliminates: the vetted-then-connect window is narrow, the fetcher’s network position is deliberately boring, and the residual risk is written down in the code as a known limitation instead of a surprise. I’d rather document a small hole than imply a perfect wall.

The checklist

If your project fetches user-supplied URLs, in any form:

  1. Resolve DNS yourself and validate the IPs, not the hostname.
  2. Block loopback, private ranges, and link-local, in both IP versions.
  3. Don’t auto-follow redirects; re-vet each hop.
  4. Cap scheme, size, and time.
  5. Know that rebinding exists, and either pin connections or document that you don’t.

None of it is exotic. It’s just that “fetch this URL for me” reads as a utility function when you write it, and as an internal network proxy when someone else does.