//! SSRF deny-list — the load-bearing security mechanism behind the //! v1.1.4 `http::*` SDK. //! //! The policy is applied to the **resolved IP address**, not the //! hostname. That is the DNS-rebinding defense: a hostname that //! resolves to a public IP at lookup time and a private IP at connect //! time is not exploitable, because reqwest re-runs every connection //! (including post-redirect hops) through [`SsrfResolver`], which //! filters the address list before the socket is opened. //! //! [`SsrfPolicy::check`] returns a CIDR-*category* reason on denial //! (e.g. `"loopback"`, `"private"`) — never the IP itself, so the //! script-visible error can't be used to map the internal network. //! //! `PICLOUD_HTTP_ALLOW_PRIVATE=true` flips `allow_private`, which //! short-circuits every check to allow. That is dev/test-only and the //! binary logs a startup warning when it's set. use std::future::Future; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::pin::Pin; use std::sync::Arc; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; /// Decision policy for a single resolved IP. Cheap to clone (one bool). #[derive(Debug, Clone, Copy)] pub struct SsrfPolicy { /// When true, every address is allowed — the entire deny-list is /// disabled. Set from `PICLOUD_HTTP_ALLOW_PRIVATE`. Dev/test only. pub allow_private: bool, } impl SsrfPolicy { #[must_use] pub const fn new(allow_private: bool) -> Self { Self { allow_private } } /// `Ok(())` if the IP may be connected to; `Err(reason)` with a /// CIDR-category label otherwise. The reason is safe to surface to /// a script — it never contains the address. /// /// # Errors /// /// Returns the deny reason when `ip` falls in a blocked range and /// `allow_private` is false. pub fn check(&self, ip: IpAddr) -> Result<(), &'static str> { if self.allow_private { return Ok(()); } match ip { IpAddr::V4(v4) => check_v4(v4), IpAddr::V6(v6) => check_v6(v6), } } #[must_use] pub fn is_allowed(&self, ip: IpAddr) -> bool { self.check(ip).is_ok() } } /// IPv4 deny-list. Order doesn't matter (ranges are disjoint by /// construction); first match wins for the reason label. // Several arms share a reason ("private") for distinct CIDRs — keeping // them separate documents each blocked range explicitly. #[allow(clippy::match_same_arms)] fn check_v4(ip: Ipv4Addr) -> Result<(), &'static str> { let o = ip.octets(); match o { [127, ..] => Err("loopback"), [0, ..] => Err("unspecified"), // 0.0.0.0/8 "this network" [10, ..] => Err("private"), [172, b, ..] if (16..=31).contains(&b) => Err("private"), [192, 168, ..] => Err("private"), [169, 254, ..] => Err("link-local"), // includes cloud metadata 169.254.169.254 [100, b, ..] if (64..=127).contains(&b) => Err("carrier-grade-nat"), [224..=239, ..] => Err("multicast"), [240..=255, ..] => Err("reserved"), _ => Ok(()), } } /// IPv6 deny-list. IPv4-mapped addresses (`::ffff:0:0/96`) re-run the /// v4 deny-list against the embedded address. fn check_v6(ip: Ipv6Addr) -> Result<(), &'static str> { // IPv4-mapped (::ffff:a.b.c.d) — re-check the embedded v4 address // so a mapped private/loopback address can't sneak through. if let Some(v4) = ip.to_ipv4_mapped() { return check_v4(v4); } if ip == Ipv6Addr::LOCALHOST { return Err("loopback"); } if ip == Ipv6Addr::UNSPECIFIED { return Err("unspecified"); } let seg0 = ip.segments()[0]; if seg0 & 0xffc0 == 0xfe80 { return Err("link-local"); // fe80::/10 } if seg0 & 0xfe00 == 0xfc00 { return Err("unique-local"); // fc00::/7 } if seg0 & 0xff00 == 0xff00 { return Err("multicast"); // ff00::/8 } Ok(()) } /// Marker error returned by the resolver when *every* resolved address /// for a host was denied. reqwest wraps this into a connect error; the /// `http_service` impl walks the source chain for the /// `"blocked by SSRF policy:"` prefix to surface a clean /// [`crate::http_service::HttpError::Ssrf`] instead of a generic DNS /// failure. Keeping the reason a category label means no IP leaks. #[derive(Debug)] struct SsrfBlocked { reason: &'static str, } impl std::fmt::Display for SsrfBlocked { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "blocked by SSRF policy: {}", self.reason) } } impl std::error::Error for SsrfBlocked {} /// Prefix the resolver embeds in its error and the impl scans for. pub const SSRF_BLOCK_PREFIX: &str = "blocked by SSRF policy: "; /// Pluggable host→addresses lookup. Production uses the system /// resolver; tests inject a closure (e.g. to simulate DNS rebinding — /// a different address on a later call). pub type LookupFn = Arc< dyn Fn(String) -> Pin>> + Send>> + Send + Sync, >; fn system_lookup( host: String, ) -> Pin>> + Send>> { Box::pin(async move { // Port 0 — reqwest overrides it with the real target port. Ok(tokio::net::lookup_host((host.as_str(), 0u16)) .await? .collect()) }) } /// reqwest DNS resolver that delegates to the system resolver, then /// filters the address list through [`SsrfPolicy`]. Plugged in via /// `ClientBuilder::dns_resolver`, so it runs at the actual connection /// point — including for every redirect hop. This is the DNS-rebinding /// defense: filtering happens at connect time, not at URL-parse time. #[derive(Clone)] pub struct SsrfResolver { policy: SsrfPolicy, lookup: LookupFn, } impl SsrfResolver { #[must_use] pub fn new(policy: SsrfPolicy) -> Self { Self { policy, lookup: Arc::new(system_lookup), } } /// Construct with an injected lookup (tests only). #[must_use] pub fn with_lookup(policy: SsrfPolicy, lookup: LookupFn) -> Self { Self { policy, lookup } } } impl Resolve for SsrfResolver { fn resolve(&self, name: Name) -> Resolving { let policy = self.policy; let lookup = self.lookup.clone(); let host = name.as_str().to_string(); Box::pin(async move { let resolved: Vec = lookup(host) .await .map_err(|e| -> Box { Box::new(e) })?; // Empty resolution → genuine DNS miss; let reqwest surface // it as a normal "no addresses" error. if resolved.is_empty() { let addrs: Addrs = Box::new(std::iter::empty()); return Ok(addrs); } let mut allowed: Vec = Vec::with_capacity(resolved.len()); let mut last_reason: &'static str = "denied"; for sa in resolved { match policy.check(sa.ip()) { Ok(()) => allowed.push(sa), Err(reason) => last_reason = reason, } } // Resolution returned addresses but the policy denied them // all → fail with the SSRF marker so the impl can report a // policy block (not a generic DNS error). if allowed.is_empty() { let err: Box = Box::new(SsrfBlocked { reason: last_reason, }); return Err(err); } let addrs: Addrs = Box::new(allowed.into_iter()); Ok(addrs) }) } } /// Build the resolver. reqwest's `dns_resolver` is generic over a /// concrete `R: Resolve` (it stores `Arc`), so this returns the /// concrete `Arc` rather than a trait object. #[must_use] pub fn resolver(policy: SsrfPolicy) -> Arc { Arc::new(SsrfResolver::new(policy)) } #[cfg(test)] mod tests { use super::*; use std::str::FromStr; fn denied(ip: &str) -> &'static str { let policy = SsrfPolicy::new(false); policy .check(IpAddr::from_str(ip).unwrap()) .expect_err(&format!("{ip} should be denied")) } fn allowed(ip: &str) { let policy = SsrfPolicy::new(false); policy .check(IpAddr::from_str(ip).unwrap()) .unwrap_or_else(|r| panic!("{ip} should be allowed, denied as {r}")); } #[test] fn denies_ipv4_loopback() { assert_eq!(denied("127.0.0.1"), "loopback"); assert_eq!(denied("127.1.2.3"), "loopback"); } #[test] fn denies_ipv4_unspecified() { assert_eq!(denied("0.0.0.0"), "unspecified"); } #[test] fn denies_rfc1918_private() { assert_eq!(denied("10.0.0.1"), "private"); assert_eq!(denied("10.255.255.255"), "private"); assert_eq!(denied("172.16.0.1"), "private"); assert_eq!(denied("172.31.255.255"), "private"); assert_eq!(denied("192.168.0.1"), "private"); } #[test] fn allows_172_outside_private_range() { // 172.15.x and 172.32.x are public — only 172.16.0.0/12 is private. allowed("172.15.0.1"); allowed("172.32.0.1"); } #[test] fn denies_link_local_and_cloud_metadata() { assert_eq!(denied("169.254.0.1"), "link-local"); // The cloud metadata endpoint is the canonical SSRF target. assert_eq!(denied("169.254.169.254"), "link-local"); } #[test] fn denies_carrier_grade_nat() { assert_eq!(denied("100.64.0.1"), "carrier-grade-nat"); assert_eq!(denied("100.127.255.255"), "carrier-grade-nat"); // 100.63.x and 100.128.x are outside 100.64.0.0/10. allowed("100.63.0.1"); allowed("100.128.0.1"); } #[test] fn denies_multicast_and_reserved() { assert_eq!(denied("224.0.0.1"), "multicast"); assert_eq!(denied("239.255.255.255"), "multicast"); assert_eq!(denied("240.0.0.1"), "reserved"); assert_eq!(denied("255.255.255.255"), "reserved"); } #[test] fn allows_public_ipv4() { allowed("1.1.1.1"); allowed("8.8.8.8"); allowed("93.184.216.34"); // example.com } #[test] fn denies_ipv6_loopback() { assert_eq!(denied("::1"), "loopback"); } #[test] fn denies_ipv6_unspecified() { assert_eq!(denied("::"), "unspecified"); } #[test] fn denies_ipv6_link_local() { assert_eq!(denied("fe80::1"), "link-local"); assert_eq!(denied("febf:ffff::1"), "link-local"); } #[test] fn denies_ipv6_unique_local() { assert_eq!(denied("fc00::1"), "unique-local"); assert_eq!(denied("fd12:3456::1"), "unique-local"); } #[test] fn denies_ipv6_multicast() { assert_eq!(denied("ff00::1"), "multicast"); assert_eq!(denied("ff02::1"), "multicast"); } #[test] fn allows_public_ipv6() { allowed("2606:4700:4700::1111"); // cloudflare allowed("2001:4860:4860::8888"); // google } #[test] fn ipv4_mapped_ipv6_rechecks_embedded_address() { // ::ffff:127.0.0.1 must be denied via the embedded-v4 re-check. assert_eq!(denied("::ffff:127.0.0.1"), "loopback"); assert_eq!(denied("::ffff:10.0.0.1"), "private"); assert_eq!(denied("::ffff:169.254.169.254"), "link-local"); // A mapped *public* address stays allowed. allowed("::ffff:1.1.1.1"); } #[test] fn allow_private_disables_all_denials() { let policy = SsrfPolicy::new(true); for ip in ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1", "fe80::1"] { assert!(policy.is_allowed(IpAddr::from_str(ip).unwrap())); } } // --- resolver-path tests (the connect-time filter) --- use std::sync::atomic::{AtomicUsize, Ordering}; fn name(s: &str) -> Name { Name::from_str(s).unwrap() } fn fixed_lookup(addrs: Vec) -> LookupFn { Arc::new(move |_host| { let addrs = addrs.clone(); Box::pin(async move { Ok(addrs) }) }) } #[tokio::test] async fn resolver_returns_only_allowed_addresses() { // A host resolving to one public + one private IP yields only // the public one to reqwest. let public: SocketAddr = "1.1.1.1:0".parse().unwrap(); let private: SocketAddr = "10.0.0.1:0".parse().unwrap(); let resolver = SsrfResolver::with_lookup(SsrfPolicy::new(false), fixed_lookup(vec![public, private])); let got: Vec = resolver .resolve(name("mixed.example")) .await .unwrap() .collect(); assert_eq!(got, vec![public]); } #[tokio::test] async fn resolver_all_denied_fails_with_ssrf_marker() { // A host resolving to ONLY private IPs fails with the SSRF // marker (not a generic empty/DNS result). let resolver = SsrfResolver::with_lookup( SsrfPolicy::new(false), fixed_lookup(vec![ "10.0.0.1:0".parse().unwrap(), "127.0.0.1:0".parse().unwrap(), ]), ); let Err(err) = resolver.resolve(name("internal.example")).await else { panic!("all-denied resolution should error"); }; assert!( err.to_string().starts_with(SSRF_BLOCK_PREFIX), "expected SSRF marker, got: {err}" ); } #[tokio::test] async fn resolver_dns_rebinding_second_resolution_denied() { // Simulate rebinding: public IP on the first lookup, private on // the second. The connect-time filter denies the second. let calls = Arc::new(AtomicUsize::new(0)); let calls2 = calls.clone(); let lookup: LookupFn = Arc::new(move |_host| { let n = calls2.fetch_add(1, Ordering::SeqCst); Box::pin(async move { let addr: SocketAddr = if n == 0 { "1.1.1.1:0".parse().unwrap() } else { "127.0.0.1:0".parse().unwrap() }; Ok(vec![addr]) }) }); let resolver = SsrfResolver::with_lookup(SsrfPolicy::new(false), lookup); // First resolution: public → allowed. let first: Vec = resolver .resolve(name("rebind.example")) .await .unwrap() .collect(); assert_eq!(first, vec!["1.1.1.1:0".parse::().unwrap()]); // Second resolution: rebinding returns loopback → denied. let Err(err) = resolver.resolve(name("rebind.example")).await else { panic!("rebound private address must be denied"); }; assert!(err.to_string().contains("loopback")); } #[tokio::test] async fn resolver_empty_resolution_is_not_ssrf() { // Genuine DNS miss (no addresses) returns an empty iterator, // NOT the SSRF marker — reqwest surfaces a normal DNS error. let resolver = SsrfResolver::with_lookup(SsrfPolicy::new(false), fixed_lookup(vec![])); let got: Vec = resolver .resolve(name("nxdomain.example")) .await .unwrap() .collect(); assert!(got.is_empty()); } }