//! Link preview, server-side OpenGraph metadata fetch for post URLs. use pulldown_cmark::{Event, Parser, Tag}; use reqwest::header::CONTENT_TYPE; /// Maximum number of URLs to extract per post. const MAX_URLS: usize = 3; /// Maximum response body size to read (1 MB). const MAX_BODY_SIZE: usize = 1_048_576; /// Check if an IP address is private/reserved (not safe for SSRF). fn is_private_ip(ip: std::net::IpAddr) -> bool { match ip { std::net::IpAddr::V4(v4) => { let o = v4.octets(); v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_broadcast() || v4.is_unspecified() || o[0] == 0 // 0.0.0.0/8 "this network" || o[0] == 100 && (o[1] & 0xC0) == 64 // 100.64.0.0/10 (CGNAT / Tailscale) || o[0] == 192 && o[1] == 0 && o[2] == 0 // 192.0.0.0/24 IETF protocol assignments || o[0] == 198 && (o[1] & 0xFE) == 18 // 198.18.0.0/15 benchmarking || o[0] >= 240 // 240.0.0.0/4 reserved / experimental } std::net::IpAddr::V6(v6) => { let seg = v6.segments(); v6.is_loopback() || v6.is_unspecified() || (seg[0] & 0xfe00) == 0xfc00 // ULA fd00::/7 || (seg[0] & 0xffc0) == 0xfe80 // link-local // NAT64 64:ff9b::/96 embeds a v4 address, check the embedded v4. || (seg[0] == 0x0064 && seg[1] == 0xff9b && is_private_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::new( (seg[6] >> 8) as u8, seg[6] as u8, (seg[7] >> 8) as u8, seg[7] as u8, )))) // v4-mapped (::ffff:a.b.c.d) and v4-compatible (::a.b.c.d) both // carry an embedded v4 that reqwest may connect to directly. || matches!(v6.to_ipv4(), Some(v4) if is_private_ip(std::net::IpAddr::V4(v4))) } } } /// Cheap, synchronous pre-check that a URL is safe to fetch: http(s) scheme and /// not an obvious private/literal-IP target. Hostnames are NOT resolved here, /// that (and the authoritative anti-rebinding check) happens at connect time in /// [`SsrfSafeResolver`], so this stays non-blocking and usable from reqwest's /// synchronous redirect callback. /// /// The host is parsed with the `url` crate, the SAME parser reqwest uses to /// decide what to connect to. An earlier hand-rolled parser split the authority /// itself and fed the host to `std::net::IpAddr::parse`, which only accepts the /// dotted-quad form; integer/hex/octal encodings (`http://2130706433/`, /// `http://0x7f000001/`, `http://0177.0.0.1/`) failed that parse, fell through as /// "a hostname (resolved safely later)", and reqwest then normalized them into /// literal internal IPs the `SsrfSafeResolver` never sees (it is consulted only /// for *name* hosts). Parsing here with `url::Url` closes that divergence: we /// inspect exactly the `Host` reqwest will act on, so an IP literal in any /// encoding is caught by `is_private_ip` and a domain defers to the resolver. fn validate_url(url: &str) -> bool { let Ok(parsed) = url::Url::parse(url) else { return false; }; match parsed.scheme() { "http" | "https" => {} _ => return false, } // Only allow the standard web ports. A public host that legitimately serves // OG metadata does so on 80/443; an explicit non-web port (e.g. :6379) means // the link is trying to use us as a request-forwarder to some other service. // `port()` is `None` for the scheme default (80/443), which is fine. if let Some(p) = parsed.port() && p != 80 && p != 443 { return false; } // Inspect the concrete host reqwest will connect to. Any IP literal, // dotted-quad, integer, hex, octal, or IPv6, is normalized by the `url` // crate into `Host::Ipv4`/`Host::Ipv6` here, so the encoding tricks that // defeated `IpAddr::parse` can no longer hide behind the "it's a hostname" // branch. A real domain defers to `SsrfSafeResolver` at connect time. match parsed.host() { Some(url::Host::Ipv4(v4)) => !is_private_ip(std::net::IpAddr::V4(v4)), Some(url::Host::Ipv6(v6)) => !is_private_ip(std::net::IpAddr::V6(v6)), Some(url::Host::Domain(domain)) => { !domain.is_empty() && !domain.eq_ignore_ascii_case("localhost") } None => false, } } /// Extract unique http/https URLs from markdown text via pulldown_cmark link parsing. /// Returns at most `MAX_URLS` URLs. pub fn extract_urls(input: &str) -> Vec { let parser = Parser::new(input); let mut seen = std::collections::HashSet::new(); let mut urls = Vec::new(); for event in parser { if let Event::Start(Tag::Link { dest_url, .. }) = event { let url = dest_url.to_string(); if (url.starts_with("http://") || url.starts_with("https://")) && seen.insert(url.clone()) { urls.push(url); if urls.len() >= MAX_URLS { break; } } } } urls } /// Connect-time DNS resolver that drops private/reserved addresses. /// /// `validate_url` resolves and checks during validation, but reqwest re-resolves /// when it actually connects, a DNS-rebinding host could pass validation and /// then connect internally. Filtering here, at the resolver reqwest uses to /// connect, closes that TOCTOU: reqwest can only connect to an address this /// resolver returned, and we never return a private one. Resolution is async /// (`tokio::net::lookup_host`), so it also avoids blocking the runtime. struct SsrfSafeResolver; impl reqwest::dns::Resolve for SsrfSafeResolver { fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { Box::pin(async move { let host = name.as_str().to_string(); let addrs = tokio::net::lookup_host((host.as_str(), 0)).await?; let public: Vec = addrs.filter(|a| !is_private_ip(a.ip())).collect(); if public.is_empty() { return Err("no public address for host (SSRF guard)".into()); } let iter: reqwest::dns::Addrs = Box::new(public.into_iter()); Ok(iter) }) } } /// Build a reqwest client for link preview fetching with SSRF-safe redirect /// policy and a connect-time resolver that refuses private addresses. pub fn build_preview_client() -> reqwest::Client { reqwest::Client::builder() // Client-level backstop so every call site is bounded even if a // per-request `.timeout()` is omitted (call sites still set a tighter // 5s; this only catches a future caller that forgets). .timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(5)) .dns_resolver(std::sync::Arc::new(SsrfSafeResolver)) .redirect(reqwest::redirect::Policy::custom(|attempt| { if !validate_url(attempt.url().as_str()) || attempt.previous().len() >= 5 { attempt.stop() } else { attempt.follow() } })) .build() .expect("failed to build preview HTTP client") } /// Strategy for fetching link previews. The `Noop` variant lets tests skip /// real HTTP without monkey-patching `tokio::spawn`; production constructs /// `Http(build_preview_client())`. #[derive(Clone)] pub enum LinkPreviewFetcher { Http(reqwest::Client), Noop, } impl LinkPreviewFetcher { pub async fn fetch(&self, url: &str) -> Option<(Option, Option)> { match self { Self::Http(client) => fetch_og_metadata(client, url).await, Self::Noop => None, } } } /// Fetch OpenGraph metadata from a URL. Returns `(og:title, og:description)`. /// Best-effort: returns None on any error (timeout, too large, parse failure). #[tracing::instrument(skip_all)] pub async fn fetch_og_metadata( http: &reqwest::Client, url: &str, ) -> Option<(Option, Option)> { if !validate_url(url) { tracing::warn!(%url, "link preview blocked: url failed scheme/host SSRF validation"); return None; } let resp = match http .get(url) .timeout(std::time::Duration::from_secs(5)) .header("User-Agent", "Multithreaded/LinkPreview") .send() .await { Ok(r) => r, Err(e) => { tracing::debug!(%url, error = ?e, "link preview fetch failed (transport/timeout/blocked resolver)"); return None; } }; if !resp.status().is_success() { tracing::debug!(%url, status = %resp.status(), "link preview fetch: non-success status"); return None; } // Only parse HTML content. Default-deny: a response with no Content-Type // (or an unreadable one) is not treated as HTML. let is_html = resp .headers() .get(CONTENT_TYPE) .and_then(|ct| ct.to_str().ok()) .is_some_and(|ct| ct.starts_with("text/html")); if !is_html { return None; } // Read body in chunks, capping at MAX_BODY_SIZE let mut body = Vec::new(); let mut stream = resp; while body.len() < MAX_BODY_SIZE { let Some(chunk) = stream.chunk().await.ok()? else { break; }; let remaining = MAX_BODY_SIZE - body.len(); body.extend_from_slice(&chunk[..chunk.len().min(remaining)]); } let html = String::from_utf8_lossy(&body); let og_title = extract_og_meta(&html, "og:title"); let og_desc = extract_og_meta(&html, "og:description"); // Fall back to tag if no og:title let title = og_title.or_else(|| extract_html_title(&html)); if title.is_some() || og_desc.is_some() { Some((title, og_desc)) } else { None } } /// Normalize an external image response's `Content-Type` to one of the four /// formats we re-serve, or `None` to reject (the proxy default-denies anything /// that isn't a recognised image type, so it can't be turned into a relay for /// arbitrary content). fn allowed_image_content_type(ct: &str) -> Option<&'static str> { match ct.split(';').next().unwrap_or("").trim() { "image/png" => Some("image/png"), "image/jpeg" => Some("image/jpeg"), "image/gif" => Some("image/gif"), "image/webp" => Some("image/webp"), _ => None, } } /// Fetch an external image for the same-origin image proxy. /// /// Same SSRF guarantees as [`fetch_og_metadata`] (scheme/port pre-check + /// connect-time `SsrfSafeResolver` that refuses private addresses, even across /// redirects), capped at `MAX_BODY_SIZE` and a 5s timeout. Returns the bytes and /// a normalised image content-type, or `None` on any failure or a non-image /// response. Best-effort by design: the caller maps `None` to a 502. #[tracing::instrument(skip_all)] pub async fn fetch_image(http: &reqwest::Client, url: &str) -> Option<(Vec<u8>, &'static str)> { if !validate_url(url) { tracing::warn!(%url, "image proxy blocked: url failed scheme/host SSRF validation"); return None; } let resp = match http .get(url) .timeout(std::time::Duration::from_secs(5)) .header("User-Agent", "Multithreaded/ImageProxy") .send() .await { Ok(r) => r, Err(e) => { tracing::debug!(%url, error = ?e, "image proxy fetch failed (transport/timeout/blocked resolver)"); return None; } }; if !resp.status().is_success() { tracing::debug!(%url, status = %resp.status(), "image proxy fetch: non-success status"); return None; } // Default-deny: only re-serve a recognised image content-type. let content_type = resp .headers() .get(CONTENT_TYPE) .and_then(|ct| ct.to_str().ok()) .and_then(allowed_image_content_type)?; // Read the body in chunks, hard-capped at MAX_BODY_SIZE (1 MB). let mut body = Vec::new(); let mut stream = resp; while body.len() < MAX_BODY_SIZE { let Some(chunk) = stream.chunk().await.ok()? else { break; }; let remaining = MAX_BODY_SIZE - body.len(); body.extend_from_slice(&chunk[..chunk.len().min(remaining)]); } if body.is_empty() { return None; } Some((body, content_type)) } /// Extract a `<meta property="..." content="...">` value from HTML. fn extract_og_meta(html: &str, property: &str) -> Option<String> { static OG_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| { regex_lite::Regex::new( r#"<meta\s[^>]*?property\s*=\s*"([^"]*)"[^>]*?content\s*=\s*"([^"]*)"[^>]*?>"#, ) .unwrap() }); static OG_RE_REV: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| { regex_lite::Regex::new( r#"<meta\s[^>]*?content\s*=\s*"([^"]*)"[^>]*?property\s*=\s*"([^"]*)"[^>]*?>"#, ) .unwrap() }); // Try property-first order for caps in OG_RE.captures_iter(html) { if &caps[1] == property { let val = caps[2].trim().to_string(); if !val.is_empty() { return Some(val); } } } // Try content-first order (some sites put content before property) for caps in OG_RE_REV.captures_iter(html) { if &caps[2] == property { let val = caps[1].trim().to_string(); if !val.is_empty() { return Some(val); } } } None } /// Extract the `<title>` tag content from HTML. fn extract_html_title(html: &str) -> Option<String> { static TITLE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| { regex_lite::Regex::new(r"<title[^>]*>([^<]+)").unwrap() }); TITLE_RE.captures(html).map(|c| c[1].trim().to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn extract_urls_from_markdown() { let input = "Check [this](https://example.com) and [that](https://other.com/page)."; let urls = extract_urls(input); assert_eq!(urls, vec!["https://example.com", "https://other.com/page"]); } #[test] fn image_proxy_content_type_allowlist() { // Recognised image types pass (with parameters stripped); everything else // is refused so the proxy can't re-serve HTML/SVG/arbitrary content. assert_eq!(allowed_image_content_type("image/png"), Some("image/png")); assert_eq!( allowed_image_content_type("image/jpeg; charset=binary"), Some("image/jpeg") ); assert_eq!(allowed_image_content_type("image/webp"), Some("image/webp")); assert_eq!(allowed_image_content_type("image/svg+xml"), None); assert_eq!(allowed_image_content_type("text/html"), None); assert_eq!(allowed_image_content_type(""), None); } #[test] fn extract_urls_skips_non_http() { let input = "[mail](mailto:a@b.com) [site](https://x.com)"; let urls = extract_urls(input); assert_eq!(urls, vec!["https://x.com"]); } #[test] fn extract_urls_caps_at_three() { let input = "[a](https://1.com) [b](https://2.com) [c](https://3.com) [d](https://4.com)"; let urls = extract_urls(input); assert_eq!(urls.len(), 3); } #[test] fn extract_urls_deduplicates() { let input = "[a](https://same.com) [b](https://same.com)"; let urls = extract_urls(input); assert_eq!(urls, vec!["https://same.com"]); } #[test] fn extract_urls_no_links() { let urls = extract_urls("no links here"); assert!(urls.is_empty()); } #[test] fn og_meta_property_first() { let html = r#""#; assert_eq!( extract_og_meta(html, "og:title"), Some("My Page".to_string()) ); } #[test] fn og_meta_content_first() { let html = r#""#; assert_eq!( extract_og_meta(html, "og:description"), Some("Description here".to_string()) ); } #[test] fn og_meta_missing() { let html = r#""#; assert_eq!(extract_og_meta(html, "og:title"), None); } #[test] fn html_title_fallback() { let html = "Page Title"; assert_eq!(extract_html_title(html), Some("Page Title".to_string())); } #[test] fn html_title_missing() { let html = ""; assert_eq!(extract_html_title(html), None); } // validate_url tests #[test] fn validate_url_allows_https() { assert!(validate_url("https://example.com")); assert!(validate_url("https://example.com/path?q=1")); } #[test] fn validate_url_allows_http() { assert!(validate_url("http://example.com")); } #[test] fn validate_url_blocks_non_http_schemes() { assert!(!validate_url("ftp://example.com")); assert!(!validate_url("file:///etc/passwd")); assert!(!validate_url("javascript:alert(1)")); assert!(!validate_url("data:text/html,

hi

")); } #[test] fn validate_url_blocks_localhost() { assert!(!validate_url("http://localhost")); assert!(!validate_url("http://localhost:8080")); assert!(!validate_url("http://127.0.0.1")); assert!(!validate_url("http://127.0.0.1:3000")); assert!(!validate_url("http://0.0.0.0")); assert!(!validate_url("http://[::1]")); assert!(!validate_url("http://[::1]:8080")); } #[test] fn validate_url_blocks_private_10() { assert!(!validate_url("http://10.0.0.1")); assert!(!validate_url("http://10.255.255.255")); } #[test] fn validate_url_blocks_private_192_168() { assert!(!validate_url("http://192.168.0.1")); assert!(!validate_url("http://192.168.1.100:8080")); } #[test] fn validate_url_blocks_private_172_16() { assert!(!validate_url("http://172.16.0.1")); assert!(!validate_url("http://172.31.255.255")); // 172.15 and 172.32 are public assert!(validate_url("http://172.15.0.1")); assert!(validate_url("http://172.32.0.1")); } #[test] fn validate_url_blocks_link_local() { assert!(!validate_url("http://169.254.0.1")); assert!(!validate_url("http://169.254.169.254")); // AWS metadata } #[test] fn validate_url_blocks_ipv6_private() { assert!(!validate_url("http://[fd00::1]")); assert!(!validate_url("http://[fe80::1]")); } #[test] fn validate_url_allows_public_ips() { assert!(validate_url("http://8.8.8.8")); assert!(validate_url("https://93.184.216.34")); } #[test] fn validate_url_blocks_userinfo_literal_ip() { // H1: a userinfo-prefixed literal IP must not slip past the literal-IP // guard. reqwest connects straight to the IP (skipping SsrfSafeResolver), // so validate_url is the only defense on this path. assert!(!validate_url("http://@10.0.0.1/")); assert!(!validate_url("http://@169.254.169.254/")); // cloud metadata assert!(!validate_url("http://user:pass@127.0.0.1/")); assert!(!validate_url("http://foo@192.168.1.1:80/path")); assert!(!validate_url("http://a@b@10.0.0.1/")); // last '@' wins assert!(!validate_url("http://@[::1]/")); // A userinfo-prefixed *public* host is still allowed (host resolves and is // re-checked at connect time by SsrfSafeResolver). assert!(validate_url("http://user@example.com/")); } #[test] fn validate_url_blocks_empty_host() { // A truly empty authority is a parse error under the `url` crate → rejected. assert!(!validate_url("http://@/")); // `http:///path` is NOT an empty host under the `url` crate (nor under // reqwest): it normalizes to host "path", a domain deferred to // SsrfSafeResolver at connect time. Parsing with the same crate reqwest // uses means we treat it exactly as reqwest will, which is the point. assert!(validate_url("http:///path")); } #[test] fn validate_url_blocks_numeric_ip_encodings() { // The SSRF integer-IP bypass: these encodings all normalize to internal // literals under the `url` crate (what reqwest connects through) but were // rejected by the old `IpAddr::parse` pre-check, so they slipped past as // "hostnames" and reqwest connected straight to the internal IP without // ever consulting `SsrfSafeResolver`. validate_url must now block them. assert!(!validate_url("http://2130706433/")); // decimal 127.0.0.1 assert!(!validate_url("http://0x7f000001/")); // hex 127.0.0.1 assert!(!validate_url("http://0177.0.0.1/")); // octal-leading 127.0.0.1 assert!(!validate_url("http://2852039166/")); // decimal 169.254.169.254 (cloud metadata) assert!(!validate_url("http://0xa9fea9fe/")); // hex 169.254.169.254 assert!(!validate_url("http://017700000001/")); // full octal 127.0.0.1 // A public host in decimal form is still reachable (sanity: the guard is // is_private_ip, not a blanket numeric-host reject). assert!(validate_url("http://134744072/")); // 8.8.8.8 } #[test] fn validate_url_blocks_reserved_ranges() { assert!(!validate_url("http://0.1.2.3")); // 0.0.0.0/8 assert!(!validate_url("http://192.0.0.1")); // 192.0.0.0/24 assert!(!validate_url("http://198.18.0.1")); // benchmarking 198.18/15 assert!(!validate_url("http://198.19.255.255")); assert!(!validate_url("http://240.0.0.1")); // reserved 240/4 assert!(!validate_url("http://255.255.255.254")); } #[test] fn is_private_ip_blocks_v6_embedded_v4() { use std::net::{IpAddr, Ipv6Addr}; // v4-mapped private assert!(is_private_ip(IpAddr::V6( "::ffff:10.0.0.1".parse::().unwrap() ))); // v4-mapped public assert!(!is_private_ip(IpAddr::V6( "::ffff:8.8.8.8".parse::().unwrap() ))); // NAT64 wrapping link-local metadata assert!(is_private_ip(IpAddr::V6( "64:ff9b::169.254.169.254".parse::().unwrap() ))); } #[tokio::test] async fn noop_fetcher_returns_none_without_network() { let fetcher = LinkPreviewFetcher::Noop; // Any URL, would be a public host in production but here we expect no I/O. assert!(fetcher.fetch("https://example.com").await.is_none()); } }