use std::net::IpAddr;
use base64::Engine as _;
use tonic::Status;
use super::errors::{
webhook_host_blocked_address_status, webhook_host_no_addresses_status,
webhook_host_unresolved_status, webhook_url_violation,
};
pub(crate) fn ip_is_blocked(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() || o[0] == 0 || (o[0] == 100 && (o[1] & 0xc0) == 0x40) || o[0] >= 224 }
IpAddr::V6(v6) => {
if let Some(mapped) = v6.to_ipv4_mapped() {
return ip_is_blocked(IpAddr::V4(mapped));
}
let seg0 = v6.segments()[0];
v6.is_loopback() || v6.is_unspecified() || (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 }
}
}
fn parse_webhook_target(url: &str) -> Result<(String, u16), Status> {
let raw = url.trim();
let scheme_ok = raw
.get(..8)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"));
if !scheme_ok {
return Err(webhook_url_violation(
"must use https scheme",
"webhook url must use https (cleartext http and non-http schemes are rejected)",
));
}
let rest = &raw[8..];
let authority_end = rest.find(['/', '?', '#', '\\']).unwrap_or(rest.len());
let authority = &rest[..authority_end];
let hostport = authority.rsplit('@').next().unwrap_or(authority);
let (host, port) = if let Some(after_bracket) = hostport.strip_prefix('[') {
let close = after_bracket.find(']').ok_or_else(|| {
webhook_url_violation(
"must contain a well-formed bracketed IPv6 host",
"webhook url has a malformed IPv6 host",
)
})?;
let addr = &after_bracket[..close];
let port = after_bracket[close + 1..]
.strip_prefix(':')
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(443);
(addr.to_string(), port)
} else if let Some(idx) = hostport.find(':') {
let port = hostport[idx + 1..].parse::<u16>().unwrap_or(443);
(hostport[..idx].to_string(), port)
} else {
(hostport.to_string(), 443)
};
if host.trim().is_empty() || host.chars().any(char::is_whitespace) {
return Err(webhook_url_violation(
"must include a valid external host",
"webhook url must include a valid host",
));
}
Ok((host, port))
}
pub(crate) fn validate_webhook_target_url(url: &str) -> Result<(), Status> {
let (host, _port) = parse_webhook_target(url)?;
if let Ok(ip) = host.parse::<IpAddr>() {
if ip_is_blocked(ip) {
return Err(webhook_url_violation(
"must not target private, loopback, link-local, CGNAT, unspecified, multicast, or reserved IP ranges",
format!(
"webhook url host {host} resolves to a private/loopback/link-local address (SSRF blocked)"
),
));
}
} else {
let lower = host.to_ascii_lowercase();
if lower == "localhost" || lower.ends_with(".localhost") {
return Err(webhook_url_violation(
"must not target localhost hostnames",
"webhook url host localhost is not an allowed external target (SSRF blocked)",
));
}
}
Ok(())
}
#[allow(dead_code)]
pub(crate) async fn resolve_and_validate_target(url: &str) -> Result<(), Status> {
validate_webhook_target_url(url)?;
let (host, port) = parse_webhook_target(url)?;
if host.parse::<IpAddr>().is_ok() {
return Ok(());
}
let mut resolved = tokio::net::lookup_host((host.as_str(), port))
.await
.map_err(|err| {
webhook_host_unresolved_status(&host, err)
})?;
let mut saw_any = false;
for addr in &mut resolved {
saw_any = true;
if ip_is_blocked(addr.ip()) {
return Err(webhook_host_blocked_address_status(&host, addr.ip()));
}
}
if !saw_any {
return Err(webhook_host_no_addresses_status(&host));
}
Ok(())
}
fn hex_lower(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
pub(crate) fn sign_webhook_body(secret: &str, body: &[u8]) -> String {
let mac = crate::runtime::security::hmac_sha256(secret.as_bytes(), body);
format!("sha256={}", hex_lower(&mac))
}
pub(crate) fn generate_signing_secret() -> String {
let mut bytes = Vec::with_capacity(32);
bytes.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
bytes.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
pub(crate) fn topic_matches_pattern(pattern: &str, topic: &str) -> bool {
let pattern = pattern.trim();
let topic = topic.trim();
if pattern.is_empty() || topic.is_empty() {
return false;
}
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
return topic.starts_with(prefix);
}
pattern == topic
}
pub(crate) fn webhook_event_matches_endpoint_scope(
endpoint_tenant: &str,
endpoint_pattern: &str,
topic: &str,
payload: &serde_json::Value,
) -> bool {
let endpoint_tenant = endpoint_tenant.trim();
if endpoint_tenant.is_empty() {
return false;
}
let event_tenant = payload
.get("tenant_id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.trim();
if event_tenant.is_empty() || event_tenant != endpoint_tenant {
return false;
}
topic_matches_pattern(endpoint_pattern, topic)
}