diff --git a/crates/trapfalld/src/alert.rs b/crates/trapfalld/src/alert.rs index 919186a..8dacb18 100644 --- a/crates/trapfalld/src/alert.rs +++ b/crates/trapfalld/src/alert.rs @@ -1,24 +1,9 @@ //! Alert engine — evaluates rules against incoming issues and dispatches webhooks. -use std::sync::LazyLock; use tokio::sync::mpsc; use trapfall_core::Store; use trapfall_proto::{AlertRule, Issue}; -/// Shared HTTP client for webhook dispatch — connection pooling. -/// -/// **Security**: redirects are disabled (`.redirect(reqwest::redirect::Policy::none()`) -/// to prevent SSRF via open-redirect chains. Webhook URLs are validated -/// before each request in `dispatch_webhook`. -static REQWEST_CLIENT: LazyLock = LazyLock::new(|| { - reqwest::Client::builder() - .pool_max_idle_per_host(4) - .redirect(reqwest::redirect::Policy::none()) - .connect_timeout(std::time::Duration::from_secs(5)) - .build() - .unwrap_or_default() -}); - /// Spawn the alert engine background task. pub fn spawn_alert_engine(store: Store, _buffer: usize) -> mpsc::UnboundedSender { let (tx, mut rx) = mpsc::unbounded_channel::(); @@ -101,7 +86,60 @@ fn matches_rule(rule: &AlertRule, issue: &Issue) -> bool { true } +/// Syntactic webhook URL validation (no DNS). Used at rule creation and again +/// at dispatch. Requires HTTPS, no userinfo, and a host that is not a +/// private/internal literal or reserved name. +pub fn validate_webhook_url(url: &str) -> Result { + let parsed = url::Url::parse(url).map_err(|e| format!("invalid webhook URL: {e}"))?; + if parsed.scheme() != "https" { + return Err(format!("webhook URL must use HTTPS (got: {})", parsed.scheme())); + } + check_host(&parsed)?; + Ok(parsed) +} + +/// Host checks shared by all validators: no credentials, no private literal, +/// no reserved name. +fn check_host(parsed: &url::Url) -> Result<(), String> { + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("webhook URL must not contain credentials".to_string()); + } + let blocked = "webhook URL points to private/internal address".to_string(); + match parsed.host() { + None => Err("webhook URL has no host".to_string()), + Some(url::Host::Ipv4(ip)) if ip_is_private(ip.into()) => Err(blocked), + Some(url::Host::Ipv6(ip)) if ip_is_private(ip.into()) => Err(blocked), + Some(url::Host::Domain(d)) if is_internal_hostname(d) => Err(blocked), + Some(_) => Ok(()), + } +} + +fn is_internal_hostname(host: &str) -> bool { + let h = host.trim_end_matches('.').to_ascii_lowercase(); + h.is_empty() + || h == "localhost" + || h.ends_with(".localhost") + || h.ends_with(".internal") + || h.ends_with(".local") + || h.ends_with(".localdomain") +} + +/// Reject the whole record set if any address is non-public (a mixed +/// public/private answer is a rebinding signal). +fn vet_addrs(addrs: &[std::net::IpAddr]) -> Result<(), String> { + if addrs.is_empty() { + return Err("webhook host did not resolve".to_string()); + } + if addrs.iter().any(|ip| ip_is_private(*ip)) { + return Err("webhook URL points to private/internal address".to_string()); + } + Ok(()) +} + /// Best-effort webhook dispatch with SSRF protection. +/// +/// DNS is resolved exactly once; every address is vetted and the connection is +/// pinned to the vetted addresses (no second lookup, so no rebinding window). async fn dispatch_webhook(rule: &AlertRule, issue: &Issue) -> anyhow::Result<()> { let url = rule .action_config @@ -109,19 +147,37 @@ async fn dispatch_webhook(rule: &AlertRule, issue: &Issue) -> anyhow::Result<()> .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("no url in action_config"))?; - // SSRF protection: enforce HTTPS scheme - let parsed_url = url::Url::parse(url).map_err(|e| anyhow::anyhow!("invalid webhook URL: {e}"))?; - if parsed_url.scheme() != "https" { - anyhow::bail!("webhook URL must use HTTPS (got: {})", parsed_url.scheme()); + let parsed_url = validate_webhook_url(url).map_err(|e| anyhow::anyhow!(e))?; + let host = parsed_url.host_str().unwrap_or_default().to_string(); + let port = parsed_url.port_or_known_default().unwrap_or(443); + + let addrs: Vec = match parsed_url.host() { + Some(url::Host::Ipv4(ip)) => vec![std::net::SocketAddr::new(ip.into(), port)], + Some(url::Host::Ipv6(ip)) => vec![std::net::SocketAddr::new(ip.into(), port)], + _ => { + let lookup = host.clone(); + tokio::task::spawn_blocking(move || dns_resolve_host(&lookup, port)) + .await + .map_err(|e| anyhow::anyhow!("dns task failed: {e}"))? + .map_err(|e| anyhow::anyhow!("webhook DNS resolution failed: {e}"))? + } + }; + let ips: Vec = addrs.iter().map(|a| a.ip()).collect(); + if let Err(e) = vet_addrs(&ips) { + tracing::warn!("Webhook blocked (host {host}): {e}"); + anyhow::bail!(e); } - // SSRF protection: block internal/private IPs (includes DNS resolution check) - let url_owned = url.to_string(); - let is_private = tokio::task::spawn_blocking(move || is_private_url(&url_owned)).await.unwrap_or(true); - if is_private { - tracing::warn!("Webhook URL blocked (private/internal IP): {url}"); - anyhow::bail!("webhook URL points to private/internal address"); + // Per-request client pinned to the vetted addresses; redirects disabled. + // no_proxy: an env proxy would resolve the host itself and defeat pinning. + let mut builder = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(5)); + if matches!(parsed_url.host(), Some(url::Host::Domain(_))) { + builder = builder.resolve_to_addrs(&host, &addrs); } + let client = builder.build()?; let payload = serde_json::json!({ "rule": rule.name, @@ -138,10 +194,10 @@ async fn dispatch_webhook(rule: &AlertRule, issue: &Issue) -> anyhow::Result<()> } }); - let resp = REQWEST_CLIENT.post(url).json(&payload).timeout(std::time::Duration::from_secs(5)).send().await?; + let resp = client.post(parsed_url).json(&payload).timeout(std::time::Duration::from_secs(5)).send().await?; if resp.status().is_success() { - tracing::info!("Webhook dispatched to {url} for rule '{}'", rule.name); + tracing::info!("Webhook dispatched to {host} for rule '{}'", rule.name); } else { let status = resp.status(); tracing::warn!("Webhook returned {status} for rule '{}'", rule.name); @@ -150,62 +206,89 @@ async fn dispatch_webhook(rule: &AlertRule, issue: &Issue) -> anyhow::Result<()> Ok(()) } -/// Check if a URL points to a private/internal IP address (SSRF protection). -/// -/// Resolves DNS to check actual IPs — mitigates DNS rebinding attacks. +/// Check if a URL points to a private/internal address (any scheme; resolves +/// DNS and requires every resolved address to be public). +#[cfg(test)] fn is_private_url(url: &str) -> bool { let Ok(parsed) = url::Url::parse(url) else { - return true; // invalid URL = block - }; - let Some(host) = parsed.host_str() else { return true; }; - - // Block known internal hostnames - if matches!(host, "localhost" | "0.0.0.0") || host.ends_with(".internal") || host.ends_with(".local") { + if check_host(&parsed).is_err() { return true; } - - // If host is a literal IP, check directly - if let Ok(ip) = host.parse::() { - return ip_is_private(ip); - } - - // Resolve DNS and check all resulting IPs - let Ok(addrs) = dns_resolve_host(host) else { - return true; // unresolvable = block - }; - if addrs.is_empty() { - return true; + match parsed.host() { + Some(url::Host::Domain(d)) => match dns_resolve_host(d, 443) { + Ok(addrs) => { + let ips: Vec<_> = addrs.iter().map(|a| a.ip()).collect(); + vet_addrs(&ips).is_err() + } + Err(_) => true, + }, + Some(_) => false, + None => true, } - addrs.iter().all(|ip| ip_is_private(*ip)) } -/// Check if an IP is loopback, private (RFC 1918), link-local, or unspecified. +/// True if the address is not a public, globally routable unicast address. fn ip_is_private(ip: std::net::IpAddr) -> bool { match ip { - std::net::IpAddr::V4(v4) => { - v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() - // 100.64.0.0/10 — Carrier-grade NAT (RFC 6598) - || matches!(v4.octets(), [100, 64..=127, ..]) - // 169.254.0.0/16 — link-local (covered by is_link_local but explicit) - || matches!(v4.octets(), [127, ..]) // loopback range - } + std::net::IpAddr::V4(v4) => ipv4_is_private(v4), std::net::IpAddr::V6(v6) => { - v6.is_loopback() || v6.is_unspecified() - // fe80::/10 — IPv6 link-local - || matches!(v6.segments(), [0xfe80..=0xfebf, ..]) + // Unwrap ::ffff:a.b.c.d to IPv4. + if let Some(v4) = v6.to_ipv4_mapped() { + return ipv4_is_private(v4); + } + let seg = v6.segments(); + // 64:ff9b::/96 NAT64: judge the embedded IPv4. + if seg[..6] == [0x64, 0xff9b, 0, 0, 0, 0] { + let [a, b] = seg[6].to_be_bytes(); + let [c, d] = seg[7].to_be_bytes(); + return ipv4_is_private(std::net::Ipv4Addr::new(a, b, c, d)); + } + // IPv4-compatible ::a.b.c.d (includes :: and ::1). + if seg[..6] == [0; 6] { + return true; + } + v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() + // fc00::/7 ULA + || (seg[0] & 0xfe00) == 0xfc00 + // fe80::/10 link-local, fec0::/10 site-local (deprecated) + || (seg[0] & 0xffc0) == 0xfe80 + || (seg[0] & 0xffc0) == 0xfec0 + // 64:ff9b:1::/48 local-use NAT64, 100::/64 discard + || (seg[0] == 0x64 && seg[1] == 0xff9b && seg[2] == 1) + || (seg[0] == 0x100 && seg[1] == 0 && seg[2] == 0 && seg[3] == 0) } } } -/// Resolve a hostname to IP addresses (blocking DNS lookup). +fn ipv4_is_private(v4: std::net::Ipv4Addr) -> bool { + let o = v4.octets(); + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_unspecified() + || v4.is_broadcast() + || v4.is_multicast() + // 0.0.0.0/8 + || o[0] == 0 + // 100.64.0.0/10 CGNAT (RFC 6598) + || (o[0] == 100 && (64..=127).contains(&o[1])) + // 192.0.0.0/24 IETF protocol assignments + || (o[0] == 192 && o[1] == 0 && o[2] == 0) + // 198.18.0.0/15 benchmarking + || (o[0] == 198 && (o[1] == 18 || o[1] == 19)) + // 240.0.0.0/4 reserved + || o[0] >= 240 +} + +/// Resolve a hostname to socket addresses (blocking DNS lookup). /// Called via spawn_blocking to avoid blocking the tokio runtime. -fn dns_resolve_host(host: &str) -> std::io::Result> { +fn dns_resolve_host(host: &str, port: u16) -> std::io::Result> { use std::net::ToSocketAddrs; - // Append port 443 for resolution (required by ToSocketAddrs) - let socket_addrs: Vec = format!("{host}:443").to_socket_addrs()?.collect(); - Ok(socket_addrs.into_iter().map(|a| a.ip()).collect()) + Ok((host, port).to_socket_addrs()?.collect()) } #[cfg(test)] @@ -303,4 +386,75 @@ mod tests { assert!(is_private_url("https://100.64.0.1/")); assert!(is_private_url("https://100.127.255.254/")); } + + #[test] + fn test_ip_is_private_ipv4_mapped_and_ula() { + for ip in [ + "::ffff:169.254.169.254", + "::ffff:127.0.0.1", + "::ffff:10.0.0.1", + "::ffff:7f00:1", + "fc00::1", + "fd12:3456::1", + "fec0::1", + "64:ff9b::a9fe:a9fe", + "64:ff9b::7f00:1", + "ff02::1", + "::", + "::127.0.0.1", + "0.0.0.1", + "192.0.0.8", + "198.18.0.1", + "198.19.255.255", + "224.0.0.1", + "255.255.255.255", + "240.0.0.1", + ] { + assert!(ip_is_private(ip.parse().unwrap()), "{ip} must be blocked"); + } + assert!(!ip_is_private("2606:4700:4700::1111".parse().unwrap())); + assert!(!ip_is_private("::ffff:8.8.8.8".parse().unwrap())); + assert!(!ip_is_private("198.20.0.1".parse().unwrap())); + } + + #[test] + fn test_is_private_url_literal_encodings() { + for u in [ + "https://[::1]/", + "https://[::ffff:169.254.169.254]/", + "https://[fd00::1]/", + "https://2130706433/", + "https://0x7f000001/", + "https://0177.0.0.1/", + "https://127.1/", + "https://LOCALHOST/", + "https://localhost./", + "https://foo.localhost/", + "https://metadata.google.internal./", + "https://user:pw@127.0.0.1/", + ] { + assert!(is_private_url(u), "{u} must be blocked"); + } + } + + #[test] + fn test_validate_webhook_url_syntax() { + assert!(validate_webhook_url("https://hooks.slack.com/services/T/B/X").is_ok()); + assert!(validate_webhook_url("http://hooks.slack.com/x").is_err()); + assert!(validate_webhook_url("https://127.0.0.1/x").is_err()); + assert!(validate_webhook_url("https://[::ffff:127.0.0.1]/x").is_err()); + assert!(validate_webhook_url("https://user:pw@example.com/x").is_err()); + assert!(validate_webhook_url("https://localhost/x").is_err()); + assert!(validate_webhook_url("nonsense").is_err()); + } + + #[test] + fn test_vet_addrs_rejects_mixed_records() { + let pub_ip: std::net::IpAddr = "8.8.8.8".parse().unwrap(); + let priv_ip: std::net::IpAddr = "10.0.0.1".parse().unwrap(); + assert!(vet_addrs(&[pub_ip, priv_ip]).is_err()); + assert!(vet_addrs(&[priv_ip, pub_ip]).is_err()); + assert!(vet_addrs(&[]).is_err()); + assert!(vet_addrs(&[pub_ip]).is_ok()); + } } diff --git a/crates/trapfalld/src/server.rs b/crates/trapfalld/src/server.rs index 1781fd3..df5d810 100644 --- a/crates/trapfalld/src/server.rs +++ b/crates/trapfalld/src/server.rs @@ -699,6 +699,14 @@ async fn create_alert_rule( let action_config = req.action_config.unwrap_or(serde_json::json!({})); let cooldown = req.cooldown_seconds.unwrap_or(300); + if action_type == "webhook" { + let url = action_config.get("url").and_then(|v| v.as_str()).unwrap_or_default(); + if let Err(e) = crate::alert::validate_webhook_url(url) { + tracing::warn!("Rejected alert rule with invalid webhook URL: {e}"); + return Err(StatusCode::BAD_REQUEST); + } + } + let rule = store .create_alert_rule( &project.id,