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 one no public feed can live at: loopback,
link-local, private, ULA, multicast, unspecified, broadcast, and the
reserved, shared and documentation ranges — including any of those
embedded in an IPv6 address.
is_forbidden_iphas the complete list. - 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.
Structs§
- Body
TooLarge - A response body refused by
read_cappedfor exceeding its cap.
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. - guarded_
get_ no_ redirect - Like
guarded_get_no_privacybut refuses redirects outright. - guarded_
post_ form - POST a form-encoded body to a user-influenced URL through the SSRF guard.
- guarded_
post_ json - POST a JSON body to a user-influenced URL through the SSRF guard.
- is_
forbidden_ ip - Whether an already-resolved IP address is one we must never connect to on behalf of an untrusted URL (SSRF sinks).
- 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.