feat: SSRF protection — block private IP ranges on outgoing requests
SsrfVerifier rejects private/reserved IPs (loopback, RFC1918, link-local, CGNAT, ULA) on all federation fetches. Raw reqwest calls in webfinger and backfill also validated. Debug mode bypasses via PermissiveVerifier. Closes #4
This commit is contained in:
@@ -48,7 +48,11 @@ impl ApFederationConfig {
|
|||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
let mut builder = FederationConfig::builder();
|
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 {
|
if let Some(actor) = signing_actor {
|
||||||
builder.signed_fetch_actor(actor);
|
builder.signed_fetch_actor(actor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod inbox;
|
|||||||
pub mod nodeinfo;
|
pub mod nodeinfo;
|
||||||
pub mod outbox;
|
pub mod outbox;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
|
pub(crate) mod security;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub(crate) mod urls;
|
pub(crate) mod urls;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|||||||
132
src/security.rs
Normal file
132
src/security.rs
Normal file
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ impl ActivityPubService {
|
|||||||
outbox_url: &str,
|
outbox_url: &str,
|
||||||
actor_url: &str,
|
actor_url: &str,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
let outbox_parsed = url::Url::parse(outbox_url)?;
|
||||||
|
crate::security::validate_url(&outbox_parsed).await?;
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(
|
.timeout(std::time::Duration::from_secs(
|
||||||
super::HTTP_FETCH_TIMEOUT_SECS,
|
super::HTTP_FETCH_TIMEOUT_SECS,
|
||||||
@@ -49,6 +51,12 @@ impl ActivityPubService {
|
|||||||
tracing::warn!(url = %current_url, "backfill: loop detected, stopping");
|
tracing::warn!(url = %current_url, "backfill: loop detected, stopping");
|
||||||
break;
|
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
|
let page: serde_json::Value = match client
|
||||||
.get(¤t_url)
|
.get(¤t_url)
|
||||||
.header("Accept", "application/activity+json")
|
.header("Accept", "application/activity+json")
|
||||||
|
|||||||
@@ -450,6 +450,8 @@ impl ActivityPubService {
|
|||||||
domain_str, user, domain_str
|
domain_str, user, domain_str
|
||||||
);
|
);
|
||||||
tracing::debug!(handle, wf_url, "resolving webfinger");
|
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()
|
let wf: serde_json::Value = reqwest::Client::new()
|
||||||
.get(&wf_url)
|
.get(&wf_url)
|
||||||
.header("Accept", "application/jrd+json, application/json")
|
.header("Accept", "application/jrd+json, application/json")
|
||||||
|
|||||||
Reference in New Issue
Block a user