fix(filter): enforce real IP/CIDR SSRF checks on webhook targets #243

Merged
KofTwentyTwo merged 1 commits from story/m6-webhook-ssrf into dev 2026-07-30 18:10:07 +00:00
2 changed files with 206 additions and 9 deletions

No files matched your search

+142 -8
View File
@@ -33,7 +33,10 @@ pub struct ValidationOptions {
pub available_folders: Option<Vec<String>>,
/// Optional domain whitelist for Pass 6 `FORWARD TO` actions.
pub allowed_forward_domains: Option<Vec<String>>,
/// 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::<std::net::IpAddr>() {
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");
}
}
}
+64 -1
View File
@@ -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<Sha256>;
/// 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<IpAddr> {
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<IpAddr> = 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<IpAddr> = 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<IpAddr> = vec![
"93.184.216.34".parse().unwrap(),
"172.32.0.1".parse().unwrap(),
];
assert_eq!(first_blocked_address(&addrs), None);
}
}