Skip to main content

webfetch/
fetch.rs

1use reqwest::header::{CONTENT_TYPE, LOCATION};
2use reqwest::{redirect::Policy, Client};
3use std::net::SocketAddr;
4use std::time::{Duration, Instant};
5
6use crate::guard;
7use crate::tls::TlsConfig;
8use webfetch_core::charset;
9use webfetch_core::http::{
10    read_body_capped_bytes, transient_send_error, transient_status, USER_AGENT,
11};
12
13const MAX_ATTEMPTS: u32 = 3;
14const MAX_REDIRECTS: usize = 5;
15
16/// Multiplier turning the per-request `--timeout` into a budget for the whole
17/// fetch.
18///
19/// `--timeout` bounds one request. With retries and redirects a single fetch
20/// could issue `MAX_ATTEMPTS * (MAX_REDIRECTS + 1)` requests, so `--timeout 10`
21/// could keep running for minutes — not what anyone setting a timeout expects.
22/// The whole fetch now shares one deadline, and each request gets whatever is
23/// left of it.
24const TOTAL_BUDGET_MULTIPLIER: u32 = 3;
25
26/// Outcome of an HTTP fetch: the body, the URL we actually landed on after
27/// following redirects, and the response's `Content-Type` (if any).
28#[derive(Debug, Clone)]
29pub struct FetchedPage {
30    pub body: String,
31    pub final_url: String,
32    pub content_type: Option<String>,
33    /// Set when the page declared a charset this build cannot decode, so the
34    /// body was read as UTF-8 and may be garbled.
35    pub undecodable_charset: Option<String>,
36}
37
38/// One hop's result: either the final page, or a redirect to a raw `Location`.
39enum Hop {
40    Page(FetchedPage),
41    Redirect(String),
42}
43
44/// Build a client for a single validated URL. `pinned` are the public IPs the
45/// host already resolved to; binding them closes the DNS-rebinding window
46/// between validation and connection.
47///
48/// Redirects are **not** followed by reqwest here ([`Policy::none`]): we follow
49/// them manually in [`fetch_page`] so every hop is re-validated *and* pinned to
50/// its own resolved addresses. (Reqwest's `resolve_to_addrs` pins only the
51/// hosts known at build time, so auto-follow would leave redirect hops
52/// unpinned.) A consequence is that connection pooling cannot be shared across
53/// hosts via one long-lived client without weakening per-URL IP pinning, so we
54/// deliberately do not cache clients — SSRF safety wins over pool reuse.
55///
56/// Note that IP pinning only takes effect on a direct connection: when
57/// `HTTP(S)_PROXY` is set, the proxy resolves the host itself and the pinned
58/// addresses are never used. See `docs/product.md`.
59fn build_client(
60    url: &reqwest::Url,
61    timeout: Duration,
62    pinned: &[SocketAddr],
63    tls: &TlsConfig,
64) -> anyhow::Result<Client> {
65    let mut builder = Client::builder()
66        .timeout(timeout)
67        .redirect(Policy::none())
68        .user_agent(USER_AGENT)
69        .gzip(true)
70        .brotli(true);
71
72    // Trust the OS store (+ SSL_CERT_FILE / --ca-cert) so org/proxy root CAs
73    // are accepted, instead of only the bundled webpki roots.
74    builder = tls.apply(builder)?;
75
76    if let Some(host) = url.host_str() {
77        if !pinned.is_empty() {
78            builder = builder.resolve_to_addrs(host, pinned);
79        }
80    }
81    Ok(builder.build()?)
82}
83
84/// One request attempt. The bool in the error reports whether the failure is
85/// transient (worth retrying): connection/timeout errors, 5xx, and 429.
86async fn attempt(client: &Client, url: &str) -> Result<Hop, (anyhow::Error, bool)> {
87    let resp = match client
88        .get(url)
89        .header("Accept", "text/html,application/xhtml+xml,*/*;q=0.8")
90        .header("Accept-Language", "en-US,en;q=0.9")
91        .send()
92        .await
93    {
94        Ok(r) => r,
95        Err(e) => {
96            let transient = transient_send_error(&e);
97            return Err((e.into(), transient));
98        }
99    };
100
101    let status = resp.status();
102
103    // Redirects are surfaced to the caller (which re-validates and pins the
104    // target) rather than followed by reqwest.
105    if status.is_redirection() {
106        return match resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()) {
107            Some(loc) => Ok(Hop::Redirect(loc.to_string())),
108            None => Err((
109                anyhow::anyhow!("redirect ({status}) without a Location header"),
110                false,
111            )),
112        };
113    }
114
115    let resp = match resp.error_for_status() {
116        Ok(r) => r,
117        Err(e) => {
118            let transient = transient_status(status);
119            return Err((e.into(), transient));
120        }
121    };
122
123    let final_url = resp.url().to_string();
124    let content_type = resp
125        .headers()
126        .get(CONTENT_TYPE)
127        .and_then(|v| v.to_str().ok())
128        .map(|s| s.to_string());
129
130    // Decode with the response's declared charset rather than assuming UTF-8:
131    // a windows-1252 / ISO-8859-1 page is otherwise returned full of
132    // replacement characters.
133    let raw = read_body_capped_bytes(resp).await?;
134    let declared = content_type
135        .as_deref()
136        .and_then(charset::from_content_type)
137        .or_else(|| charset::sniff_meta(&raw));
138    let (body, undecodable_charset) = charset::decode(&raw, declared.as_deref());
139
140    Ok(Hop::Page(FetchedPage {
141        body,
142        final_url,
143        content_type,
144        undecodable_charset,
145    }))
146}
147
148/// Issue one hop's request, retrying transient failures with exponential
149/// backoff (200ms, 400ms) while the overall deadline allows.
150async fn fetch_with_retries(client: &Client, url: &str, deadline: Instant) -> anyhow::Result<Hop> {
151    let mut delay = Duration::from_millis(200);
152    for attempt_no in 1..=MAX_ATTEMPTS {
153        match attempt(client, url).await {
154            Ok(hop) => return Ok(hop),
155            Err((err, transient)) => {
156                if attempt_no == MAX_ATTEMPTS || !transient {
157                    return Err(err);
158                }
159                if Instant::now() + delay >= deadline {
160                    return Err(err);
161                }
162                tokio::time::sleep(delay).await;
163                delay *= 2;
164            }
165        }
166    }
167    unreachable!("loop returns on the final attempt")
168}
169
170/// Fetch a URL, following redirects manually so the SSRF guard re-validates and
171/// re-pins each hop (closing the DNS-rebinding window for redirected hosts too),
172/// retrying transient failures with exponential backoff. Caps the redirect
173/// chain at [`MAX_REDIRECTS`], the body at
174/// [`webfetch_core::http::MAX_BODY_BYTES`], and the whole operation at
175/// [`TOTAL_BUDGET_MULTIPLIER`] times `timeout_secs`.
176pub async fn fetch_page(
177    url: &str,
178    timeout_secs: u64,
179    tls: &TlsConfig,
180) -> anyhow::Result<FetchedPage> {
181    let per_request = Duration::from_secs(timeout_secs);
182    let deadline = Instant::now() + per_request * TOTAL_BUDGET_MULTIPLIER;
183
184    let mut current = reqwest::Url::parse(url)?;
185    let mut hops = 0usize;
186
187    loop {
188        let remaining = deadline.saturating_duration_since(Instant::now());
189        if remaining.is_zero() {
190            anyhow::bail!(
191                "fetch exceeded its total budget ({}s across redirects and retries)",
192                timeout_secs * TOTAL_BUDGET_MULTIPLIER as u64
193            );
194        }
195
196        // Validate + resolve the host for THIS hop, then pin the connection to
197        // exactly those addresses.
198        let pinned = guard::validate_url(&current).await?;
199        let client = build_client(&current, per_request.min(remaining), &pinned, tls)?;
200
201        match fetch_with_retries(&client, current.as_str(), deadline).await? {
202            Hop::Page(page) => return Ok(page),
203            Hop::Redirect(location) => {
204                hops += 1;
205                if hops > MAX_REDIRECTS {
206                    anyhow::bail!("too many redirects (>{MAX_REDIRECTS})");
207                }
208                current = current
209                    .join(&location)
210                    .map_err(|e| anyhow::anyhow!("invalid redirect target `{location}`: {e}"))?;
211            }
212        }
213    }
214}