diff --git a/.env.example b/.env.example index 0f7bbba6f13..0d5870315bc 100644 --- a/.env.example +++ b/.env.example @@ -249,4 +249,7 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # attestation are optional; configuring any one enables policy acceptance. # BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here." # BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here." +# Comma-separated CIDR allowlist for workflow webhook SSRF check. Empty (default) blocks all private/reserved ranges. +# BUZZ_WORKFLOW_WEBHOOK_ALLOWED_CIDRS=100.64.0.0/10,fd00::/8 + # BUZZ_AGE_ATTESTATION_REQUIRED=true diff --git a/crates/buzz-core/src/network.rs b/crates/buzz-core/src/network.rs index fb3718d58c5..c2ba509032f 100644 --- a/crates/buzz-core/src/network.rs +++ b/crates/buzz-core/src/network.rs @@ -43,6 +43,8 @@ fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option bool { match ip { std::net::IpAddr::V4(v4) => { @@ -94,6 +96,98 @@ pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { } } +/// CIDR range for allowlist matching (hand-rolled, no extra dependency). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpCidr { + /// Base address of the CIDR range. + pub base: std::net::IpAddr, + /// Prefix length (0-32 for IPv4, 0-128 for IPv6). + pub prefix_len: u8, +} + +impl IpCidr { + /// Returns true if `ip` is within this CIDR range. Family mismatch returns false. + pub fn contains(&self, ip: &std::net::IpAddr) -> bool { + match (&self.base, ip) { + (std::net::IpAddr::V4(b), std::net::IpAddr::V4(i)) => { + if self.prefix_len == 0 { + return true; + } + if self.prefix_len > 32 { + return false; + } + let mask = if self.prefix_len == 32 { + u32::MAX + } else { + u32::MAX << (32 - self.prefix_len) + }; + (u32::from(*b) & mask) == (u32::from(*i) & mask) + } + (std::net::IpAddr::V6(b), std::net::IpAddr::V6(i)) => { + if self.prefix_len == 0 { + return true; + } + if self.prefix_len > 128 { + return false; + } + let b = b.octets(); + let i = i.octets(); + let full = (self.prefix_len / 8) as usize; + if b[..full] != i[..full] { + return false; + } + let rem = self.prefix_len % 8; + if rem == 0 { + return true; + } + let mask: u8 = 0xFF << (8 - rem); + (b[full] & mask) == (i[full] & mask) + } + _ => false, + } + } +} + +/// Parse comma-separated CIDR spec into valid entries. Malformed entries are dropped. +/// Returns the valid ranges and the dropped entry strings. +pub fn parse_allowed_cidrs(spec: &str) -> (Vec, Vec) { + let mut out = Vec::new(); + let mut dropped = Vec::new(); + for part in spec.split(',') { + let s = part.trim(); + if s.is_empty() { + continue; + } + let Some((addr_str, prefix_str)) = s.split_once('/') else { + dropped.push(s.to_string()); + continue; + }; + let Ok(prefix_len) = prefix_str.trim().parse::() else { + dropped.push(s.to_string()); + continue; + }; + let Ok(base) = addr_str.trim().parse::() else { + dropped.push(s.to_string()); + continue; + }; + let max = match base { + std::net::IpAddr::V4(_) => 32, + std::net::IpAddr::V6(_) => 128, + }; + if prefix_len > max { + dropped.push(s.to_string()); + continue; + } + out.push(IpCidr { base, prefix_len }); + } + (out, dropped) +} + +/// Returns true if `ip` is private/reserved and not covered by `allowed`. +pub fn is_blocked_ip(ip: &std::net::IpAddr, allowed: &[IpCidr]) -> bool { + is_private_ip(ip) && !allowed.iter().any(|c| c.contains(ip)) +} + #[cfg(test)] mod tests { use super::*; @@ -359,4 +453,85 @@ mod tests { // fe00:: — just below ff00::/8 (not multicast, not link-local, not ULA) assert!(!is_private_ip(&"fe00::1".parse::().unwrap())); } + + #[test] + fn test_allowlist_cgnat() { + let ip: IpAddr = "100.64.1.5".parse().unwrap(); + assert!(is_blocked_ip(&ip, &[])); + let (allowed, _) = parse_allowed_cidrs("100.64.0.0/10"); + assert!(!is_blocked_ip(&ip, &allowed)); + let (near, _) = parse_allowed_cidrs("100.65.0.0/16"); + assert!(is_blocked_ip(&ip, &near)); + } + + #[test] + fn test_allowlist_public_never_blocked() { + let ip: IpAddr = "93.184.216.34".parse().unwrap(); + assert!(!is_blocked_ip(&ip, &[])); + let (allowed, _) = parse_allowed_cidrs("100.64.0.0/10"); + assert!(!is_blocked_ip(&ip, &allowed)); + } + + #[test] + fn test_allowlist_one_range_does_not_open_others() { + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + let (allowed, _) = parse_allowed_cidrs("100.64.0.0/10"); + assert!(is_blocked_ip(&ip, &allowed)); + } + + #[test] + fn test_allowlist_ipv6_ula() { + let ip: IpAddr = "fd00::1".parse().unwrap(); + assert!(is_blocked_ip(&ip, &[])); + let (allowed, _) = parse_allowed_cidrs("fd00::/8"); + assert!(!is_blocked_ip(&ip, &allowed)); + } + + #[test] + fn test_allowlist_family_mismatch() { + let v4: IpAddr = "10.0.0.1".parse().unwrap(); + let v6: IpAddr = "fd00::1".parse().unwrap(); + let (a1, _) = parse_allowed_cidrs("fd00::/8"); + assert!(is_blocked_ip(&v4, &a1)); + let (a2, _) = parse_allowed_cidrs("10.0.0.0/8"); + assert!(is_blocked_ip(&v6, &a2)); + } + + #[test] + fn test_allowlist_single_host() { + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + let neigh: IpAddr = "10.0.0.2".parse().unwrap(); + let (allowed, _) = parse_allowed_cidrs("10.0.0.1/32"); + assert!(!is_blocked_ip(&ip, &allowed)); + assert!(is_blocked_ip(&neigh, &allowed)); + let v6: IpAddr = "fd00::1".parse().unwrap(); + let v6n: IpAddr = "fd00::2".parse().unwrap(); + let (allowed6, _) = parse_allowed_cidrs("fd00::1/128"); + assert!(!is_blocked_ip(&v6, &allowed6)); + assert!(is_blocked_ip(&v6n, &allowed6)); + } + + #[test] + fn test_parse_drops_malformed() { + let (v, dropped) = parse_allowed_cidrs("10.0.0.0/8, garbage, 100.64.0.0/99, 100.64.0.0/10"); + assert_eq!(v.len(), 2); + assert_eq!(dropped, vec!["garbage", "100.64.0.0/99"]); + } + + #[test] + fn test_parse_empty() { + assert!(parse_allowed_cidrs("").0.is_empty()); + assert!(parse_allowed_cidrs(" ").0.is_empty()); + // empty allowlist behaves like is_private_ip + for s in [ + "10.0.0.1", + "8.8.8.8", + "100.64.1.5", + "fd00::1", + "2606:4700::1", + ] { + let ip: IpAddr = s.parse().unwrap(); + assert_eq!(is_blocked_ip(&ip, &[]), is_private_ip(&ip)); + } + } } diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index 5c712dcff7c..6f77f00c5c1 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -843,15 +843,37 @@ async fn check_ssrf(host: &str, port: u16) -> Result Result { + let (allowed, dropped) = buzz_core::network::parse_allowed_cidrs(allowed_spec); + if !dropped.is_empty() { + tracing::warn!( + "BUZZ_WORKFLOW_WEBHOOK_ALLOWED_CIDRS: dropping malformed entries {:?}", + dropped + ); + } + for ip in addrs { + if buzz_core::network::is_blocked_ip(ip, &allowed) { return Err(WorkflowError::WebhookError(format!( - "SSRF blocked: '{host}' resolved to private/reserved address {ip}" + "SSRF blocked: '{host}' resolved to private/reserved address {ip} (allow it with BUZZ_WORKFLOW_WEBHOOK_ALLOWED_CIDRS)" ))); } } - - Ok(addrs[0]) + addrs + .first() + .copied() + .ok_or_else(|| WorkflowError::WebhookError("DNS resolution returned no addresses".into())) } /// Maximum response body size for webhook calls (1 MiB). @@ -1958,6 +1980,57 @@ mod tests { ); } + #[test] + #[cfg(feature = "reqwest")] + fn ssrf_verdict_empty_blocks_cgnat() { + let ip: std::net::IpAddr = "100.64.1.5".parse().unwrap(); + assert!(ssrf_verdict("example.com", &[ip], "").is_err()); + } + + #[test] + #[cfg(feature = "reqwest")] + fn ssrf_verdict_allows_cgnat_with_spec() { + let ip: std::net::IpAddr = "100.64.1.5".parse().unwrap(); + assert!(ssrf_verdict("example.com", &[ip], "100.64.0.0/10").is_ok()); + } + + #[test] + #[cfg(feature = "reqwest")] + fn ssrf_verdict_still_blocks_other_private() { + let ip: std::net::IpAddr = "10.0.0.1".parse().unwrap(); + assert!(ssrf_verdict("example.com", &[ip], "100.64.0.0/10").is_err()); + } + + #[test] + #[cfg(feature = "reqwest")] + fn ssrf_verdict_public_passes() { + let ip: std::net::IpAddr = "93.184.216.34".parse().unwrap(); + assert!(ssrf_verdict("example.com", &[ip], "").is_ok()); + assert!(ssrf_verdict("example.com", &[ip], "100.64.0.0/10").is_ok()); + } + + #[test] + #[cfg(feature = "reqwest")] + fn ssrf_verdict_empty_addrs_is_err() { + assert!(ssrf_verdict("example.com", &[], "").is_err()); + } + + #[cfg(feature = "reqwest")] + #[test] + fn check_ssrf_reads_allowlist_env_var() { + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + std::env::remove_var("BUZZ_WORKFLOW_WEBHOOK_ALLOWED_CIDRS"); + assert!(rt.block_on(check_ssrf("127.0.0.1", 80)).is_err()); + std::env::set_var("BUZZ_WORKFLOW_WEBHOOK_ALLOWED_CIDRS", "127.0.0.0/8"); + assert!(rt.block_on(check_ssrf("127.0.0.1", 80)).is_ok()); + std::env::remove_var("BUZZ_WORKFLOW_WEBHOOK_ALLOWED_CIDRS"); + } + #[test] fn send_message_canonicalizes_valid_explicit_override_for_global_workflow() { let override_channel_id = Uuid::new_v4();