diff --git a/src/federation.rs b/src/federation.rs index 4dd94df..c71f514 100644 --- a/src/federation.rs +++ b/src/federation.rs @@ -48,7 +48,11 @@ impl ApFederationConfig { .await? } else { let mut builder = FederationConfig::builder(); - builder.domain(&data.domain).app_data(data).debug(false); + builder + .domain(&data.domain) + .url_verifier(Box::new(crate::security::SsrfVerifier)) + .app_data(data) + .debug(false); if let Some(actor) = signing_actor { builder.signed_fetch_actor(actor); } diff --git a/src/lib.rs b/src/lib.rs index 5e5db8b..7ff3447 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod inbox; pub mod nodeinfo; pub mod outbox; pub mod repository; +pub(crate) mod security; pub mod service; pub(crate) mod urls; pub mod user; diff --git a/src/security.rs b/src/security.rs new file mode 100644 index 0000000..017d01a --- /dev/null +++ b/src/security.rs @@ -0,0 +1,132 @@ +use std::net::IpAddr; + +use url::Url; + +fn is_ip_private(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_unspecified() + || v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64 // 100.64.0.0/10 + } + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + || (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 (ULA) + || (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 (link-local) + } + } +} + +/// Resolve a URL's hostname and reject private/reserved IP ranges. +pub(crate) async fn validate_url(url: &Url) -> anyhow::Result<()> { + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("URL has no host: {url}"))?; + let port = url.port_or_known_default().unwrap_or(443); + let addr = format!("{host}:{port}"); + let resolved = tokio::net::lookup_host(&addr).await?; + for ip in resolved { + if is_ip_private(ip.ip()) { + anyhow::bail!("SSRF blocked: {url} resolves to private IP {}", ip.ip()); + } + } + Ok(()) +} + +#[derive(Clone)] +pub(crate) struct SsrfVerifier; + +#[async_trait::async_trait] +impl activitypub_federation::config::UrlVerifier for SsrfVerifier { + async fn verify(&self, url: &Url) -> Result<(), activitypub_federation::error::Error> { + validate_url(url).await.map_err(|_| { + activitypub_federation::error::Error::UrlVerificationError( + "URL resolves to a private/reserved IP range", + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_ipv4_loopback() { + assert!(is_ip_private("127.0.0.1".parse().unwrap())); + assert!(is_ip_private("127.255.255.255".parse().unwrap())); + } + + #[test] + fn rejects_ipv4_private_10() { + assert!(is_ip_private("10.0.0.1".parse().unwrap())); + assert!(is_ip_private("10.255.255.255".parse().unwrap())); + } + + #[test] + fn rejects_ipv4_private_172() { + assert!(is_ip_private("172.16.0.1".parse().unwrap())); + assert!(is_ip_private("172.31.255.255".parse().unwrap())); + } + + #[test] + fn rejects_ipv4_private_192() { + assert!(is_ip_private("192.168.0.1".parse().unwrap())); + assert!(is_ip_private("192.168.255.255".parse().unwrap())); + } + + #[test] + fn rejects_ipv4_link_local() { + assert!(is_ip_private("169.254.0.1".parse().unwrap())); + assert!(is_ip_private("169.254.255.255".parse().unwrap())); + } + + #[test] + fn rejects_ipv4_unspecified() { + assert!(is_ip_private("0.0.0.0".parse().unwrap())); + } + + #[test] + fn rejects_ipv4_cgnat() { + assert!(is_ip_private("100.64.0.1".parse().unwrap())); + assert!(is_ip_private("100.127.255.255".parse().unwrap())); + } + + #[test] + fn allows_public_ipv4() { + assert!(!is_ip_private("8.8.8.8".parse().unwrap())); + assert!(!is_ip_private("1.1.1.1".parse().unwrap())); + assert!(!is_ip_private("93.184.216.34".parse().unwrap())); + } + + #[test] + fn rejects_ipv6_loopback() { + assert!(is_ip_private("::1".parse().unwrap())); + } + + #[test] + fn rejects_ipv6_unspecified() { + assert!(is_ip_private("::".parse().unwrap())); + } + + #[test] + fn rejects_ipv6_ula() { + assert!(is_ip_private("fc00::1".parse().unwrap())); + assert!(is_ip_private("fd12:3456::1".parse().unwrap())); + } + + #[test] + fn rejects_ipv6_link_local() { + assert!(is_ip_private("fe80::1".parse().unwrap())); + } + + #[test] + fn allows_public_ipv6() { + assert!(!is_ip_private("2001:4860:4860::8888".parse().unwrap())); + assert!(!is_ip_private("2606:4700::1111".parse().unwrap())); + } +} diff --git a/src/service/backfill.rs b/src/service/backfill.rs index 16b4736..a73caf8 100644 --- a/src/service/backfill.rs +++ b/src/service/backfill.rs @@ -21,6 +21,8 @@ impl ActivityPubService { outbox_url: &str, actor_url: &str, ) -> anyhow::Result<()> { + let outbox_parsed = url::Url::parse(outbox_url)?; + crate::security::validate_url(&outbox_parsed).await?; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs( super::HTTP_FETCH_TIMEOUT_SECS, @@ -49,6 +51,12 @@ impl ActivityPubService { tracing::warn!(url = %current_url, "backfill: loop detected, stopping"); break; } + if let Ok(page_url) = url::Url::parse(¤t_url) + && let Err(e) = crate::security::validate_url(&page_url).await + { + tracing::warn!(url = %current_url, error = %e, "backfill: SSRF check failed"); + break; + } let page: serde_json::Value = match client .get(¤t_url) .header("Accept", "application/activity+json") diff --git a/src/service/mod.rs b/src/service/mod.rs index 12e3948..7d6f991 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -450,6 +450,8 @@ impl ActivityPubService { domain_str, user, domain_str ); tracing::debug!(handle, wf_url, "resolving webfinger"); + let wf_parsed = Url::parse(&wf_url)?; + crate::security::validate_url(&wf_parsed).await?; let wf: serde_json::Value = reqwest::Client::new() .get(&wf_url) .header("Accept", "application/jrd+json, application/json")