1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
12
13use thiserror::Error;
14use url::Url;
15
16use crate::metrics::SecurityMetrics;
17
18pub const ENV_SSRF_ALLOWLIST: &str = "SSRF_ALLOWLIST";
22
23pub const ENV_SSRF_DENYLIST: &str = "SSRF_DENYLIST";
26
27pub const ENV_SSRF_ALLOW_PRIVATE: &str = "SSRF_ALLOW_PRIVATE";
30
31pub const ENV_SSRF_ALLOW_LOOPBACK: &str = "SSRF_ALLOW_LOOPBACK";
34
35pub const ENV_SSRF_ALLOW_LINK_LOCAL: &str = "SSRF_ALLOW_LINK_LOCAL";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum IpClass {
48 Public,
50 Private,
52 Loopback,
54 LinkLocal,
57 Multicast,
59 Reserved,
63}
64
65#[derive(Debug, Error)]
67pub enum SsrfError {
68 #[error("URL has no host component: {0}")]
70 MissingHost(String),
71
72 #[error("DNS resolution failed for host '{host}': {source}")]
75 DnsFailure {
76 host: String,
77 #[source]
78 source: std::io::Error,
79 },
80
81 #[error("DNS resolution returned no addresses for host '{host}'")]
83 NoAddresses { host: String },
84
85 #[error("host '{host}' (resolved to {ip}) is denylisted")]
87 Denylisted { host: String, ip: IpAddr },
88
89 #[error("host '{host}' (resolved to {ip}) blocked: {class:?}")]
91 BlockedClass {
92 host: String,
93 ip: IpAddr,
94 class: IpClass,
95 },
96}
97
98#[derive(Debug, Clone)]
103pub struct SsrfPolicy {
104 allow_private: bool,
105 allow_loopback: bool,
106 allow_link_local: bool,
107 allowlist: Vec<String>,
108 denylist: Vec<String>,
109 metrics: Option<SecurityMetrics>,
110}
111
112impl SsrfPolicy {
113 pub fn new() -> Self {
119 Self {
120 allow_private: false,
121 allow_loopback: false,
122 allow_link_local: false,
123 allowlist: Vec::new(),
124 denylist: Vec::new(),
125 metrics: None,
126 }
127 }
128
129 pub fn from_env() -> Self {
138 Self {
139 allow_private: env_bool(ENV_SSRF_ALLOW_PRIVATE),
140 allow_loopback: env_bool(ENV_SSRF_ALLOW_LOOPBACK),
141 allow_link_local: env_bool(ENV_SSRF_ALLOW_LINK_LOCAL),
142 allowlist: env_csv(ENV_SSRF_ALLOWLIST),
143 denylist: env_csv(ENV_SSRF_DENYLIST),
144 metrics: None,
145 }
146 }
147
148 pub fn with_metrics(mut self, metrics: SecurityMetrics) -> Self {
151 self.metrics = Some(metrics);
152 self
153 }
154
155 pub fn with_allowlist(mut self, hosts: Vec<String>) -> Self {
158 self.allowlist = hosts;
159 self
160 }
161
162 pub fn with_denylist(mut self, hosts: Vec<String>) -> Self {
164 self.denylist = hosts;
165 self
166 }
167
168 pub fn with_allow_private(mut self, allow: bool) -> Self {
170 self.allow_private = allow;
171 self
172 }
173
174 pub fn with_allow_loopback(mut self, allow: bool) -> Self {
176 self.allow_loopback = allow;
177 self
178 }
179
180 pub fn with_allow_link_local(mut self, allow: bool) -> Self {
182 self.allow_link_local = allow;
183 self
184 }
185
186 pub fn classify(ip: IpAddr) -> IpClass {
188 match ip {
189 IpAddr::V4(v4) => classify_v4(v4),
190 IpAddr::V6(v6) => classify_v6(v6),
191 }
192 }
193
194 pub async fn resolve_and_check(&self, url: &Url) -> Result<IpAddr, SsrfError> {
207 let host = url
208 .host_str()
209 .ok_or_else(|| SsrfError::MissingHost(url.to_string()))?;
210 let host_lower = host.to_ascii_lowercase();
211
212 let port = url.port_or_known_default().unwrap_or(80);
215 let lookup_target = format!("{host}:{port}");
216 let mut addrs =
217 tokio::net::lookup_host(&lookup_target)
218 .await
219 .map_err(|e| SsrfError::DnsFailure {
220 host: host.to_string(),
221 source: e,
222 })?;
223 let sock_addr = addrs.next().ok_or_else(|| SsrfError::NoAddresses {
224 host: host.to_string(),
225 })?;
226 let ip = sock_addr.ip();
227
228 if list_contains_host(&self.denylist, &host_lower) {
230 self.record_block(IpClass::Reserved);
231 return Err(SsrfError::Denylisted {
232 host: host.to_string(),
233 ip,
234 });
235 }
236
237 if list_contains_host(&self.allowlist, &host_lower) {
239 return Ok(ip);
240 }
241
242 let class = Self::classify(ip);
243 let permitted = match class {
244 IpClass::Public => true,
245 IpClass::Private => self.allow_private,
246 IpClass::Loopback => self.allow_loopback,
247 IpClass::LinkLocal => self.allow_link_local,
248 IpClass::Multicast | IpClass::Reserved => false,
252 };
253
254 if !permitted {
255 self.record_block(class);
256 return Err(SsrfError::BlockedClass {
257 host: host.to_string(),
258 ip,
259 class,
260 });
261 }
262
263 Ok(ip)
264 }
265
266 fn record_block(&self, class: IpClass) {
267 if let Some(m) = &self.metrics {
268 m.record_ssrf_block(class);
269 }
270 }
271}
272
273impl Default for SsrfPolicy {
274 fn default() -> Self {
275 Self::new()
276 }
277}
278
279fn classify_v4(v4: Ipv4Addr) -> IpClass {
282 let o = v4.octets();
283
284 if o == [169, 254, 169, 254] {
288 return IpClass::Reserved;
289 }
290
291 if v4.is_unspecified() || v4.is_broadcast() || v4.is_documentation() {
292 return IpClass::Reserved;
293 }
294 if v4.is_loopback() {
295 return IpClass::Loopback;
296 }
297 if v4.is_link_local() {
298 return IpClass::LinkLocal;
299 }
300 if v4.is_multicast() {
301 return IpClass::Multicast;
302 }
303 if v4.is_private() {
304 return IpClass::Private;
305 }
306
307 match o[0] {
318 0 => return IpClass::Reserved,
319 100 if (o[1] & 0xC0) == 0x40 => return IpClass::Reserved, 192 if o[1] == 0 && o[2] == 0 => return IpClass::Reserved,
321 192 if o[1] == 88 && o[2] == 99 => return IpClass::Reserved,
322 198 if o[1] == 18 || o[1] == 19 => return IpClass::Reserved,
323 240..=255 => return IpClass::Reserved,
324 _ => {}
325 }
326
327 IpClass::Public
328}
329
330fn classify_v6(v6: Ipv6Addr) -> IpClass {
331 let segs = v6.segments();
333 if segs == [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254] {
334 return IpClass::Reserved;
335 }
336
337 if v6.is_unspecified() {
338 return IpClass::Reserved;
339 }
340 if v6.is_loopback() {
341 return IpClass::Loopback;
342 }
343 if v6.is_multicast() {
344 return IpClass::Multicast;
345 }
346
347 if let Some(v4) = v6.to_ipv4_mapped() {
349 return classify_v4(v4);
350 }
351
352 let octets = v6.octets();
356 if octets[..10] == [0u8; 10] && octets[10] == 0 && octets[11] == 0 {
357 let v4 = Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]);
361 if !v4.is_unspecified() && v4 != Ipv4Addr::new(0, 0, 0, 1) {
362 return classify_v4(v4);
363 }
364 }
365
366 let first = segs[0];
367
368 if first == 0x2002 {
371 let v4 = Ipv4Addr::new(
372 (segs[1] >> 8) as u8,
373 (segs[1] & 0xFF) as u8,
374 (segs[2] >> 8) as u8,
375 (segs[2] & 0xFF) as u8,
376 );
377 return classify_v4(v4);
378 }
379
380 if (first & 0xFFC0) == 0xFE80 {
382 return IpClass::LinkLocal;
383 }
384
385 if (first & 0xFE00) == 0xFC00 {
387 return IpClass::Private;
388 }
389
390 if (first & 0xFFC0) == 0xFEC0 {
392 return IpClass::Private;
393 }
394
395 if first == 0x0100 && segs[1] == 0 && segs[2] == 0 && segs[3] == 0 {
402 return IpClass::Reserved;
403 }
404 if first == 0x2001 && segs[1] == 0x0db8 {
405 return IpClass::Reserved;
406 }
407
408 IpClass::Public
409}
410
411fn list_contains_host(list: &[String], host_lower: &str) -> bool {
414 list.iter().any(|entry| {
415 let e = entry.trim().to_ascii_lowercase();
416 let e_host = e.split(':').next().unwrap_or(&e);
418 !e_host.is_empty() && e_host == host_lower
419 })
420}
421
422fn env_bool(key: &str) -> bool {
423 std::env::var(key)
424 .ok()
425 .map(|v| {
426 let v = v.trim().to_ascii_lowercase();
427 matches!(v.as_str(), "1" | "true" | "yes" | "on")
428 })
429 .unwrap_or(false)
430}
431
432fn env_csv(key: &str) -> Vec<String> {
433 std::env::var(key)
434 .ok()
435 .map(|raw| {
436 raw.split(',')
437 .map(|s| s.trim().to_string())
438 .filter(|s| !s.is_empty())
439 .collect()
440 })
441 .unwrap_or_default()
442}
443
444pub fn is_safe_url(url: &str) -> Result<(), SsrfError> {
471 let parsed = Url::parse(url).map_err(|_| SsrfError::MissingHost(url.to_string()))?;
472 let host = parsed
473 .host()
474 .ok_or_else(|| SsrfError::MissingHost(url.to_string()))?;
475
476 match host {
477 url::Host::Ipv4(v4) => check_ip_safe(&v4.to_string(), IpAddr::V4(v4)),
478 url::Host::Ipv6(v6) => check_ip_safe(&v6.to_string(), IpAddr::V6(v6)),
479 url::Host::Domain(d) => {
480 if is_known_metadata_hostname(d) {
481 return Err(SsrfError::BlockedClass {
482 host: d.to_string(),
483 ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
484 class: IpClass::Reserved,
485 });
486 }
487 Ok(())
488 }
489 }
490}
491
492pub async fn resolve_and_check(host: &str) -> Result<IpAddr, SsrfError> {
504 if is_known_metadata_hostname(host) {
508 return Err(SsrfError::BlockedClass {
509 host: host.to_string(),
510 ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
511 class: IpClass::Reserved,
512 });
513 }
514
515 let lookup_target = if host.contains(':') {
516 host.to_string()
517 } else {
518 format!("{host}:80")
519 };
520 let addrs =
521 tokio::net::lookup_host(&lookup_target)
522 .await
523 .map_err(|e| SsrfError::DnsFailure {
524 host: host.to_string(),
525 source: e,
526 })?;
527
528 let mut first: Option<IpAddr> = None;
529 for sock in addrs {
530 let ip = sock.ip();
531 check_ip_safe(host, ip)?;
532 if first.is_none() {
533 first = Some(ip);
534 }
535 }
536 first.ok_or_else(|| SsrfError::NoAddresses {
537 host: host.to_string(),
538 })
539}
540
541fn check_ip_safe(host: &str, ip: IpAddr) -> Result<(), SsrfError> {
542 let class = SsrfPolicy::classify(ip);
543 match class {
544 IpClass::Public => Ok(()),
545 IpClass::Private
546 | IpClass::Loopback
547 | IpClass::LinkLocal
548 | IpClass::Multicast
549 | IpClass::Reserved => Err(SsrfError::BlockedClass {
550 host: host.to_string(),
551 ip,
552 class,
553 }),
554 }
555}
556
557fn is_known_metadata_hostname(host: &str) -> bool {
558 let host_only = host.split(':').next().unwrap_or(host);
560 let lc = host_only.to_ascii_lowercase();
561 matches!(
565 lc.as_str(),
566 "metadata.google.internal" | "metadata" | "metadata.goog"
567 )
568}
569
570#[cfg(test)]
573mod tests {
574 use super::*;
575 use std::net::{Ipv4Addr, Ipv6Addr};
576
577 #[test]
578 fn classify_rfc1918_private() {
579 assert_eq!(
580 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
581 IpClass::Private
582 );
583 assert_eq!(
584 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))),
585 IpClass::Private
586 );
587 assert_eq!(
588 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))),
589 IpClass::Private
590 );
591 }
592
593 #[test]
594 fn classify_loopback() {
595 assert_eq!(
596 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
597 IpClass::Loopback
598 );
599 assert_eq!(
600 SsrfPolicy::classify(IpAddr::V6(Ipv6Addr::LOCALHOST)),
601 IpClass::Loopback
602 );
603 }
604
605 #[test]
606 fn classify_public() {
607 assert_eq!(
608 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))),
609 IpClass::Public
610 );
611 assert_eq!(
612 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))),
613 IpClass::Public
614 );
615 }
616
617 #[test]
618 fn classify_cloud_metadata() {
619 assert_eq!(
620 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))),
621 IpClass::Reserved
622 );
623 }
624
625 #[test]
626 fn classify_ipv6_link_local() {
627 assert_eq!(
628 SsrfPolicy::classify(IpAddr::V6("fe80::1".parse().unwrap())),
629 IpClass::LinkLocal
630 );
631 }
632
633 #[test]
634 fn classify_ipv6_ula() {
635 assert_eq!(
636 SsrfPolicy::classify(IpAddr::V6("fc00::1".parse().unwrap())),
637 IpClass::Private
638 );
639 assert_eq!(
640 SsrfPolicy::classify(IpAddr::V6("fd12:3456:789a::1".parse().unwrap())),
641 IpClass::Private
642 );
643 }
644
645 #[test]
646 fn classify_ipv6_public() {
647 assert_eq!(
648 SsrfPolicy::classify(IpAddr::V6("2001:4860:4860::8888".parse().unwrap())),
649 IpClass::Public
650 );
651 }
652
653 #[test]
656 fn classify_ipv4_compatible_loopback() {
657 let addr: Ipv6Addr = "::127.0.0.1".parse().unwrap();
659 assert_eq!(
660 SsrfPolicy::classify(IpAddr::V6(addr)),
661 IpClass::Loopback,
662 "::127.0.0.1 must be classified as Loopback"
663 );
664 }
665
666 #[test]
667 fn classify_ipv4_compatible_private_10() {
668 let addr: Ipv6Addr = "::10.0.0.1".parse().unwrap();
670 assert_eq!(
671 SsrfPolicy::classify(IpAddr::V6(addr)),
672 IpClass::Private,
673 "::10.0.0.1 must be classified as Private"
674 );
675 }
676
677 #[test]
678 fn classify_ipv4_compatible_link_local_metadata() {
679 let addr: Ipv6Addr = "::169.254.169.254".parse().unwrap();
681 assert_eq!(
682 SsrfPolicy::classify(IpAddr::V6(addr)),
683 IpClass::Reserved,
684 "::169.254.169.254 must be classified as Reserved (cloud metadata)"
685 );
686 }
687
688 #[test]
689 fn classify_ipv4_compatible_public() {
690 let addr: Ipv6Addr = "::8.8.8.8".parse().unwrap();
692 assert_eq!(
693 SsrfPolicy::classify(IpAddr::V6(addr)),
694 IpClass::Public,
695 "::8.8.8.8 should be classified as Public"
696 );
697 }
698
699 #[test]
700 fn classify_6to4_private() {
701 let addr: Ipv6Addr = "2002:c0a8:0101::".parse().unwrap();
703 assert_eq!(
704 SsrfPolicy::classify(IpAddr::V6(addr)),
705 IpClass::Private,
706 "2002:c0a8:0101:: (6to4 embedding 192.168.1.1) must be Private"
707 );
708 }
709
710 #[test]
711 fn classify_6to4_loopback() {
712 let addr: Ipv6Addr = "2002:7f00:0001::".parse().unwrap();
714 assert_eq!(
715 SsrfPolicy::classify(IpAddr::V6(addr)),
716 IpClass::Loopback,
717 "2002:7f00:0001:: (6to4 embedding 127.0.0.1) must be Loopback"
718 );
719 }
720
721 #[test]
722 fn classify_6to4_metadata() {
723 let addr: Ipv6Addr = "2002:a9fe:a9fe::".parse().unwrap();
725 assert_eq!(
726 SsrfPolicy::classify(IpAddr::V6(addr)),
727 IpClass::Reserved,
728 "2002:a9fe:a9fe:: (6to4 embedding 169.254.169.254) must be Reserved"
729 );
730 }
731
732 #[test]
733 fn classify_6to4_public() {
734 let addr: Ipv6Addr = "2002:0808:0808::".parse().unwrap();
736 assert_eq!(
737 SsrfPolicy::classify(IpAddr::V6(addr)),
738 IpClass::Public,
739 "2002:0808:0808:: (6to4 embedding 8.8.8.8) should be Public"
740 );
741 }
742
743 #[test]
744 fn blocks_ipv4_compatible_in_url() {
745 assert_blocked("http://[::127.0.0.1]/", IpClass::Loopback);
747 assert_blocked("http://[::10.0.0.1]/", IpClass::Private);
748 assert_blocked("http://[::169.254.169.254]/", IpClass::Reserved);
749 }
750
751 #[test]
752 fn blocks_6to4_in_url() {
753 assert_blocked("http://[2002:c0a8:0101::]/", IpClass::Private);
754 assert_blocked("http://[2002:7f00:0001::]/", IpClass::Loopback);
755 assert_blocked("http://[2002:a9fe:a9fe::]/", IpClass::Reserved);
756 }
757
758 #[test]
759 fn default_policy_blocks_private() {
760 let p = SsrfPolicy::new();
761 assert!(!p.allow_private);
762 assert!(!p.allow_loopback);
763 assert!(!p.allow_link_local);
764 }
765
766 fn assert_blocked(url: &str, want_class: IpClass) {
769 match is_safe_url(url) {
770 Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(
771 class, want_class,
772 "url {url} blocked with {class:?}, wanted {want_class:?}"
773 ),
774 Err(other) => panic!("url {url} rejected with unexpected error: {other}"),
775 Ok(()) => panic!("url {url} accepted but expected block for {want_class:?}"),
776 }
777 }
778
779 #[test]
780 fn blocks_rfc1918_addresses() {
781 let cases = [
782 "http://10.0.0.1/",
783 "http://10.255.255.255/",
784 "http://172.16.0.1/",
785 "http://172.31.255.255/",
786 "http://192.168.0.1/",
787 "http://192.168.255.255/",
788 "http://[fc00::1]/",
789 "http://[fd00::1]/",
790 ];
791 for url in cases {
792 assert_blocked(url, IpClass::Private);
793 }
794 }
795
796 #[test]
797 fn blocks_loopback() {
798 assert_blocked("http://127.0.0.1/", IpClass::Loopback);
799 assert_blocked("http://127.255.255.254/", IpClass::Loopback);
800 assert_blocked("http://[::1]/", IpClass::Loopback);
801 }
802
803 #[test]
804 fn blocks_link_local() {
805 assert_blocked("http://169.254.1.1/", IpClass::LinkLocal);
806 assert_blocked("http://169.254.254.254/", IpClass::LinkLocal);
807 assert_blocked("http://[fe80::1]/", IpClass::LinkLocal);
808 }
809
810 #[test]
811 fn blocks_aws_metadata_ip() {
812 assert_blocked(
814 "http://169.254.169.254/latest/meta-data/",
815 IpClass::Reserved,
816 );
817 assert_blocked(
818 "http://[fd00:ec2::254]/latest/meta-data/",
819 IpClass::Reserved,
820 );
821 }
822
823 #[tokio::test]
824 async fn blocks_aws_metadata_hostname() {
825 assert_blocked(
826 "http://metadata.google.internal/computeMetadata/v1/",
827 IpClass::Reserved,
828 );
829 match resolve_and_check("metadata.google.internal").await {
830 Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(class, IpClass::Reserved),
831 other => panic!("expected BlockedClass for metadata.google.internal, got {other:?}"),
832 }
833 match resolve_and_check("metadata").await {
834 Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(class, IpClass::Reserved),
835 other => panic!("expected BlockedClass for bare 'metadata', got {other:?}"),
836 }
837 }
838
839 #[test]
840 fn allows_public_ipv4() {
841 assert!(is_safe_url("https://8.8.8.8/").is_ok());
842 assert!(is_safe_url("https://1.1.1.1/").is_ok());
843 assert!(is_safe_url("https://93.184.216.34/").is_ok());
844 }
845
846 #[test]
847 fn allows_public_ipv6() {
848 assert!(is_safe_url("https://[2001:4860:4860::8888]/").is_ok());
849 assert!(is_safe_url("https://[2606:4700:4700::1111]/").is_ok());
850 }
851
852 #[test]
853 fn rejects_malformed_url() {
854 match is_safe_url("not a url") {
855 Err(SsrfError::MissingHost(_)) => {}
856 other => panic!("expected MissingHost for malformed url, got {other:?}"),
857 }
858 match is_safe_url("") {
859 Err(SsrfError::MissingHost(_)) => {}
860 other => panic!("expected MissingHost for empty url, got {other:?}"),
861 }
862 }
863
864 #[test]
865 fn rejects_http_without_host() {
866 match is_safe_url("file:///etc/passwd") {
867 Err(SsrfError::MissingHost(_)) => {}
868 other => panic!("expected MissingHost for file URL, got {other:?}"),
869 }
870 }
871
872 #[tokio::test]
878 async fn dns_failure_blocks_request() {
879 let result = resolve_and_check("this-host-does-not-exist.invalid").await;
882 match result {
883 Err(SsrfError::DnsFailure { host, .. }) => {
884 assert_eq!(host, "this-host-does-not-exist.invalid");
885 }
886 Err(SsrfError::NoAddresses { host, .. }) => {
887 assert_eq!(host, "this-host-does-not-exist.invalid");
890 }
891 Err(other) => {
892 panic!("expected DnsFailure or NoAddresses for unresolvable host, got {other:?}")
893 }
894 Ok(ip) => panic!("expected DNS failure for unresolvable host, got Ok({ip})"),
895 }
896 }
897
898 #[tokio::test]
899 async fn policy_dns_failure_blocks_request() {
900 let policy = SsrfPolicy::new();
902 let url =
903 Url::parse("https://this-host-does-not-exist.invalid/resource").expect("valid URL");
904 let result = policy.resolve_and_check(&url).await;
905 match result {
906 Err(SsrfError::DnsFailure { host, .. }) => {
907 assert!(host.contains("this-host-does-not-exist.invalid"));
908 }
909 Err(SsrfError::NoAddresses { host, .. }) => {
910 assert!(host.contains("this-host-does-not-exist.invalid"));
911 }
912 Err(other) => panic!("expected DnsFailure/NoAddresses through policy, got {other:?}"),
913 Ok(ip) => panic!("expected DNS failure through policy, got Ok({ip})"),
914 }
915 }
916}