Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
175 changes: 175 additions & 0 deletions crates/buzz-core/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ fn embedded_ipv4(v6: &std::net::Ipv6Addr, prefix: &[u8; 12]) -> Option<std::net:
/// - NAT64 local-use 64:ff9b:1::/48 (RFC 8215)
/// - Teredo 2001::/32 (RFC 4380)
/// - 6to4 2002::/16 (RFC 3056)
///
/// Returns `true` if the IP address is in a private, reserved, or loopback range.
pub fn is_private_ip(ip: &std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
Expand Down Expand Up @@ -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<IpCidr>, Vec<String>) {
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::<u8>() else {
dropped.push(s.to_string());
continue;
};
let Ok(base) = addr_str.trim().parse::<std::net::IpAddr>() 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::*;
Expand Down Expand Up @@ -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::<IpAddr>().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));
}
}
}
83 changes: 78 additions & 5 deletions crates/buzz-workflow/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -843,15 +843,37 @@ async fn check_ssrf(host: &str, port: u16) -> Result<std::net::IpAddr, WorkflowE

debug!("Resolved webhook host '{}' → {:?}", host, addrs);

for ip in &addrs {
if buzz_core::network::is_private_ip(ip) {
ssrf_verdict(
host,
&addrs,
&std::env::var("BUZZ_WORKFLOW_WEBHOOK_ALLOWED_CIDRS").unwrap_or_default(),
)
}

#[cfg(feature = "reqwest")]
fn ssrf_verdict(
host: &str,
addrs: &[std::net::IpAddr],
allowed_spec: &str,
) -> Result<std::net::IpAddr, WorkflowError> {
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).
Expand Down Expand Up @@ -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();
Expand Down