Skip to main content

Module net

Module net 

Source
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:

  1. Scheme allow-list — only http / https. No file:, gopher:, …
  2. 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.
  3. 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.
  4. 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/https scheme AND every resolved IP passes the SSRF allow-list. Returns Ok(()) for a public target, Err for 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_get without the feed-privacy layer: scheme + IP allow-list, connect-pinning, and per-hop re-validation, but no classify_feed_privacy check.
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 trusts Content-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 only http/https; anything else (notably javascript: / data: — stored-XSS vectors that survive HTML escaping) yields None so the caller drops the link.