use std::net::IpAddr; use anyhow::bail; use reqwest::{redirect::Policy, Client, Response, Url}; use tokio::net::lookup_host; use crate::error::AppError; // Outbound requests for feed/article URLs are driven by user input (feed URLs, // "read" links). Without these checks a user could point the server at // internal services or cloud metadata endpoints (e.g. http://169.254.169.254/) // and have it fetch them on their behalf (SSRF). pub async fn safe_fetch(url: &str) -> Result { safe_fetch_inner(url).await.map_err(AppError::from) } // Redirects are validated and followed manually (rather than via // `redirect::Policy::default()`) so each hop's resolved address is checked // against `is_globally_routable` before it's fetched — otherwise an allowed // host could redirect to an internal address and bypass the checks below. const MAX_REDIRECTS: u8 = 5; async fn safe_fetch_inner(url: &str) -> anyhow::Result { let client = Client::builder().redirect(Policy::none()).build()?; let mut current = Url::parse(url)?; for _ in 0..=MAX_REDIRECTS { check_url_is_safe(¤t).await?; let response = client.get(current.clone()).send().await?; if response.status().is_redirection() { let location = response .headers() .get(reqwest::header::LOCATION) .ok_or_else(|| { anyhow::anyhow!("redirect response from {} has no Location header", current) })? .to_str()?; current = current.join(location)?; continue; } return Ok(response); } bail!("refusing to fetch {}: too many redirects", url); } async fn check_url_is_safe(url: &Url) -> anyhow::Result<()> { if url.scheme() != "http" && url.scheme() != "https" { bail!("refusing to fetch {}: unsupported URL scheme", url); } let host = url .host_str() .ok_or_else(|| anyhow::anyhow!("refusing to fetch {}: URL has no host", url))?; let port = url.port_or_known_default().unwrap_or(80); let mut resolved_any = false; for addr in lookup_host((host, port)).await? { resolved_any = true; if !is_globally_routable(addr.ip()) { bail!( "refusing to fetch {}: resolves to non-public address {}", url, addr.ip() ); } } if !resolved_any { bail!("refusing to fetch {}: host did not resolve to any address", url); } Ok(()) } fn is_globally_routable(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => { !(v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() || v4.is_multicast()) } IpAddr::V6(v6) => { if let Some(v4) = v6.to_ipv4_mapped() { return is_globally_routable(IpAddr::V4(v4)); } let segments = v6.segments(); let is_unique_local = (segments[0] & 0xfe00) == 0xfc00; // fc00::/7 let is_unicast_link_local = (segments[0] & 0xffc0) == 0xfe80; // fe80::/10 !(v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() || is_unique_local || is_unicast_link_local) } } } #[cfg(test)] mod tests { use super::*; #[test] fn rejects_loopback_and_private_addresses() { assert!(!is_globally_routable("127.0.0.1".parse().unwrap())); assert!(!is_globally_routable("10.0.0.5".parse().unwrap())); assert!(!is_globally_routable("192.168.1.1".parse().unwrap())); assert!(!is_globally_routable("169.254.169.254".parse().unwrap())); assert!(!is_globally_routable("::1".parse().unwrap())); assert!(!is_globally_routable("fc00::1".parse().unwrap())); assert!(!is_globally_routable("fe80::1".parse().unwrap())); assert!(!is_globally_routable("::ffff:127.0.0.1".parse().unwrap())); } #[test] fn allows_public_addresses() { assert!(is_globally_routable("93.184.216.34".parse().unwrap())); assert!(is_globally_routable("2606:2800:220:1:248:1893:25c8:1946".parse().unwrap())); } #[actix_web::test] async fn rejects_unsupported_schemes() { let result = safe_fetch("ftp://example.test/file").await; assert!(result.is_err()); } #[actix_web::test] async fn rejects_loopback_urls() { let result = safe_fetch("http://127.0.0.1:8001/").await; assert!(result.is_err()); } #[actix_web::test] async fn rejects_link_local_metadata_url() { let result = safe_fetch("http://169.254.169.254/latest/meta-data/").await; assert!(result.is_err()); } }