Skip to content
Merged
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
284 changes: 219 additions & 65 deletions crates/trapfalld/src/alert.rs
Original file line number Diff line number Diff line change
@@ -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<reqwest::Client> = 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<Issue> {
let (tx, mut rx) = mpsc::unbounded_channel::<Issue>();
Expand Down Expand Up @@ -101,27 +86,98 @@ 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<url::Url, String> {
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
.get("url")
.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<std::net::SocketAddr> = 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<std::net::IpAddr> = 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,
Expand All @@ -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);
Expand All @@ -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::<std::net::IpAddr>() {
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<Vec<std::net::IpAddr>> {
fn dns_resolve_host(host: &str, port: u16) -> std::io::Result<Vec<std::net::SocketAddr>> {
use std::net::ToSocketAddrs;
// Append port 443 for resolution (required by ToSocketAddrs)
let socket_addrs: Vec<std::net::SocketAddr> = 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)]
Expand Down Expand Up @@ -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());
}
}
8 changes: 8 additions & 0 deletions crates/trapfalld/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading