Expand description
Hardened outbound HTTP for untrusted, user-supplied feed URLs.
A feed reader fetches arbitrary URLs on behalf of its users: the add-feed
flow, the background poller, and OPML import all hand a user-controlled
host to reqwest. Left unguarded that is a textbook SSRF primitive — a
subscribed feed can 302 to http://169.254.169.254/ (cloud metadata) or
http://127.0.0.1:<port>/ (an internal service), and because the body is
reflected back into the reader UI the exfiltration is non-blind.
This module centralises the defence so every fetch path shares one guard:
- Scheme allow-list — only
http/https. Nofile:,gopher:, … - IP allow-list — the target host is resolved to IP(s) and rejected if
any resolved address is loopback, link-local (
169.254.0.0/16,fe80::/10), private (10/8,172.16/12,192.168/16), ULA (fc00::/7), multicast, unspecified, or broadcast. - Per-hop re-validation — auto-redirect is disabled and redirects are followed manually, re-running (1) and (2) on every hop, so a benign first host cannot bounce us onto an internal one.
- Capped streaming body — the response body is streamed and aborted the
moment it exceeds
MAX_BODY_BYTES, so a gzip decompression bomb cannot materialise gigabytes before a post-hoc size check (a Content-Length guard is useless once gzip strips the header).
Resolution happens immediately before each request. The vetted IP is then
pinned onto the connection (reqwest .resolve(host, addr)), so connect
reuses the exact address that passed is_forbidden_ip rather than doing an
independent second DNS lookup. That closes the DNS-rebinding TOCTOU window: an
attacker-controlled resolver cannot answer “public IP” for the check and
“127.0.0.1” for the connect, because there is no second resolution.
Constants§
- MAX_
BODY_ BYTES - Cap on how many bytes we will read from any body, streamed. 8 MiB is comfortably above any sane feed; a body that exceeds it is aborted mid-stream (never fully buffered), which is what defeats a gzip decompression bomb.
Functions§
- assert_
public_ target - Validate that a URL is safe to use as an outbound target:
http/httpsscheme AND every resolved IP passes the SSRF allow-list. ReturnsOk(())for a public target,Errfor a forbidden one (loopback / link-local / private / ULA / CGNAT / metadata) or a bad scheme. - guarded_
get - Fetch a user-supplied URL through the full SSRF guard: scheme + IP checks on the initial URL and on every redirect hop, following redirects manually.
- guarded_
get_ no_ privacy - The SSRF core of
guarded_getwithout the feed-privacy layer: scheme + IP allow-list, connect-pinning, and per-hop re-validation, but noclassify_feed_privacycheck. - is_
forbidden_ ip - Whether an already-resolved IP address is one we must never connect to on behalf of an untrusted URL (SSRF sinks): loopback, link-local, private, ULA, multicast, unspecified, or broadcast.
- read_
capped - Read a response body, streaming chunk-by-chunk and aborting the moment
the accumulated size would exceed
MAX_BODY_BYTES. Never trustsContent-Length(gzip strips it) and never fully buffers an over-cap body — this is the decompression-bomb / OOM guard. - safe_
link - Scheme-allow-list a URL destined to be rendered as an
href(an entry’s “View original” link, a feed’s site link). Accepts onlyhttp/https; anything else (notablyjavascript:/data:— stored-XSS vectors that survive HTML escaping) yieldsNoneso the caller drops the link.