From fb69e335ea41e6cd046c29bb667ae05bdee2b475 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 30 Jul 2026 13:03:12 -0500 Subject: [PATCH] fix(filter): enforce real IP/CIDR SSRF checks on webhook targets Replace the substring blocklist in pass 6 with parsed IP-literal range checks and add a dispatch-time DNS-resolution check so hostnames that resolve into private/metadata ranges are rejected before connecting. Refs #203 --- crates/nuncio-filter/src/validator.rs | 150 ++++++++++++++++++++++++-- crates/nuncio-filter/src/webhook.rs | 65 ++++++++++- 2 files changed, 206 insertions(+), 9 deletions(-) diff --git a/crates/nuncio-filter/src/validator.rs b/crates/nuncio-filter/src/validator.rs index ff67cbd..7975e55 100644 --- a/crates/nuncio-filter/src/validator.rs +++ b/crates/nuncio-filter/src/validator.rs @@ -33,7 +33,10 @@ pub struct ValidationOptions { pub available_folders: Option>, /// Optional domain whitelist for Pass 6 `FORWARD TO` actions. pub allowed_forward_domains: Option>, - /// Flag enabling Pass 6 pre-flight DNS IP blacklisting for webhooks. + /// When set, reject webhook targets that point at private, loopback, + /// link-local, or metadata address ranges. The validator checks literal-IP + /// hosts; the dispatcher additionally checks addresses a hostname resolves + /// to before connecting. pub block_private_webhooks: bool, } @@ -278,13 +281,25 @@ impl NsqlValidator { ))); } if opts.block_private_webhooks { - let url_lower = url.to_lowercase(); - if url_lower.contains("127.0.0.1") - || url_lower.contains("localhost") - || url_lower.contains("169.254.169.254") - || url_lower.contains("0.0.0.0") - { - return Err(ValidationError::ActionSecurityViolation(format!("CALL WEBHOOK URL '{url}' points to blocked private / metadata IP address"))); + // When the host is an IP literal, reject any that lands in a + // blocked range. A substring match on the raw URL is not a + // real defense: RFC 1918, alternate literal encodings, and + // IPv6 all slip past it. Hostnames cannot be resolved here + // (this pass is synchronous); the dispatcher performs the + // DNS-resolution check before it connects. + if let Ok(parsed) = reqwest::Url::parse(url) { + if let Some(host) = parsed.host_str() { + // `host_str` brackets IPv6 literals (`[::1]`); strip + // them so the address parses. + let host = host.trim_start_matches('[').trim_end_matches(']'); + if let Ok(ip) = host.parse::() { + if is_blocked_webhook_target(ip) { + return Err(ValidationError::ActionSecurityViolation(format!( + "CALL WEBHOOK URL '{url}' points to a blocked private / metadata address" + ))); + } + } + } } } } @@ -309,6 +324,51 @@ fn is_valid_email_format(email: &str) -> bool { && !parts[1].ends_with('.') } +/// Reports whether `ip` falls in a range that must never be a webhook target. +/// +/// This is the core SSRF egress control. It blocks loopback, RFC 1918 / ULA +/// private space, link-local (which subsumes the `169.254.169.254` cloud +/// metadata endpoint), the unspecified address, broadcast, and multicast. +/// It is shared by the synchronous validator (literal IPs in the URL) and the +/// dispatcher (addresses a hostname resolves to) so both apply identical rules. +pub(crate) fn is_blocked_webhook_target(ip: std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(v4) => is_blocked_ipv4(v4), + std::net::IpAddr::V6(v6) => { + // Unwrap IPv4-mapped addresses (`::ffff:a.b.c.d`) first: an embedded + // private or loopback IPv4 must be caught by the v4 rules and not + // slip through as an ordinary v6 address. + match v6.to_ipv4_mapped() { + Some(mapped) => is_blocked_ipv4(mapped), + None => is_blocked_ipv6(v6), + } + } + } +} + +fn is_blocked_ipv4(ip: std::net::Ipv4Addr) -> bool { + // `is_private` implements the exact RFC 1918 blocks (10/8, 172.16/12, + // 192.168/16), so boundaries such as 172.32.0.0 stay allowed. The leading + // `0` octet covers 0.0.0.0/8 (this-network, wider than `is_unspecified`). + ip.octets()[0] == 0 + || ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_multicast() + || ip.is_broadcast() +} + +fn is_blocked_ipv6(ip: std::net::Ipv6Addr) -> bool { + if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() { + return true; + } + // `is_unique_local` / `is_unicast_link_local` are not stable on the pinned + // toolchain, so match the prefixes by hand: fc00::/7 (unique-local) and + // fe80::/10 (link-local unicast). + let first = ip.segments()[0]; + (first & 0xfe00) == 0xfc00 || (first & 0xffc0) == 0xfe80 +} + #[cfg(test)] mod tests { use super::*; @@ -372,4 +432,78 @@ mod tests { let err = NsqlValidator::validate(&rule, &ValidationOptions::default()).unwrap_err(); assert!(matches!(err, ValidationError::ActionSecurityViolation(_))); } + + #[test] + fn test_pass6_rejects_literal_ip_bypasses() { + // Encodings the old case-insensitive substring blocklist let through. + let blocked_urls = [ + "http://10.1.2.3/hook", + "http://172.16.0.1/hook", + "http://192.168.1.1/hook", + "http://169.254.169.254/latest/meta-data/", + "http://[::1]/hook", + "http://[fc00::1]/hook", + "http://[fe80::1]/hook", + "http://[::ffff:127.0.0.1]/hook", + ]; + for url in blocked_urls { + let rule = crate::parser::NsqlParser::parse_rule( + "Webhook Bypass", + 1, + &format!("WHERE subject CONTAINS 'x' ACTION CALL WEBHOOK '{url}'"), + ) + .unwrap(); + let err = NsqlValidator::validate(&rule, &ValidationOptions::default()).unwrap_err(); + assert!( + matches!(err, ValidationError::ActionSecurityViolation(_)), + "expected {url} to be blocked" + ); + } + } + + #[test] + fn test_pass6_allows_public_literal_ip() { + let rule = crate::parser::NsqlParser::parse_rule( + "Webhook Public", + 1, + "WHERE subject CONTAINS 'x' ACTION CALL WEBHOOK 'http://93.184.216.34/hook'", + ) + .unwrap(); + assert!(NsqlValidator::validate(&rule, &ValidationOptions::default()).is_ok()); + } + + #[test] + fn test_is_blocked_webhook_target_blocks_private_ranges() { + let blocked: [std::net::IpAddr; 12] = [ + "127.0.0.1".parse().unwrap(), + "10.1.2.3".parse().unwrap(), + "172.16.0.1".parse().unwrap(), + "172.31.255.255".parse().unwrap(), + "192.168.1.1".parse().unwrap(), + "169.254.169.254".parse().unwrap(), + "169.254.0.1".parse().unwrap(), + "0.0.0.0".parse().unwrap(), + "::1".parse().unwrap(), + "fc00::1".parse().unwrap(), + "fe80::1".parse().unwrap(), + "::ffff:127.0.0.1".parse().unwrap(), + ]; + for ip in blocked { + assert!(is_blocked_webhook_target(ip), "{ip} should be blocked"); + } + } + + #[test] + fn test_is_blocked_webhook_target_allows_public() { + // 172.32.0.1 sits just outside 172.16.0.0/12 and proves the range math + // is exact rather than a naive `172.` prefix check. + let allowed: [std::net::IpAddr; 3] = [ + "172.32.0.1".parse().unwrap(), + "93.184.216.34".parse().unwrap(), + "2606:2800:220:1:248:1893:25c8:1946".parse().unwrap(), + ]; + for ip in allowed { + assert!(!is_blocked_webhook_target(ip), "{ip} should be allowed"); + } + } } diff --git a/crates/nuncio-filter/src/webhook.rs b/crates/nuncio-filter/src/webhook.rs index d25dd41..68e7422 100644 --- a/crates/nuncio-filter/src/webhook.rs +++ b/crates/nuncio-filter/src/webhook.rs @@ -1,17 +1,29 @@ //! Outbound HTTP Webhook Execution Engine with HMAC-SHA256 Signatures and SSRF Defense. use crate::ast::RuleAction; -use crate::validator::{NsqlValidator, ValidationOptions}; +use crate::validator::{is_blocked_webhook_target, NsqlValidator, ValidationOptions}; use hmac::{Hmac, Mac}; use reqwest::Client; use serde_json::json; use sha2::Sha256; +use std::net::IpAddr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use thiserror::Error; +use tokio::net::lookup_host; use zeroize::Zeroizing; type HmacSha256 = Hmac; +/// Returns the first address in `addrs` that is a blocked webhook target, if +/// any. Split out from the DNS lookup so the egress policy can be exercised +/// against synthetic address lists without touching the network. +fn first_blocked_address(addrs: &[IpAddr]) -> Option { + addrs + .iter() + .copied() + .find(|ip| is_blocked_webhook_target(*ip)) +} + /// Errors emitted during HTTP webhook dispatch execution. #[derive(Error, Debug)] pub enum WebhookError { @@ -61,6 +73,34 @@ impl WebhookDispatcher { NsqlValidator::pass6_action_security(&[action], opts) .map_err(|e| WebhookError::SecurityViolation(e.to_string()))?; + if opts.block_private_webhooks { + // Pass 6 only sees literal-IP hosts. Resolve the target here and + // reject it if a hostname points into a blocked range, closing the + // DNS-rebind class of SSRF bypass. This is not airtight: reqwest + // re-resolves and connects on its own after this check, so a narrow + // TOCTOU window remains where DNS could flip between our lookup and + // the actual connection. Fully closing it needs a pinned + // resolver/connector, which is out of scope here. + if let Ok(parsed) = reqwest::Url::parse(url) { + if let Some(host) = parsed.host_str() { + // `host_str` brackets IPv6 literals (`[::1]`); strip them so + // the pair passes to the resolver cleanly. + let host = host.trim_start_matches('[').trim_end_matches(']'); + let port = parsed.port_or_known_default().unwrap_or(0); + let resolved: Vec = lookup_host((host, port)) + .await + .map_err(|e| WebhookError::NetworkError(e.to_string()))? + .map(|addr| addr.ip()) + .collect(); + if let Some(blocked) = first_blocked_address(&resolved) { + return Err(WebhookError::SecurityViolation(format!( + "webhook host '{host}' resolves to blocked address {blocked}" + ))); + } + } + } + } + let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -142,4 +182,27 @@ mod tests { WebhookError::SecurityViolation(_) )); } + + #[test] + fn test_first_blocked_address_flags_private_in_mixed_list() { + // Synthetic resolver output: a public address alongside a private one, + // as a rebinding host might return. The private one must be caught. + let addrs: Vec = vec![ + "93.184.216.34".parse().unwrap(), + "10.0.0.5".parse().unwrap(), + ]; + assert_eq!( + first_blocked_address(&addrs), + Some("10.0.0.5".parse().unwrap()) + ); + } + + #[test] + fn test_first_blocked_address_allows_all_public() { + let addrs: Vec = vec![ + "93.184.216.34".parse().unwrap(), + "172.32.0.1".parse().unwrap(), + ]; + assert_eq!(first_blocked_address(&addrs), None); + } } -- 2.54.0