diff --git a/src/alerts/target.rs b/src/alerts/target.rs index ba700ae2a..bc181b902 100644 --- a/src/alerts/target.rs +++ b/src/alerts/target.rs @@ -18,6 +18,7 @@ use std::{ collections::HashMap, + net::{IpAddr, SocketAddr, ToSocketAddrs}, sync::{Arc, Mutex}, time::Duration, }; @@ -473,12 +474,164 @@ impl TargetType { TargetType::AlertManager(target) => target.call(payload).await, } } + + pub fn endpoint_url(&self) -> &Url { + match self { + TargetType::Slack(h) => &h.endpoint, + TargetType::Other(h) => &h.endpoint, + TargetType::AlertManager(h) => &h.endpoint, + } + } } fn default_client_builder() -> ClientBuilder { ClientBuilder::new() } +fn mask_endpoint(url: &Url) -> String { + let s = url.to_string(); + format!("{}********", &s[..4]) +} + +fn is_private_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_multicast() + || v4.is_unspecified() + // "This network" 0.0.0.0/8 — on some systems 0.x.x.x routes to localhost + || v4.octets()[0] == 0 + } + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_multicast() + || v6.is_unspecified() + // Unique local addresses (ULA): fc00::/7 + || (v6.segments()[0] & 0xfe00) == 0xfc00 + // Link-local: fe80::/10 + || (v6.segments()[0] & 0xffc0) == 0xfe80 + // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) + || v6.to_ipv4_mapped().is_some_and(|v4| is_private_ip(IpAddr::V4(v4))) + } + } +} + +/// Validate a target endpoint URL to prevent SSRF. +/// Blocks private/loopback/link-local IP addresses (both literal and DNS-resolved). +pub async fn validate_target_endpoint(url: &Url) -> Result<(), AlertError> { + match url.scheme() { + "http" | "https" => {} + _ => { + return Err(AlertError::CustomError( + "Target endpoint must use http or https scheme".into(), + )); + } + } + + let host = url + .host() + .ok_or_else(|| AlertError::CustomError("Target endpoint must have a valid host".into()))?; + + match host { + url::Host::Ipv4(v4) => { + if is_private_ip(IpAddr::V4(v4)) { + return Err(AlertError::CustomError( + "Target endpoint must not point to a private or internal address".into(), + )); + } + } + url::Host::Ipv6(v6) => { + if is_private_ip(IpAddr::V6(v6)) { + return Err(AlertError::CustomError( + "Target endpoint must not point to a private or internal address".into(), + )); + } + } + url::Host::Domain(hostname) => { + let port = url.port_or_known_default().unwrap_or(80); + let addr = format!("{hostname}:{port}"); + let resolved = tokio::task::spawn_blocking(move || { + addr.to_socket_addrs().map(|i| i.collect::>()) + }) + .await + .map_err(|e| AlertError::CustomError(format!("DNS resolution task failed: {e}")))?; + + let addrs = resolved.map_err(|e| { + AlertError::CustomError(format!("Could not resolve target endpoint host: {e}")) + })?; + for sock_addr in addrs { + if is_private_ip(sock_addr.ip()) { + return Err(AlertError::CustomError( + "Target endpoint must not point to a private or internal address".into(), + )); + } + } + } + } + + Ok(()) +} + +/// Resolve DNS and validate IPs at call time to prevent DNS rebinding. +/// Returns `Some((hostname, addr))` for domain-based URLs (to pin via `ClientBuilder::resolve`), +/// or `None` for IP-literal URLs (no pinning needed). +async fn resolve_and_pin(url: &Url) -> Result, String> { + let host = url.host().ok_or("No host in target URL")?; + + match host { + url::Host::Ipv4(v4) => { + if is_private_ip(IpAddr::V4(v4)) { + return Err("Target resolves to a private/internal address".into()); + } + Ok(None) + } + url::Host::Ipv6(v6) => { + if is_private_ip(IpAddr::V6(v6)) { + return Err("Target resolves to a private/internal address".into()); + } + Ok(None) + } + url::Host::Domain(hostname) => { + let port = url.port_or_known_default().unwrap_or(80); + let addr_str = format!("{hostname}:{port}"); + let hostname = hostname.to_string(); + let addrs = tokio::task::spawn_blocking(move || { + addr_str.to_socket_addrs().map(|i| i.collect::>()) + }) + .await + .map_err(|e| format!("DNS resolution task failed: {e}"))? + .map_err(|e| format!("Could not resolve host: {e}"))?; + + for sock_addr in &addrs { + if is_private_ip(sock_addr.ip()) { + return Err("Target resolves to a private/internal address".into()); + } + } + + let first = addrs + .into_iter() + .next() + .ok_or("DNS resolution returned no addresses")?; + Ok(Some((hostname, first))) + } + } +} + +/// Apply DNS pinning to a `ClientBuilder` to prevent DNS rebinding attacks. +/// Resolves the endpoint, validates all IPs, and pins the connection to the validated address. +async fn apply_dns_pinning( + mut builder: ClientBuilder, + endpoint: &Url, +) -> Result { + if let Some((host, addr)) = resolve_and_pin(endpoint).await? { + builder = builder.resolve(&host, addr); + } + Ok(builder) +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SlackWebHook { endpoint: Url, @@ -487,7 +640,17 @@ pub struct SlackWebHook { #[async_trait] impl CallableTarget for SlackWebHook { async fn call(&self, payload: &Context) { - let client = default_client_builder() + let builder = match apply_dns_pinning(default_client_builder(), &self.endpoint).await { + Ok(b) => b, + Err(e) => { + error!( + "SSRF protection blocked request to {}: {e}", + mask_endpoint(&self.endpoint) + ); + return; + } + }; + let client = builder .build() .expect("Client can be constructed on this system"); @@ -526,6 +689,16 @@ impl CallableTarget for OtherWebHook { if self.skip_tls_check { builder = builder.danger_accept_invalid_certs(true) } + builder = match apply_dns_pinning(builder, &self.endpoint).await { + Ok(b) => b, + Err(e) => { + error!( + "SSRF protection blocked request to {}: {e}", + mask_endpoint(&self.endpoint) + ); + return; + } + }; let client = builder .build() @@ -576,6 +749,17 @@ impl CallableTarget for AlertManager { builder = builder.default_headers(headers) } + builder = match apply_dns_pinning(builder, &self.endpoint).await { + Ok(b) => b, + Err(e) => { + error!( + "SSRF protection blocked request to {}: {e}", + mask_endpoint(&self.endpoint) + ); + return; + } + }; + let client = builder .build() .expect("Client can be constructed on this system"); diff --git a/src/handlers/http/oidc.rs b/src/handlers/http/oidc.rs index 51d1355cd..9a06ff501 100644 --- a/src/handlers/http/oidc.rs +++ b/src/handlers/http/oidc.rs @@ -167,11 +167,23 @@ pub async fn login( } } -pub async fn logout(req: HttpRequest, query: web::Query) -> HttpResponse { +pub async fn logout( + req: HttpRequest, + query: web::Query, +) -> Result { + // Validate redirect URL against server host to prevent open redirect attacks + let conn = req.connection_info().clone(); + let base_url_without_scheme = format!("{}/", conn.host()); + if !is_valid_redirect_url(&base_url_without_scheme, query.redirect.as_str()) { + return Err(OIDCError::BadRequest( + "Bad Request, Invalid Redirect URL!".to_string(), + )); + } + let oidc_client = OIDC_CLIENT.get(); let Some(session) = extract_session_key_from_req(&req).ok() else { - return redirect_to_client(query.redirect.as_str(), None); + return Ok(redirect_to_client(query.redirect.as_str(), None)); }; let tenant_id = get_tenant_id_from_key(&session); let user = Users.remove_session(&session); @@ -181,14 +193,14 @@ pub async fn logout(req: HttpRequest, query: web::Query) -> None }; - match (user, logout_endpoint) { + Ok(match (user, logout_endpoint) { (Some(username), Some(logout_endpoint)) if Users.is_oauth(&username, &tenant_id).unwrap_or_default() => { redirect_to_oidc_logout(logout_endpoint, &query.redirect) } _ => redirect_to_client(query.redirect.as_str(), None), - } + }) } /// Handler for code callback diff --git a/src/handlers/http/targets.rs b/src/handlers/http/targets.rs index 919a40df5..bc635de66 100644 --- a/src/handlers/http/targets.rs +++ b/src/handlers/http/targets.rs @@ -26,7 +26,7 @@ use ulid::Ulid; use crate::{ alerts::{ AlertError, - target::{TARGETS, Target}, + target::{TARGETS, Target, validate_target_endpoint}, }, utils::get_tenant_id_from_request, }; @@ -38,8 +38,7 @@ pub async fn post( ) -> Result { let tenant_id = get_tenant_id_from_request(&req); target.tenant = tenant_id; - // should check for duplicacy and liveness (??) - // add to the map + validate_target_endpoint(target.target.endpoint_url()).await?; TARGETS.update(target.clone()).await?; // Ok(web::Json(target.mask())) @@ -88,11 +87,10 @@ pub async fn update( )); } - // esnure that the supplied target id is assigned to the target config + // ensure that the supplied target id is assigned to the target config target.id = target_id; target.tenant = tenant_id; - // should check for duplicacy and liveness (??) - // add to the map + validate_target_endpoint(target.target.endpoint_url()).await?; TARGETS.update(target.clone()).await?; // Ok(web::Json(target.mask()))