Skip to main content

kcode_web_fetch/
lib.rs

1//! Bounded public-web fetching with SSRF protection and text extraction.
2
3#![deny(missing_docs)]
4#![forbid(unsafe_code)]
5
6use std::{
7    error::Error as StdError,
8    fmt,
9    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
10    time::{Duration, SystemTime},
11};
12
13use reqwest::{Client, StatusCode, Url, header};
14
15/// Stable web-fetch failure classification.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum ErrorKind {
18    /// The URL or limits were invalid.
19    InvalidInput,
20    /// The URL did not resolve exclusively to public web addresses.
21    UnsafeDestination,
22    /// The request exceeded its configured timeout.
23    Timeout,
24    /// The destination could not be reached or returned malformed transport data.
25    Transport,
26    /// The destination returned a non-success HTTP status.
27    HttpStatus,
28    /// The destination returned a non-text content type.
29    UnsupportedContent,
30    /// The page contained no readable text.
31    EmptyContent,
32}
33
34/// A sanitized URL, transport, HTTP, or content failure.
35#[derive(Debug)]
36pub struct Error {
37    kind: ErrorKind,
38    message: String,
39    status: Option<u16>,
40}
41
42impl Error {
43    fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
44        Self {
45            kind,
46            message: message.into(),
47            status: None,
48        }
49    }
50
51    fn with_status(mut self, status: StatusCode) -> Self {
52        self.status = Some(status.as_u16());
53        self
54    }
55
56    /// Returns the stable failure classification.
57    pub const fn kind(&self) -> ErrorKind {
58        self.kind
59    }
60
61    /// Returns the destination HTTP status, when one caused the failure.
62    pub const fn status(&self) -> Option<u16> {
63        self.status
64    }
65
66    /// Returns the sanitized failure detail.
67    pub fn message(&self) -> &str {
68        &self.message
69    }
70}
71
72impl fmt::Display for Error {
73    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74        formatter.write_str(&self.message)
75    }
76}
77
78impl StdError for Error {}
79
80/// Result type returned by web-fetch operations.
81pub type Result<T> = std::result::Result<T, Error>;
82
83/// Resource and redirect limits applied to every fetch.
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct FetchLimits {
86    /// Timeout applied to each HTTP request.
87    pub request_timeout: Duration,
88    /// Maximum downloaded response bytes.
89    pub max_bytes: usize,
90    /// Maximum returned Unicode characters.
91    pub max_characters: usize,
92    /// Maximum manually validated redirects.
93    pub max_redirects: usize,
94}
95
96impl Default for FetchLimits {
97    fn default() -> Self {
98        Self {
99            request_timeout: Duration::from_secs(90),
100            max_bytes: 2_000_000,
101            max_characters: 50_000,
102            max_redirects: 5,
103        }
104    }
105}
106
107/// A fetched page converted into readable text.
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct FetchedPage {
110    /// Final URL after validated redirects.
111    pub url: String,
112    /// HTML title, when one was present.
113    pub title: Option<String>,
114    /// Normalized response media type without parameters.
115    pub content_type: String,
116    /// Readable text content.
117    pub content: String,
118    /// Whether byte or character limits truncated the page.
119    pub truncated: bool,
120    /// Local time at which retrieval completed.
121    pub retrieved_at: SystemTime,
122}
123
124/// Cloneable public-web fetcher.
125#[derive(Clone, Debug)]
126pub struct WebFetcher {
127    limits: FetchLimits,
128}
129
130impl WebFetcher {
131    /// Constructs a fetcher after validating nonzero limits.
132    pub fn new(limits: FetchLimits) -> Result<Self> {
133        if limits.request_timeout.is_zero() || limits.max_bytes == 0 || limits.max_characters == 0 {
134            return Err(Error::new(
135                ErrorKind::InvalidInput,
136                "web-fetch timeout and content limits must be greater than zero",
137            ));
138        }
139        Ok(Self { limits })
140    }
141
142    /// Fetches one public HTTP(S) URL and returns bounded readable text.
143    pub async fn fetch(&self, value: &str) -> Result<FetchedPage> {
144        let requested = parse_public_web_url(value.trim())?;
145        let fetched = fetch_page(&requested, &self.limits).await?;
146        let raw = String::from_utf8_lossy(&fetched.body);
147        let title = is_html_content(&fetched.content_type)
148            .then(|| extract_html_title(&raw))
149            .flatten();
150        let readable = if is_html_content(&fetched.content_type) {
151            html2text::from_read(raw.as_bytes(), 100).map_err(|_| {
152                Error::new(
153                    ErrorKind::Transport,
154                    "the page could not be converted to readable text",
155                )
156            })?
157        } else {
158            raw.into_owned()
159        };
160        let (content, character_truncated) =
161            truncate_characters(readable.trim(), self.limits.max_characters);
162        if content.is_empty() {
163            return Err(Error::new(
164                ErrorKind::EmptyContent,
165                "the page contained no readable text",
166            ));
167        }
168        Ok(FetchedPage {
169            url: fetched.url.to_string(),
170            title,
171            content_type: fetched.content_type,
172            content,
173            truncated: fetched.truncated || character_truncated,
174            retrieved_at: SystemTime::now(),
175        })
176    }
177}
178
179impl Default for WebFetcher {
180    fn default() -> Self {
181        Self::new(FetchLimits::default()).expect("default web-fetch limits are valid")
182    }
183}
184
185struct RawPage {
186    url: Url,
187    content_type: String,
188    body: Vec<u8>,
189    truncated: bool,
190}
191
192async fn fetch_page(url: &Url, limits: &FetchLimits) -> Result<RawPage> {
193    let mut current = url.clone();
194    for redirect_count in 0..=limits.max_redirects {
195        let client = safe_client(&current, limits.request_timeout).await?;
196        let mut response = client
197            .get(current.clone())
198            .header(
199                header::ACCEPT,
200                "text/html,application/xhtml+xml,text/plain,application/json;q=0.8",
201            )
202            .send()
203            .await
204            .map_err(transport_error)?;
205        if response.status().is_redirection() {
206            if redirect_count == limits.max_redirects {
207                return Err(Error::new(
208                    ErrorKind::Transport,
209                    "the page exceeded the redirect limit",
210                ));
211            }
212            let location = response
213                .headers()
214                .get(header::LOCATION)
215                .and_then(|value| value.to_str().ok())
216                .ok_or_else(|| {
217                    Error::new(
218                        ErrorKind::Transport,
219                        "the page returned an invalid redirect",
220                    )
221                })?;
222            current = parse_public_web_url(
223                current
224                    .join(location)
225                    .map_err(|_| {
226                        Error::new(
227                            ErrorKind::Transport,
228                            "the page returned an invalid redirect URL",
229                        )
230                    })?
231                    .as_str(),
232            )?;
233            continue;
234        }
235        if !response.status().is_success() {
236            let status = response.status();
237            return Err(Error::new(
238                ErrorKind::HttpStatus,
239                format!("the page returned HTTP {status}"),
240            )
241            .with_status(status));
242        }
243        let content_type = response
244            .headers()
245            .get(header::CONTENT_TYPE)
246            .and_then(|value| value.to_str().ok())
247            .unwrap_or("application/octet-stream")
248            .split(';')
249            .next()
250            .unwrap_or("application/octet-stream")
251            .trim()
252            .to_ascii_lowercase();
253        if !is_supported_text_content(&content_type) {
254            return Err(Error::new(
255                ErrorKind::UnsupportedContent,
256                format!("the page returned unsupported content type {content_type}"),
257            ));
258        }
259        let mut body = Vec::new();
260        let mut truncated = false;
261        while let Some(chunk) = response.chunk().await.map_err(transport_error)? {
262            let remaining = limits.max_bytes.saturating_sub(body.len());
263            if chunk.len() > remaining {
264                body.extend_from_slice(&chunk[..remaining]);
265                truncated = true;
266                break;
267            }
268            body.extend_from_slice(&chunk);
269            if body.len() == limits.max_bytes {
270                truncated = true;
271                break;
272            }
273        }
274        return Ok(RawPage {
275            url: current,
276            content_type,
277            body,
278            truncated,
279        });
280    }
281    unreachable!("redirect loop returns or continues within its bound")
282}
283
284fn transport_error(error: reqwest::Error) -> Error {
285    if error.is_timeout() {
286        Error::new(ErrorKind::Timeout, "the page fetch timed out")
287    } else {
288        Error::new(ErrorKind::Transport, "the page could not be fetched")
289    }
290}
291
292fn parse_public_web_url(value: &str) -> Result<Url> {
293    if value.is_empty() || value.len() > 4_096 {
294        return Err(Error::new(
295            ErrorKind::InvalidInput,
296            "URL must contain between 1 and 4096 bytes",
297        ));
298    }
299    let url = Url::parse(value)
300        .map_err(|_| Error::new(ErrorKind::InvalidInput, "URL must be absolute"))?;
301    if !matches!(url.scheme(), "http" | "https") {
302        return Err(Error::new(
303            ErrorKind::InvalidInput,
304            "URL must use HTTP or HTTPS",
305        ));
306    }
307    if !url.username().is_empty() || url.password().is_some() {
308        return Err(Error::new(
309            ErrorKind::InvalidInput,
310            "URL must not contain credentials",
311        ));
312    }
313    let host = url
314        .host_str()
315        .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "URL must contain a host"))?;
316    let lookup_host = host.trim_start_matches('[').trim_end_matches(']');
317    let normalized = lookup_host.trim_end_matches('.').to_ascii_lowercase();
318    if normalized == "localhost" || normalized.ends_with(".localhost") {
319        return Err(unsafe_destination());
320    }
321    if !matches!(url.port_or_known_default(), Some(80 | 443)) {
322        return Err(Error::new(
323            ErrorKind::InvalidInput,
324            "URL must use standard HTTP or HTTPS ports",
325        ));
326    }
327    if lookup_host
328        .parse::<IpAddr>()
329        .is_ok_and(|address| !is_public_ip(address))
330    {
331        return Err(unsafe_destination());
332    }
333    Ok(url)
334}
335
336fn unsafe_destination() -> Error {
337    Error::new(
338        ErrorKind::UnsafeDestination,
339        "URL does not refer to a public web destination",
340    )
341}
342
343async fn safe_client(url: &Url, timeout: Duration) -> Result<Client> {
344    let host = url
345        .host_str()
346        .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "URL must contain a host"))?;
347    let lookup_name = host.trim_start_matches('[').trim_end_matches(']');
348    let port = url
349        .port_or_known_default()
350        .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "URL must contain a valid port"))?;
351    let addresses = tokio::net::lookup_host((lookup_name, port))
352        .await
353        .map_err(|_| Error::new(ErrorKind::Transport, "the page host could not be resolved"))?
354        .collect::<Vec<SocketAddr>>();
355    if addresses.is_empty() || addresses.iter().any(|address| !is_public_ip(address.ip())) {
356        return Err(unsafe_destination());
357    }
358    Client::builder()
359        .timeout(timeout)
360        .redirect(reqwest::redirect::Policy::none())
361        .retry(reqwest::retry::never())
362        .referer(false)
363        .no_proxy()
364        .user_agent(concat!("kcode-web-fetch/", env!("CARGO_PKG_VERSION")))
365        .resolve_to_addrs(lookup_name, &addresses)
366        .build()
367        .map_err(|_| {
368            Error::new(
369                ErrorKind::Transport,
370                "the safe page-fetch client could not be created",
371            )
372        })
373}
374
375fn is_public_ip(address: IpAddr) -> bool {
376    match address {
377        IpAddr::V4(address) => is_public_ipv4(address),
378        IpAddr::V6(address) => is_public_ipv6(address),
379    }
380}
381
382fn is_public_ipv4(address: Ipv4Addr) -> bool {
383    let [a, b, c, _] = address.octets();
384    !(a == 0
385        || a == 10
386        || a == 127
387        || (a == 100 && (64..=127).contains(&b))
388        || (a == 169 && b == 254)
389        || (a == 172 && (16..=31).contains(&b))
390        || (a == 192 && b == 0 && c == 0)
391        || (a == 192 && b == 0 && c == 2)
392        || (a == 192 && b == 168)
393        || (a == 198 && (b == 18 || b == 19))
394        || (a == 198 && b == 51 && c == 100)
395        || (a == 203 && b == 0 && c == 113)
396        || a >= 224)
397}
398
399fn is_public_ipv6(address: Ipv6Addr) -> bool {
400    if let Some(mapped) = address.to_ipv4_mapped() {
401        return is_public_ipv4(mapped);
402    }
403    let segments = address.segments();
404    !(address.is_unspecified()
405        || address.is_loopback()
406        || address.is_multicast()
407        || segments[0] & 0xfe00 == 0xfc00
408        || segments[0] & 0xffc0 == 0xfe80
409        || (segments[0] == 0x2001 && segments[1] == 0x0db8))
410}
411
412fn is_html_content(content_type: &str) -> bool {
413    matches!(content_type, "text/html" | "application/xhtml+xml")
414}
415
416fn is_supported_text_content(content_type: &str) -> bool {
417    is_html_content(content_type)
418        || content_type.starts_with("text/")
419        || content_type == "application/json"
420}
421
422fn extract_html_title(html: &str) -> Option<String> {
423    let lowercase = html.to_ascii_lowercase();
424    let start = lowercase.find("<title")?;
425    let content_start = lowercase[start..].find('>')? + start + 1;
426    let end = lowercase[content_start..].find("</title>")? + content_start;
427    let title = html[content_start..end]
428        .split_whitespace()
429        .collect::<Vec<_>>()
430        .join(" ");
431    (!title.is_empty()).then_some(title)
432}
433
434fn truncate_characters(value: &str, limit: usize) -> (String, bool) {
435    let mut iter = value.char_indices();
436    let Some((boundary, _)) = iter.nth(limit) else {
437        return (value.to_owned(), false);
438    };
439    (value[..boundary].trim_end().to_owned(), true)
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn private_and_credentialed_urls_are_rejected() {
448        for value in [
449            "http://127.0.0.1/",
450            "http://[::1]/",
451            "http://localhost/",
452            "https://user:secret@example.com/",
453            "file:///etc/passwd",
454            "https://example.com:8443/",
455        ] {
456            assert!(parse_public_web_url(value).is_err(), "accepted {value}");
457        }
458        assert!(parse_public_web_url("https://example.com/path").is_ok());
459    }
460
461    #[test]
462    fn readable_helpers_are_bounded() {
463        assert_eq!(
464            extract_html_title("<TITLE>  Example page </TITLE>"),
465            Some("Example page".into())
466        );
467        assert_eq!(truncate_characters("éclair", 2), ("éc".into(), true));
468        assert_eq!(truncate_characters("short", 20), ("short".into(), false));
469    }
470}