1818
1919use std:: {
2020 collections:: HashMap ,
21+ net:: { IpAddr , SocketAddr , ToSocketAddrs } ,
2122 sync:: { Arc , Mutex } ,
2223 time:: Duration ,
2324} ;
@@ -473,12 +474,164 @@ impl TargetType {
473474 TargetType :: AlertManager ( target) => target. call ( payload) . await ,
474475 }
475476 }
477+
478+ pub fn endpoint_url ( & self ) -> & Url {
479+ match self {
480+ TargetType :: Slack ( h) => & h. endpoint ,
481+ TargetType :: Other ( h) => & h. endpoint ,
482+ TargetType :: AlertManager ( h) => & h. endpoint ,
483+ }
484+ }
476485}
477486
478487fn default_client_builder ( ) -> ClientBuilder {
479488 ClientBuilder :: new ( )
480489}
481490
491+ fn mask_endpoint ( url : & Url ) -> String {
492+ let s = url. to_string ( ) ;
493+ format ! ( "{}********" , & s[ ..4 ] )
494+ }
495+
496+ fn is_private_ip ( ip : IpAddr ) -> bool {
497+ match ip {
498+ IpAddr :: V4 ( v4) => {
499+ v4. is_loopback ( )
500+ || v4. is_private ( )
501+ || v4. is_link_local ( )
502+ || v4. is_broadcast ( )
503+ || v4. is_multicast ( )
504+ || v4. is_unspecified ( )
505+ // "This network" 0.0.0.0/8 — on some systems 0.x.x.x routes to localhost
506+ || v4. octets ( ) [ 0 ] == 0
507+ }
508+ IpAddr :: V6 ( v6) => {
509+ v6. is_loopback ( )
510+ || v6. is_multicast ( )
511+ || v6. is_unspecified ( )
512+ // Unique local addresses (ULA): fc00::/7
513+ || ( v6. segments ( ) [ 0 ] & 0xfe00 ) == 0xfc00
514+ // Link-local: fe80::/10
515+ || ( v6. segments ( ) [ 0 ] & 0xffc0 ) == 0xfe80
516+ // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
517+ || v6. to_ipv4_mapped ( ) . is_some_and ( |v4| is_private_ip ( IpAddr :: V4 ( v4) ) )
518+ }
519+ }
520+ }
521+
522+ /// Validate a target endpoint URL to prevent SSRF.
523+ /// Blocks private/loopback/link-local IP addresses (both literal and DNS-resolved).
524+ pub async fn validate_target_endpoint ( url : & Url ) -> Result < ( ) , AlertError > {
525+ match url. scheme ( ) {
526+ "http" | "https" => { }
527+ _ => {
528+ return Err ( AlertError :: CustomError (
529+ "Target endpoint must use http or https scheme" . into ( ) ,
530+ ) ) ;
531+ }
532+ }
533+
534+ let host = url
535+ . host ( )
536+ . ok_or_else ( || AlertError :: CustomError ( "Target endpoint must have a valid host" . into ( ) ) ) ?;
537+
538+ match host {
539+ url:: Host :: Ipv4 ( v4) => {
540+ if is_private_ip ( IpAddr :: V4 ( v4) ) {
541+ return Err ( AlertError :: CustomError (
542+ "Target endpoint must not point to a private or internal address" . into ( ) ,
543+ ) ) ;
544+ }
545+ }
546+ url:: Host :: Ipv6 ( v6) => {
547+ if is_private_ip ( IpAddr :: V6 ( v6) ) {
548+ return Err ( AlertError :: CustomError (
549+ "Target endpoint must not point to a private or internal address" . into ( ) ,
550+ ) ) ;
551+ }
552+ }
553+ url:: Host :: Domain ( hostname) => {
554+ let port = url. port_or_known_default ( ) . unwrap_or ( 80 ) ;
555+ let addr = format ! ( "{hostname}:{port}" ) ;
556+ let resolved = tokio:: task:: spawn_blocking ( move || {
557+ addr. to_socket_addrs ( ) . map ( |i| i. collect :: < Vec < _ > > ( ) )
558+ } )
559+ . await
560+ . map_err ( |e| AlertError :: CustomError ( format ! ( "DNS resolution task failed: {e}" ) ) ) ?;
561+
562+ let addrs = resolved. map_err ( |e| {
563+ AlertError :: CustomError ( format ! ( "Could not resolve target endpoint host: {e}" ) )
564+ } ) ?;
565+ for sock_addr in addrs {
566+ if is_private_ip ( sock_addr. ip ( ) ) {
567+ return Err ( AlertError :: CustomError (
568+ "Target endpoint must not point to a private or internal address" . into ( ) ,
569+ ) ) ;
570+ }
571+ }
572+ }
573+ }
574+
575+ Ok ( ( ) )
576+ }
577+
578+ /// Resolve DNS and validate IPs at call time to prevent DNS rebinding.
579+ /// Returns `Some((hostname, addr))` for domain-based URLs (to pin via `ClientBuilder::resolve`),
580+ /// or `None` for IP-literal URLs (no pinning needed).
581+ async fn resolve_and_pin ( url : & Url ) -> Result < Option < ( String , SocketAddr ) > , String > {
582+ let host = url. host ( ) . ok_or ( "No host in target URL" ) ?;
583+
584+ match host {
585+ url:: Host :: Ipv4 ( v4) => {
586+ if is_private_ip ( IpAddr :: V4 ( v4) ) {
587+ return Err ( "Target resolves to a private/internal address" . into ( ) ) ;
588+ }
589+ Ok ( None )
590+ }
591+ url:: Host :: Ipv6 ( v6) => {
592+ if is_private_ip ( IpAddr :: V6 ( v6) ) {
593+ return Err ( "Target resolves to a private/internal address" . into ( ) ) ;
594+ }
595+ Ok ( None )
596+ }
597+ url:: Host :: Domain ( hostname) => {
598+ let port = url. port_or_known_default ( ) . unwrap_or ( 80 ) ;
599+ let addr_str = format ! ( "{hostname}:{port}" ) ;
600+ let hostname = hostname. to_string ( ) ;
601+ let addrs = tokio:: task:: spawn_blocking ( move || {
602+ addr_str. to_socket_addrs ( ) . map ( |i| i. collect :: < Vec < _ > > ( ) )
603+ } )
604+ . await
605+ . map_err ( |e| format ! ( "DNS resolution task failed: {e}" ) ) ?
606+ . map_err ( |e| format ! ( "Could not resolve host: {e}" ) ) ?;
607+
608+ for sock_addr in & addrs {
609+ if is_private_ip ( sock_addr. ip ( ) ) {
610+ return Err ( "Target resolves to a private/internal address" . into ( ) ) ;
611+ }
612+ }
613+
614+ let first = addrs
615+ . into_iter ( )
616+ . next ( )
617+ . ok_or ( "DNS resolution returned no addresses" ) ?;
618+ Ok ( Some ( ( hostname, first) ) )
619+ }
620+ }
621+ }
622+
623+ /// Apply DNS pinning to a `ClientBuilder` to prevent DNS rebinding attacks.
624+ /// Resolves the endpoint, validates all IPs, and pins the connection to the validated address.
625+ async fn apply_dns_pinning (
626+ mut builder : ClientBuilder ,
627+ endpoint : & Url ,
628+ ) -> Result < ClientBuilder , String > {
629+ if let Some ( ( host, addr) ) = resolve_and_pin ( endpoint) . await ? {
630+ builder = builder. resolve ( & host, addr) ;
631+ }
632+ Ok ( builder)
633+ }
634+
482635#[ derive( Debug , Clone , PartialEq , Eq , serde:: Serialize , serde:: Deserialize ) ]
483636pub struct SlackWebHook {
484637 endpoint : Url ,
@@ -487,7 +640,17 @@ pub struct SlackWebHook {
487640#[ async_trait]
488641impl CallableTarget for SlackWebHook {
489642 async fn call ( & self , payload : & Context ) {
490- let client = default_client_builder ( )
643+ let builder = match apply_dns_pinning ( default_client_builder ( ) , & self . endpoint ) . await {
644+ Ok ( b) => b,
645+ Err ( e) => {
646+ error ! (
647+ "SSRF protection blocked request to {}: {e}" ,
648+ mask_endpoint( & self . endpoint)
649+ ) ;
650+ return ;
651+ }
652+ } ;
653+ let client = builder
491654 . build ( )
492655 . expect ( "Client can be constructed on this system" ) ;
493656
@@ -526,6 +689,16 @@ impl CallableTarget for OtherWebHook {
526689 if self . skip_tls_check {
527690 builder = builder. danger_accept_invalid_certs ( true )
528691 }
692+ builder = match apply_dns_pinning ( builder, & self . endpoint ) . await {
693+ Ok ( b) => b,
694+ Err ( e) => {
695+ error ! (
696+ "SSRF protection blocked request to {}: {e}" ,
697+ mask_endpoint( & self . endpoint)
698+ ) ;
699+ return ;
700+ }
701+ } ;
529702
530703 let client = builder
531704 . build ( )
@@ -576,6 +749,17 @@ impl CallableTarget for AlertManager {
576749 builder = builder. default_headers ( headers)
577750 }
578751
752+ builder = match apply_dns_pinning ( builder, & self . endpoint ) . await {
753+ Ok ( b) => b,
754+ Err ( e) => {
755+ error ! (
756+ "SSRF protection blocked request to {}: {e}" ,
757+ mask_endpoint( & self . endpoint)
758+ ) ;
759+ return ;
760+ }
761+ } ;
762+
579763 let client = builder
580764 . build ( )
581765 . expect ( "Client can be constructed on this system" ) ;
0 commit comments