//! SSRF-safe outbound HTTP for webhook deliveries. //! //! Every destination is checked twice: once when a hook URL is saved ([`validate_url`]) and again //! at send time. Hostnames are resolved by [`PolicyResolver`], which drops internal addresses before //! reqwest can connect, so a DNS rebind cannot slip past the check. Literal IP hosts are checked //! directly, since the resolver is not consulted for them. Redirects are never followed. use std::fmt; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; use forge_core::config::WebhookConfig; use futures_util::StreamExt; use hmac::{Hmac, KeyInit, Mac}; use ipnet::IpNet; use reqwest::Url; use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use reqwest::header::{HeaderName, HeaderValue}; use sha2::Sha256; /// Longest accepted hook URL, in bytes. pub const MAX_URL_LEN: usize = 2048; /// Most response body bytes kept in a [`SendOutcome`]. pub const MAX_BODY_BYTES: usize = 64 * 1024; type HmacSha256 = Hmac; type BoxError = Box; /// One entry of the allow list: an exact hostname (case-insensitive) or a CIDR network. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AllowEntry { Host(String), Net(IpNet), } /// Outbound policy derived from the `[webhooks]` config section. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WebhookPolicy { pub allow_http: bool, pub allowed_hosts: Vec, } impl WebhookPolicy { /// Builds the policy from config. An entry containing `/` must be a CIDR network, a bare IP /// address becomes a single-host network, and anything else is a hostname. pub fn from_config(config: &WebhookConfig) -> Result { let allowed_hosts = config .allowed_hosts .iter() .map(|entry| { let entry = entry.trim(); if entry.contains('/') { IpNet::from_str(entry) .map(AllowEntry::Net) .map_err(|e| format!("invalid allowed_hosts entry `{entry}`: {e}")) } else if let Ok(ip) = IpAddr::from_str(entry) { Ok(AllowEntry::Net(IpNet::from(ip))) } else if entry.is_empty() || entry.contains(char::is_whitespace) { Err(format!("invalid allowed_hosts entry `{entry}`")) } else { Ok(AllowEntry::Host(entry.to_ascii_lowercase())) } }) .collect::, _>>()?; Ok(Self { allow_http: config.allow_http, allowed_hosts, }) } /// True when the hostname is named in the allow list (case-insensitive). pub fn host_allowed(&self, host: &str) -> bool { self.allowed_hosts.iter().any(|entry| match entry { AllowEntry::Host(allowed) => allowed.eq_ignore_ascii_case(host), AllowEntry::Net(_) => false, }) } /// True when the address falls inside a CIDR network in the allow list. pub fn ip_allowed(&self, ip: IpAddr) -> bool { // An IPv4-mapped address matches an IPv4 network, so check both forms. let mapped = ip.to_canonical(); self.allowed_hosts.iter().any(|entry| match entry { AllowEntry::Net(net) => net.contains(&ip) || net.contains(&mapped), AllowEntry::Host(_) => false, }) } } /// Why a hook URL was refused. #[derive(Debug, Clone, PartialEq, Eq)] pub enum UrlError { TooLong, Parse(String), Scheme(String), Userinfo, MissingHost, BadPort, Forbidden(IpAddr), } impl fmt::Display for UrlError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::TooLong => write!(f, "url is longer than {MAX_URL_LEN} bytes"), Self::Parse(e) => write!(f, "invalid url: {e}"), Self::Scheme(s) => write!(f, "url scheme `{s}` is not allowed"), Self::Userinfo => write!(f, "url must not contain a username or password"), Self::MissingHost => write!(f, "url has no host"), Self::BadPort => write!(f, "url port must be between 1 and 65535"), Self::Forbidden(ip) => write!(f, "destination not allowed: {ip}"), } } } impl std::error::Error for UrlError {} /// Checks a hook URL against the policy. Used when a hook is saved and again before each send. pub fn validate_url(url: &str, policy: &WebhookPolicy) -> Result { if url.len() > MAX_URL_LEN { return Err(UrlError::TooLong); } let parsed = Url::parse(url).map_err(|e| UrlError::Parse(e.to_string()))?; match parsed.scheme() { "https" => {} "http" if policy.allow_http => {} other => return Err(UrlError::Scheme(other.to_owned())), } if !parsed.username().is_empty() || parsed.password().is_some() { return Err(UrlError::Userinfo); } // An explicit port is parsed by `Url` (so >65535 fails above); only 0 needs rejecting here. if parsed.port() == Some(0) { return Err(UrlError::BadPort); } let host = parsed.host_str().unwrap_or_default(); if host.is_empty() { return Err(UrlError::MissingHost); } // IPv6 literals come back bracketed, e.g. "[::1]". let bare = host.trim_start_matches('[').trim_end_matches(']'); if let Ok(ip) = bare.parse::() { check_literal(ip, policy)?; } Ok(parsed) } fn check_literal(ip: IpAddr, policy: &WebhookPolicy) -> Result<(), UrlError> { if is_forbidden_ip(ip) && !policy.ip_allowed(ip) { Err(UrlError::Forbidden(ip)) } else { Ok(()) } } /// True for addresses a webhook must not reach by default: loopback, private, link-local, CGNAT, /// unspecified, multicast, broadcast, documentation and reserved ranges. IPv4 addresses embedded /// in IPv6 (mapped, compatible, NAT64, 6to4, Teredo) are judged by the embedded IPv4 address. pub fn is_forbidden_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => is_forbidden_v4(v4), IpAddr::V6(v6) => is_forbidden_v6(v6), } } fn is_forbidden_v4(ip: Ipv4Addr) -> bool { let [a, b, c, _] = ip.octets(); ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() || ip.is_multicast() || ip.is_broadcast() || ip.is_documentation() || a == 0 // 0.0.0.0/8 || (a == 100 && (b & 0xc0) == 64) // 100.64.0.0/10 CGNAT || (a == 192 && b == 0 && c == 0) // 192.0.0.0/24 IETF protocol assignments || (a == 198 && (b & 0xfe) == 18) // 198.18.0.0/15 benchmarking || a >= 240 // 240.0.0.0/4 reserved } fn is_forbidden_v6(ip: Ipv6Addr) -> bool { if let Some(v4) = ip.to_ipv4_mapped() { return is_forbidden_v4(v4); } let s = ip.segments(); if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() { return true; } if (s[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local || (s[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local || (s[0] == 0x2001 && s[1] == 0x0db8) // 2001:db8::/32 documentation || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0) // 100::/64 discard { return true; } // IPv4-compatible ::/96 (deprecated). if s[..6] == [0; 6] { return is_forbidden_v4(v4_from(s[6], s[7])); } // NAT64 well-known prefix 64:ff9b::/96, and the local-use 64:ff9b:1::/48. if s[0] == 0x0064 && s[1] == 0xff9b { return s[2] == 0x0001 || (s[2..6] == [0; 4] && is_forbidden_v4(v4_from(s[6], s[7]))); } // 6to4 2002::/16 embeds the IPv4 address in the second and third segments. if s[0] == 0x2002 { return is_forbidden_v4(v4_from(s[1], s[2])); } // Teredo 2001::/32 embeds the server address and an obfuscated client address. if s[0] == 0x2001 && s[1] == 0 { let client = Ipv4Addr::from(!u32::from(v4_from(s[6], s[7]))); return is_forbidden_v4(v4_from(s[2], s[3])) || is_forbidden_v4(client); } false } fn v4_from(hi: u16, lo: u16) -> Ipv4Addr { let [a, b] = hi.to_be_bytes(); let [c, d] = lo.to_be_bytes(); Ipv4Addr::new(a, b, c, d) } /// Resolves hostnames and drops forbidden addresses, unless the host or the address is allowed. #[derive(Debug)] pub struct PolicyResolver { policy: Arc, } impl Resolve for PolicyResolver { fn resolve(&self, name: Name) -> Resolving { Box::pin(resolve_allowed( self.policy.clone(), name.as_str().to_owned(), )) } } async fn resolve_allowed(policy: Arc, host: String) -> Result { let found: Vec = tokio::net::lookup_host((host.as_str(), 0)).await?.collect(); let host_allowed = policy.host_allowed(&host); let kept: Vec = found .into_iter() .filter(|addr| host_allowed || !is_forbidden_ip(addr.ip()) || policy.ip_allowed(addr.ip())) .collect(); if kept.is_empty() { return Err("destination not allowed".into()); } let addrs: Addrs = Box::new(kept.into_iter()); Ok(addrs) } /// What happened when a delivery was sent. A failure leaves `status` as `None` and sets `error`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SendOutcome { pub status: Option, pub headers: Vec<(String, String)>, /// Response body, at most [`MAX_BODY_BYTES`], decoded lossily as UTF-8. pub body: String, pub error: Option, pub duration_ms: u64, } /// Shared HTTP client for webhook deliveries. Build it once and share it behind an `Arc`. #[derive(Debug)] pub struct HookClient { client: reqwest::Client, policy: Arc, } impl HookClient { pub fn new(policy: WebhookPolicy) -> reqwest::Result> { let policy = Arc::new(policy); let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(20)) .dns_resolver(Arc::new(PolicyResolver { policy: policy.clone(), })) .user_agent(concat!("forge-hookshot/", env!("CARGO_PKG_VERSION"))) .no_proxy() .build()?; Ok(Arc::new(Self { client, policy })) } /// POSTs `body` to `url`. Never follows redirects, and reads at most [`MAX_BODY_BYTES`] of the response. pub async fn send( &self, url: &str, headers: &[(String, String)], body: Vec, ) -> SendOutcome { let start = Instant::now(); let parsed = match validate_url(url, &self.policy) { Ok(parsed) => parsed, Err(e) => return failed(e.to_string(), start), }; let mut request = self.client.post(parsed).body(body); for (name, value) in headers { let name = match HeaderName::from_bytes(name.as_bytes()) { Ok(name) => name, Err(e) => return failed(format!("invalid header name `{name}`: {e}"), start), }; let value = match HeaderValue::from_str(value) { Ok(value) => value, Err(e) => return failed(format!("invalid value for header `{name}`: {e}"), start), }; request = request.header(name, value); } let response = match request.send().await { Ok(response) => response, Err(e) => return failed(error_chain(&e), start), }; let status = response.status().as_u16(); let headers = response .headers() .iter() .map(|(name, value)| { ( name.as_str().to_owned(), String::from_utf8_lossy(value.as_bytes()).into_owned(), ) }) .collect(); let mut buf = Vec::new(); let mut error = None; let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { match chunk { Ok(bytes) => { let room = MAX_BODY_BYTES - buf.len(); buf.extend_from_slice(&bytes[..bytes.len().min(room)]); if buf.len() >= MAX_BODY_BYTES { break; } } Err(e) => { error = Some(error_chain(&e)); break; } } } SendOutcome { status: Some(status), headers, body: String::from_utf8_lossy(&buf).into_owned(), error, duration_ms: elapsed_ms(start), } } } /// Signs a delivery body: `sha256=` followed by the hex HMAC-SHA256 of `body` under `secret`. pub fn sign(secret: &[u8], body: &[u8]) -> String { // HMAC accepts keys of any length, so the error branch is unreachable. let mut mac = match HmacSha256::new_from_slice(secret) { Ok(mac) => mac, Err(_) => return String::new(), }; mac.update(body); format!("sha256={}", hex::encode(mac.finalize().into_bytes())) } fn failed(error: String, start: Instant) -> SendOutcome { SendOutcome { status: None, headers: Vec::new(), body: String::new(), error: Some(error), duration_ms: elapsed_ms(start), } } fn elapsed_ms(start: Instant) -> u64 { u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX) } /// Formats an error with its source chain, so the resolver's "destination not allowed" shows up. fn error_chain(err: &(dyn std::error::Error + 'static)) -> String { let mut out = err.to_string(); let mut source = err.source(); while let Some(next) = source { out.push_str(": "); out.push_str(&next.to_string()); source = next.source(); } out } #[cfg(test)] mod tests { use super::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; fn policy(allow_http: bool, allowed: &[&str]) -> WebhookPolicy { WebhookPolicy::from_config(&WebhookConfig { allow_http, allowed_hosts: allowed.iter().map(|s| (*s).to_owned()).collect(), }) .expect("valid policy") } #[test] fn forbidden_ip_table() { let forbidden = [ "127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0", "::1", "fe80::1", "fc00::1", "::ffff:127.0.0.1", "64:ff9b::7f00:1", "::ffff:10.0.0.1", "2002:7f00:0001::1", "224.0.0.1", "255.255.255.255", "192.0.2.1", "2001:db8::1", "ff02::1", "240.0.0.1", ]; for ip in forbidden { let parsed: IpAddr = ip.parse().expect("ip literal"); assert!(is_forbidden_ip(parsed), "{ip} should be forbidden"); } let allowed = [ "8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "64:ff9b::808:808", ]; for ip in allowed { let parsed: IpAddr = ip.parse().expect("ip literal"); assert!(!is_forbidden_ip(parsed), "{ip} should be allowed"); } } #[test] fn validate_url_cases() { let strict = policy(false, &[]); let lax = policy(true, &[]); assert!(validate_url("https://hooks.example.com/a?b=1", &strict).is_ok()); assert!(validate_url("https://example.com:8443/x", &strict).is_ok()); assert!(matches!( validate_url("http://example.com/", &strict), Err(UrlError::Scheme(_)) )); assert!(validate_url("http://example.com/", &lax).is_ok()); assert!(matches!( validate_url("ftp://example.com/", &lax), Err(UrlError::Scheme(_)) )); assert_eq!( validate_url("https://user:pw@example.com/", &strict), Err(UrlError::Userinfo) ); assert_eq!( validate_url("https://user@example.com/", &strict), Err(UrlError::Userinfo) ); assert_eq!( validate_url("https://example.com:0/", &strict), Err(UrlError::BadPort) ); assert!(matches!( validate_url("https://example.com:70000/", &strict), Err(UrlError::Parse(_)) )); assert!(matches!( validate_url("https://", &strict), Err(UrlError::Parse(_) | UrlError::MissingHost) )); let long = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN)); assert_eq!(validate_url(&long, &strict), Err(UrlError::TooLong)); assert_eq!( validate_url("https://127.0.0.1/", &strict), Err(UrlError::Forbidden("127.0.0.1".parse().expect("ip"))) ); assert!(matches!( validate_url("https://[::1]:8080/", &strict), Err(UrlError::Forbidden(_)) )); assert!(validate_url("https://8.8.8.8/", &strict).is_ok()); } #[test] fn allowed_hosts_cidr_and_hostname() { let p = policy(false, &["Hooks.Internal", "10.20.0.0/16", "192.168.5.7"]); assert!(p.host_allowed("hooks.internal")); assert!(p.host_allowed("HOOKS.INTERNAL")); assert!(!p.host_allowed("other.internal")); assert!(p.ip_allowed("10.20.3.4".parse().expect("ip"))); assert!(!p.ip_allowed("10.21.0.1".parse().expect("ip"))); assert!(p.ip_allowed("192.168.5.7".parse().expect("ip"))); assert!(p.ip_allowed("::ffff:10.20.0.9".parse().expect("ip"))); assert!(validate_url("https://10.20.1.1/hook", &p).is_ok()); assert!(validate_url("https://10.30.1.1/hook", &p).is_err()); assert!( WebhookPolicy::from_config(&WebhookConfig { allow_http: false, allowed_hosts: vec!["10.0.0.0/99".into()], }) .is_err() ); } #[test] fn sign_matches_rfc4231_case_2() { // RFC 4231 test case 2: key "Jefe", data "what do ya want for nothing?". assert_eq!( sign(b"Jefe", b"what do ya want for nothing?"), "sha256=5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" ); } /// Serves one HTTP response per connection with the given status line and body. async fn serve(response: &'static [u8]) -> SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); tokio::spawn(async move { loop { let Ok((mut socket, _)) = listener.accept().await else { return; }; tokio::spawn(async move { let mut buf = Vec::new(); let mut chunk = [0u8; 1024]; loop { match socket.read(&mut chunk).await { Ok(0) | Err(_) => break, Ok(n) => { buf.extend_from_slice(&chunk[..n]); if buf.windows(4).any(|w| w == b"\r\n\r\n") { break; } } } } let _ = socket.write_all(response).await; let _ = socket.shutdown().await; }); } }); addr } fn ok_response() -> &'static [u8] { b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok" } async fn post(client: &HookClient, url: &str) -> SendOutcome { client .send(url, &[("X-Test".into(), "1".into())], b"{}".to_vec()) .await } #[tokio::test] async fn send_rejects_loopback_by_default() { let addr = serve(ok_response()).await; let client = HookClient::new(policy(true, &[])).expect("client"); let outcome = post(&client, &format!("http://127.0.0.1:{}/hook", addr.port())).await; assert_eq!(outcome.status, None); assert!( outcome .error .as_deref() .is_some_and(|e| e.contains("destination not allowed")) ); } #[tokio::test] async fn send_rejects_localhost_name_by_default() { let addr = serve(ok_response()).await; let client = HookClient::new(policy(true, &[])).expect("client"); let outcome = post(&client, &format!("http://localhost:{}/hook", addr.port())).await; assert_eq!(outcome.status, None); assert!(outcome.error.is_some()); } #[tokio::test] async fn send_accepts_allowed_loopback_cidr() { let addr = serve(ok_response()).await; let client = HookClient::new(policy(true, &["127.0.0.1/32"])).expect("client"); let outcome = post(&client, &format!("http://127.0.0.1:{}/hook", addr.port())).await; assert_eq!(outcome.error, None); assert_eq!(outcome.status, Some(200)); assert_eq!(outcome.body, "ok"); assert!(outcome.headers.iter().any(|(k, _)| k == "content-type")); } #[tokio::test] async fn send_accepts_allowed_hostname() { let addr = serve(ok_response()).await; let client = HookClient::new(policy(true, &["localhost"])).expect("client"); let outcome = post(&client, &format!("http://localhost:{}/hook", addr.port())).await; assert_eq!(outcome.error, None); assert_eq!(outcome.status, Some(200)); } #[tokio::test] async fn send_rejects_http_when_not_allowed() { let client = HookClient::new(policy(false, &["127.0.0.1/32"])).expect("client"); let outcome = post(&client, "http://127.0.0.1:9/hook").await; assert_eq!(outcome.status, None); assert!(outcome.error.is_some()); } #[tokio::test] async fn redirects_are_not_followed() { let addr = serve( b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:9/elsewhere\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", ) .await; let client = HookClient::new(policy(true, &["127.0.0.1/32"])).expect("client"); let outcome = post(&client, &format!("http://127.0.0.1:{}/hook", addr.port())).await; assert_eq!(outcome.error, None); assert_eq!(outcome.status, Some(302)); } #[tokio::test] async fn body_is_truncated_to_limit() { let body = "x".repeat(MAX_BODY_BYTES + 50_000); let head = format!( "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); let mut response = head.into_bytes(); response.extend_from_slice(body.as_bytes()); let leaked: &'static [u8] = Box::leak(response.into_boxed_slice()); let addr = serve(leaked).await; let client = HookClient::new(policy(true, &["127.0.0.1/32"])).expect("client"); let outcome = post(&client, &format!("http://127.0.0.1:{}/hook", addr.port())).await; assert_eq!(outcome.status, Some(200)); assert_eq!(outcome.body.len(), MAX_BODY_BYTES); } }