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 = tokio::net::lookup_host(&lookup_target)
217 .await
218 .map_err(|e| SsrfError::DnsFailure {
219 host: host.to_string(),
220 source: e,
221 })?;
222 let sock_addr = addrs.next().ok_or_else(|| SsrfError::NoAddresses {
223 host: host.to_string(),
224 })?;
225 let ip = sock_addr.ip();
226
227 if list_contains_host(&self.denylist, &host_lower) {
229 self.record_block(IpClass::Reserved);
230 return Err(SsrfError::Denylisted {
231 host: host.to_string(),
232 ip,
233 });
234 }
235
236 if list_contains_host(&self.allowlist, &host_lower) {
238 return Ok(ip);
239 }
240
241 let class = Self::classify(ip);
242 let permitted = match class {
243 IpClass::Public => true,
244 IpClass::Private => self.allow_private,
245 IpClass::Loopback => self.allow_loopback,
246 IpClass::LinkLocal => self.allow_link_local,
247 IpClass::Multicast | IpClass::Reserved => false,
251 };
252
253 if !permitted {
254 self.record_block(class);
255 return Err(SsrfError::BlockedClass {
256 host: host.to_string(),
257 ip,
258 class,
259 });
260 }
261
262 Ok(ip)
263 }
264
265 fn record_block(&self, class: IpClass) {
266 if let Some(m) = &self.metrics {
267 m.record_ssrf_block(class);
268 }
269 }
270}
271
272impl Default for SsrfPolicy {
273 fn default() -> Self {
274 Self::new()
275 }
276}
277
278fn classify_v4(v4: Ipv4Addr) -> IpClass {
281 let o = v4.octets();
282
283 if o == [169, 254, 169, 254] {
287 return IpClass::Reserved;
288 }
289
290 if v4.is_unspecified() || v4.is_broadcast() || v4.is_documentation() {
291 return IpClass::Reserved;
292 }
293 if v4.is_loopback() {
294 return IpClass::Loopback;
295 }
296 if v4.is_link_local() {
297 return IpClass::LinkLocal;
298 }
299 if v4.is_multicast() {
300 return IpClass::Multicast;
301 }
302 if v4.is_private() {
303 return IpClass::Private;
304 }
305
306 match o[0] {
317 0 => return IpClass::Reserved,
318 100 if (o[1] & 0xC0) == 0x40 => return IpClass::Reserved, 192 if o[1] == 0 && o[2] == 0 => return IpClass::Reserved,
320 192 if o[1] == 88 && o[2] == 99 => return IpClass::Reserved,
321 198 if o[1] == 18 || o[1] == 19 => return IpClass::Reserved,
322 240..=255 => return IpClass::Reserved,
323 _ => {}
324 }
325
326 IpClass::Public
327}
328
329fn classify_v6(v6: Ipv6Addr) -> IpClass {
330 let segs = v6.segments();
332 if segs == [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254] {
333 return IpClass::Reserved;
334 }
335
336 if v6.is_unspecified() {
337 return IpClass::Reserved;
338 }
339 if v6.is_loopback() {
340 return IpClass::Loopback;
341 }
342 if v6.is_multicast() {
343 return IpClass::Multicast;
344 }
345
346 if let Some(v4) = v6.to_ipv4_mapped() {
348 return classify_v4(v4);
349 }
350
351 let octets = v6.octets();
355 if octets[..10] == [0u8; 10] && octets[10] == 0 && octets[11] == 0 {
356 let v4 = Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]);
360 if !v4.is_unspecified() && v4 != Ipv4Addr::new(0, 0, 0, 1) {
361 return classify_v4(v4);
362 }
363 }
364
365 let first = segs[0];
366
367 if first == 0x2002 {
370 let v4 = Ipv4Addr::new(
371 (segs[1] >> 8) as u8,
372 (segs[1] & 0xFF) as u8,
373 (segs[2] >> 8) as u8,
374 (segs[2] & 0xFF) as u8,
375 );
376 return classify_v4(v4);
377 }
378
379 if (first & 0xFFC0) == 0xFE80 {
381 return IpClass::LinkLocal;
382 }
383
384 if (first & 0xFE00) == 0xFC00 {
386 return IpClass::Private;
387 }
388
389 if (first & 0xFFC0) == 0xFEC0 {
391 return IpClass::Private;
392 }
393
394 if first == 0x0100 && segs[1] == 0 && segs[2] == 0 && segs[3] == 0 {
401 return IpClass::Reserved;
402 }
403 if first == 0x2001 && segs[1] == 0x0db8 {
404 return IpClass::Reserved;
405 }
406
407 IpClass::Public
408}
409
410fn list_contains_host(list: &[String], host_lower: &str) -> bool {
413 list.iter().any(|entry| {
414 let e = entry.trim().to_ascii_lowercase();
415 let e_host = e.split(':').next().unwrap_or(&e);
417 !e_host.is_empty() && e_host == host_lower
418 })
419}
420
421fn env_bool(key: &str) -> bool {
422 std::env::var(key)
423 .ok()
424 .map(|v| {
425 let v = v.trim().to_ascii_lowercase();
426 matches!(v.as_str(), "1" | "true" | "yes" | "on")
427 })
428 .unwrap_or(false)
429}
430
431fn env_csv(key: &str) -> Vec<String> {
432 std::env::var(key)
433 .ok()
434 .map(|raw| {
435 raw.split(',')
436 .map(|s| s.trim().to_string())
437 .filter(|s| !s.is_empty())
438 .collect()
439 })
440 .unwrap_or_default()
441}
442
443pub fn is_safe_url(url: &str) -> Result<(), SsrfError> {
470 let parsed = Url::parse(url).map_err(|_| SsrfError::MissingHost(url.to_string()))?;
471 let host = parsed
472 .host()
473 .ok_or_else(|| SsrfError::MissingHost(url.to_string()))?;
474
475 match host {
476 url::Host::Ipv4(v4) => check_ip_safe(&v4.to_string(), IpAddr::V4(v4)),
477 url::Host::Ipv6(v6) => check_ip_safe(&v6.to_string(), IpAddr::V6(v6)),
478 url::Host::Domain(d) => {
479 if is_known_metadata_hostname(d) {
480 return Err(SsrfError::BlockedClass {
481 host: d.to_string(),
482 ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
483 class: IpClass::Reserved,
484 });
485 }
486 Ok(())
487 }
488 }
489}
490
491pub async fn resolve_and_check(host: &str) -> Result<IpAddr, SsrfError> {
503 if is_known_metadata_hostname(host) {
507 return Err(SsrfError::BlockedClass {
508 host: host.to_string(),
509 ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
510 class: IpClass::Reserved,
511 });
512 }
513
514 let lookup_target = if host.contains(':') {
515 host.to_string()
516 } else {
517 format!("{host}:80")
518 };
519 let addrs = tokio::net::lookup_host(&lookup_target)
520 .await
521 .map_err(|e| SsrfError::DnsFailure {
522 host: host.to_string(),
523 source: e,
524 })?;
525
526 let mut first: Option<IpAddr> = None;
527 for sock in addrs {
528 let ip = sock.ip();
529 check_ip_safe(host, ip)?;
530 if first.is_none() {
531 first = Some(ip);
532 }
533 }
534 first.ok_or_else(|| SsrfError::NoAddresses {
535 host: host.to_string(),
536 })
537}
538
539fn check_ip_safe(host: &str, ip: IpAddr) -> Result<(), SsrfError> {
540 let class = SsrfPolicy::classify(ip);
541 match class {
542 IpClass::Public => Ok(()),
543 IpClass::Private
544 | IpClass::Loopback
545 | IpClass::LinkLocal
546 | IpClass::Multicast
547 | IpClass::Reserved => Err(SsrfError::BlockedClass {
548 host: host.to_string(),
549 ip,
550 class,
551 }),
552 }
553}
554
555fn is_known_metadata_hostname(host: &str) -> bool {
556 let host_only = host.split(':').next().unwrap_or(host);
558 let lc = host_only.to_ascii_lowercase();
559 matches!(
563 lc.as_str(),
564 "metadata.google.internal" | "metadata" | "metadata.goog"
565 )
566}
567
568#[cfg(test)]
571mod tests {
572 use super::*;
573 use std::net::{Ipv4Addr, Ipv6Addr};
574
575 #[test]
576 fn classify_rfc1918_private() {
577 assert_eq!(
578 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
579 IpClass::Private
580 );
581 assert_eq!(
582 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))),
583 IpClass::Private
584 );
585 assert_eq!(
586 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))),
587 IpClass::Private
588 );
589 }
590
591 #[test]
592 fn classify_loopback() {
593 assert_eq!(
594 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
595 IpClass::Loopback
596 );
597 assert_eq!(
598 SsrfPolicy::classify(IpAddr::V6(Ipv6Addr::LOCALHOST)),
599 IpClass::Loopback
600 );
601 }
602
603 #[test]
604 fn classify_public() {
605 assert_eq!(
606 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))),
607 IpClass::Public
608 );
609 assert_eq!(
610 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))),
611 IpClass::Public
612 );
613 }
614
615 #[test]
616 fn classify_cloud_metadata() {
617 assert_eq!(
618 SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))),
619 IpClass::Reserved
620 );
621 }
622
623 #[test]
624 fn classify_ipv6_link_local() {
625 assert_eq!(
626 SsrfPolicy::classify(IpAddr::V6("fe80::1".parse().unwrap())),
627 IpClass::LinkLocal
628 );
629 }
630
631 #[test]
632 fn classify_ipv6_ula() {
633 assert_eq!(
634 SsrfPolicy::classify(IpAddr::V6("fc00::1".parse().unwrap())),
635 IpClass::Private
636 );
637 assert_eq!(
638 SsrfPolicy::classify(IpAddr::V6("fd12:3456:789a::1".parse().unwrap())),
639 IpClass::Private
640 );
641 }
642
643 #[test]
644 fn classify_ipv6_public() {
645 assert_eq!(
646 SsrfPolicy::classify(IpAddr::V6("2001:4860:4860::8888".parse().unwrap())),
647 IpClass::Public
648 );
649 }
650
651 #[test]
654 fn classify_ipv4_compatible_loopback() {
655 let addr: Ipv6Addr = "::127.0.0.1".parse().unwrap();
657 assert_eq!(
658 SsrfPolicy::classify(IpAddr::V6(addr)),
659 IpClass::Loopback,
660 "::127.0.0.1 must be classified as Loopback"
661 );
662 }
663
664 #[test]
665 fn classify_ipv4_compatible_private_10() {
666 let addr: Ipv6Addr = "::10.0.0.1".parse().unwrap();
668 assert_eq!(
669 SsrfPolicy::classify(IpAddr::V6(addr)),
670 IpClass::Private,
671 "::10.0.0.1 must be classified as Private"
672 );
673 }
674
675 #[test]
676 fn classify_ipv4_compatible_link_local_metadata() {
677 let addr: Ipv6Addr = "::169.254.169.254".parse().unwrap();
679 assert_eq!(
680 SsrfPolicy::classify(IpAddr::V6(addr)),
681 IpClass::Reserved,
682 "::169.254.169.254 must be classified as Reserved (cloud metadata)"
683 );
684 }
685
686 #[test]
687 fn classify_ipv4_compatible_public() {
688 let addr: Ipv6Addr = "::8.8.8.8".parse().unwrap();
690 assert_eq!(
691 SsrfPolicy::classify(IpAddr::V6(addr)),
692 IpClass::Public,
693 "::8.8.8.8 should be classified as Public"
694 );
695 }
696
697 #[test]
698 fn classify_6to4_private() {
699 let addr: Ipv6Addr = "2002:c0a8:0101::".parse().unwrap();
701 assert_eq!(
702 SsrfPolicy::classify(IpAddr::V6(addr)),
703 IpClass::Private,
704 "2002:c0a8:0101:: (6to4 embedding 192.168.1.1) must be Private"
705 );
706 }
707
708 #[test]
709 fn classify_6to4_loopback() {
710 let addr: Ipv6Addr = "2002:7f00:0001::".parse().unwrap();
712 assert_eq!(
713 SsrfPolicy::classify(IpAddr::V6(addr)),
714 IpClass::Loopback,
715 "2002:7f00:0001:: (6to4 embedding 127.0.0.1) must be Loopback"
716 );
717 }
718
719 #[test]
720 fn classify_6to4_metadata() {
721 let addr: Ipv6Addr = "2002:a9fe:a9fe::".parse().unwrap();
723 assert_eq!(
724 SsrfPolicy::classify(IpAddr::V6(addr)),
725 IpClass::Reserved,
726 "2002:a9fe:a9fe:: (6to4 embedding 169.254.169.254) must be Reserved"
727 );
728 }
729
730 #[test]
731 fn classify_6to4_public() {
732 let addr: Ipv6Addr = "2002:0808:0808::".parse().unwrap();
734 assert_eq!(
735 SsrfPolicy::classify(IpAddr::V6(addr)),
736 IpClass::Public,
737 "2002:0808:0808:: (6to4 embedding 8.8.8.8) should be Public"
738 );
739 }
740
741 #[test]
742 fn blocks_ipv4_compatible_in_url() {
743 assert_blocked("http://[::127.0.0.1]/", IpClass::Loopback);
745 assert_blocked("http://[::10.0.0.1]/", IpClass::Private);
746 assert_blocked("http://[::169.254.169.254]/", IpClass::Reserved);
747 }
748
749 #[test]
750 fn blocks_6to4_in_url() {
751 assert_blocked("http://[2002:c0a8:0101::]/", IpClass::Private);
752 assert_blocked("http://[2002:7f00:0001::]/", IpClass::Loopback);
753 assert_blocked("http://[2002:a9fe:a9fe::]/", IpClass::Reserved);
754 }
755
756 #[test]
757 fn default_policy_blocks_private() {
758 let p = SsrfPolicy::new();
759 assert!(!p.allow_private);
760 assert!(!p.allow_loopback);
761 assert!(!p.allow_link_local);
762 }
763
764 fn assert_blocked(url: &str, want_class: IpClass) {
767 match is_safe_url(url) {
768 Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(
769 class, want_class,
770 "url {url} blocked with {class:?}, wanted {want_class:?}"
771 ),
772 Err(other) => panic!("url {url} rejected with unexpected error: {other}"),
773 Ok(()) => panic!("url {url} accepted but expected block for {want_class:?}"),
774 }
775 }
776
777 #[test]
778 fn blocks_rfc1918_addresses() {
779 let cases = [
780 "http://10.0.0.1/",
781 "http://10.255.255.255/",
782 "http://172.16.0.1/",
783 "http://172.31.255.255/",
784 "http://192.168.0.1/",
785 "http://192.168.255.255/",
786 "http://[fc00::1]/",
787 "http://[fd00::1]/",
788 ];
789 for url in cases {
790 assert_blocked(url, IpClass::Private);
791 }
792 }
793
794 #[test]
795 fn blocks_loopback() {
796 assert_blocked("http://127.0.0.1/", IpClass::Loopback);
797 assert_blocked("http://127.255.255.254/", IpClass::Loopback);
798 assert_blocked("http://[::1]/", IpClass::Loopback);
799 }
800
801 #[test]
802 fn blocks_link_local() {
803 assert_blocked("http://169.254.1.1/", IpClass::LinkLocal);
804 assert_blocked("http://169.254.254.254/", IpClass::LinkLocal);
805 assert_blocked("http://[fe80::1]/", IpClass::LinkLocal);
806 }
807
808 #[test]
809 fn blocks_aws_metadata_ip() {
810 assert_blocked("http://169.254.169.254/latest/meta-data/", IpClass::Reserved);
812 assert_blocked("http://[fd00:ec2::254]/latest/meta-data/", IpClass::Reserved);
813 }
814
815 #[tokio::test]
816 async fn blocks_aws_metadata_hostname() {
817 assert_blocked(
818 "http://metadata.google.internal/computeMetadata/v1/",
819 IpClass::Reserved,
820 );
821 match resolve_and_check("metadata.google.internal").await {
822 Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(class, IpClass::Reserved),
823 other => panic!("expected BlockedClass for metadata.google.internal, got {other:?}"),
824 }
825 match resolve_and_check("metadata").await {
826 Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(class, IpClass::Reserved),
827 other => panic!("expected BlockedClass for bare 'metadata', got {other:?}"),
828 }
829 }
830
831 #[test]
832 fn allows_public_ipv4() {
833 assert!(is_safe_url("https://8.8.8.8/").is_ok());
834 assert!(is_safe_url("https://1.1.1.1/").is_ok());
835 assert!(is_safe_url("https://93.184.216.34/").is_ok());
836 }
837
838 #[test]
839 fn allows_public_ipv6() {
840 assert!(is_safe_url("https://[2001:4860:4860::8888]/").is_ok());
841 assert!(is_safe_url("https://[2606:4700:4700::1111]/").is_ok());
842 }
843
844 #[test]
845 fn rejects_malformed_url() {
846 match is_safe_url("not a url") {
847 Err(SsrfError::MissingHost(_)) => {}
848 other => panic!("expected MissingHost for malformed url, got {other:?}"),
849 }
850 match is_safe_url("") {
851 Err(SsrfError::MissingHost(_)) => {}
852 other => panic!("expected MissingHost for empty url, got {other:?}"),
853 }
854 }
855
856 #[test]
857 fn rejects_http_without_host() {
858 match is_safe_url("file:///etc/passwd") {
859 Err(SsrfError::MissingHost(_)) => {}
860 other => panic!("expected MissingHost for file URL, got {other:?}"),
861 }
862 }
863
864 #[tokio::test]
870 async fn dns_failure_blocks_request() {
871 let result = resolve_and_check("this-host-does-not-exist.invalid").await;
874 match result {
875 Err(SsrfError::DnsFailure { host, .. }) => {
876 assert_eq!(host, "this-host-does-not-exist.invalid");
877 }
878 Err(SsrfError::NoAddresses { host, .. }) => {
879 assert_eq!(host, "this-host-does-not-exist.invalid");
882 }
883 Err(other) => panic!(
884 "expected DnsFailure or NoAddresses for unresolvable host, got {other:?}"
885 ),
886 Ok(ip) => panic!(
887 "expected DNS failure for unresolvable host, got Ok({ip})"
888 ),
889 }
890 }
891
892 #[tokio::test]
893 async fn policy_dns_failure_blocks_request() {
894 let policy = SsrfPolicy::new();
896 let url = Url::parse("https://this-host-does-not-exist.invalid/resource")
897 .expect("valid URL");
898 let result = policy.resolve_and_check(&url).await;
899 match result {
900 Err(SsrfError::DnsFailure { host, .. }) => {
901 assert!(host.contains("this-host-does-not-exist.invalid"));
902 }
903 Err(SsrfError::NoAddresses { host, .. }) => {
904 assert!(host.contains("this-host-does-not-exist.invalid"));
905 }
906 Err(other) => panic!(
907 "expected DnsFailure/NoAddresses through policy, got {other:?}"
908 ),
909 Ok(ip) => panic!(
910 "expected DNS failure through policy, got Ok({ip})"
911 ),
912 }
913 }
914}