1#[cfg(feature = "client")]
36use std::net::SocketAddr;
37use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
38
39use acdp_primitives::AcdpError;
40
41#[cfg(feature = "client")]
42use std::sync::Arc;
43
44pub use acdp_primitives::limits::{MAX_CONTEXT_BYTES, MAX_METADATA_BYTES, MAX_REDIRECTS};
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum SsrfReason {
60 NonHttps,
62 IpLiteral,
64 InvalidUrl,
66 Loopback,
68 Private,
71 Imds,
75 MulticastOrReserved,
79 CrossAuthority,
82}
83
84impl SsrfReason {
85 pub fn as_str(&self) -> &'static str {
88 match self {
89 SsrfReason::NonHttps => "non_https",
90 SsrfReason::IpLiteral => "ip_literal",
91 SsrfReason::InvalidUrl => "invalid_url",
92 SsrfReason::Loopback => "loopback",
93 SsrfReason::Private => "private",
94 SsrfReason::Imds => "imds",
95 SsrfReason::MulticastOrReserved => "multicast_or_reserved",
96 SsrfReason::CrossAuthority => "cross_authority",
97 }
98 }
99}
100
101impl std::fmt::Display for SsrfReason {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 f.write_str(self.as_str())
104 }
105}
106
107#[derive(Debug, Clone)]
114pub struct SsrfRejection {
115 pub reason: SsrfReason,
117 pub detail: String,
120}
121
122impl std::fmt::Display for SsrfRejection {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 write!(f, "{} [{}]", self.detail, self.reason)
125 }
126}
127
128impl From<SsrfRejection> for AcdpError {
129 fn from(r: SsrfRejection) -> Self {
130 AcdpError::SchemaViolation(r.detail)
131 }
132}
133
134#[derive(Debug, Clone)]
136pub struct SsrfPolicy {
137 pub reject_ip_literals: bool,
139 pub allow_http: bool,
141 pub allow_loopback_resolved: bool,
153}
154
155impl Default for SsrfPolicy {
156 fn default() -> Self {
157 Self {
158 reject_ip_literals: true,
159 allow_http: false,
160 allow_loopback_resolved: false,
161 }
162 }
163}
164
165impl SsrfPolicy {
166 #[doc(hidden)]
171 #[cfg(feature = "test-transport")]
172 pub fn allow_test_loopback() -> Self {
173 Self {
174 allow_loopback_resolved: true,
175 ..Self::default()
176 }
177 }
178}
179
180impl SsrfPolicy {
181 pub fn check_url(&self, url: &str) -> Result<(), AcdpError> {
187 self.classify_url(url).map_err(AcdpError::from)
188 }
189
190 pub fn classify_url(&self, url: &str) -> Result<(), SsrfRejection> {
199 let parsed = url::Url::parse(url).map_err(|e| SsrfRejection {
200 reason: SsrfReason::InvalidUrl,
201 detail: format!("invalid URL: {e}"),
202 })?;
203
204 if !self.allow_http && parsed.scheme() != "https" {
205 return Err(SsrfRejection {
206 reason: SsrfReason::NonHttps,
207 detail: format!(
208 "SSRF policy: scheme '{}' not permitted; only https",
209 parsed.scheme()
210 ),
211 });
212 }
213
214 let host = parsed.host().ok_or_else(|| SsrfRejection {
215 reason: SsrfReason::InvalidUrl,
216 detail: format!("URL has no host: {url}"),
217 })?;
218
219 match host {
220 url::Host::Ipv4(v4) => {
221 if self.reject_ip_literals {
222 return Err(SsrfRejection {
223 reason: SsrfReason::IpLiteral,
224 detail: format!(
225 "SSRF policy: IPv4 literal '{v4}' not permitted; use a hostname"
226 ),
227 });
228 }
229 self.classify_ip(IpAddr::V4(v4))?;
230 }
231 url::Host::Ipv6(v6) => {
232 if self.reject_ip_literals {
233 return Err(SsrfRejection {
234 reason: SsrfReason::IpLiteral,
235 detail: format!(
236 "SSRF policy: IPv6 literal '{v6}' not permitted; use a hostname"
237 ),
238 });
239 }
240 self.classify_ip(IpAddr::V6(v6))?;
241 }
242 url::Host::Domain(name) => {
243 if name.is_empty() || name.len() > 253 {
244 return Err(SsrfRejection {
245 reason: SsrfReason::InvalidUrl,
246 detail: format!("SSRF policy: invalid hostname length: {name}"),
247 });
248 }
249 }
250 }
251
252 Ok(())
253 }
254
255 pub fn check_resolved_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
259 self.check_ip(ip)
260 }
261
262 pub fn check_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
267 self.classify_ip(ip).map_err(AcdpError::from)
268 }
269
270 pub fn classify_ip(&self, ip: IpAddr) -> Result<(), SsrfRejection> {
274 let reason = match ip {
275 IpAddr::V4(v4) => {
276 if self.allow_loopback_resolved && v4.is_loopback() {
277 None
278 } else {
279 classify_unsafe_v4(v4)
280 }
281 }
282 IpAddr::V6(v6) => {
283 if self.allow_loopback_resolved && v6.is_loopback() {
284 None
285 } else {
286 classify_unsafe_v6(v6)
287 }
288 }
289 };
290 match reason {
291 Some(reason) => Err(SsrfRejection {
292 reason,
293 detail: format!("SSRF policy: IP address '{ip}' is in a forbidden range"),
294 }),
295 None => Ok(()),
296 }
297 }
298
299 #[cfg(feature = "client")]
317 pub async fn pin_resolved_ip(&self, host: &str, port: u16) -> Result<SocketAddr, AcdpError> {
318 let target = format!("{host}:{port}");
319 let candidates: Vec<SocketAddr> = tokio::net::lookup_host(&target)
320 .await
321 .map_err(|e| AcdpError::Http(format!("DNS lookup for '{host}' failed: {e}")))?
322 .collect();
323 if candidates.is_empty() {
324 return Err(AcdpError::Http(format!(
325 "DNS lookup for '{host}' returned no addresses"
326 )));
327 }
328 reject_if_any_forbidden(self, host, &candidates)?;
331 let pinned = candidates
333 .iter()
334 .find(|a| a.is_ipv4())
335 .or_else(|| candidates.first())
336 .copied()
337 .expect("candidates is non-empty");
338 Ok(pinned)
339 }
340
341 pub fn check_redirect_authority(
345 &self,
346 original_url: &url::Url,
347 redirect_url: &str,
348 ) -> Result<(), AcdpError> {
349 self.classify_redirect_authority(original_url, redirect_url)
350 .map_err(AcdpError::from)
351 }
352
353 pub fn classify_redirect_authority(
356 &self,
357 original_url: &url::Url,
358 redirect_url: &str,
359 ) -> Result<(), SsrfRejection> {
360 let redirect = url::Url::parse(redirect_url).map_err(|e| SsrfRejection {
361 reason: SsrfReason::InvalidUrl,
362 detail: format!("invalid redirect URL: {e}"),
363 })?;
364 if !same_fetch_authority(original_url, &redirect) {
365 return Err(SsrfRejection {
366 reason: SsrfReason::CrossAuthority,
367 detail: format!(
368 "SSRF policy: cross-authority redirect rejected: {original_url} → {redirect}"
369 ),
370 });
371 }
372 Ok(())
373 }
374
375 pub fn classify_redirect(&self, from_url: &str, to_url: &str) -> Result<(), SsrfRejection> {
380 let original = url::Url::parse(from_url).map_err(|e| SsrfRejection {
381 reason: SsrfReason::InvalidUrl,
382 detail: format!("invalid origin URL: {e}"),
383 })?;
384 self.classify_redirect_authority(&original, to_url)
385 }
386}
387
388#[doc(hidden)]
396pub fn same_fetch_authority(a: &url::Url, b: &url::Url) -> bool {
397 a.scheme() == b.scheme()
398 && a.host_str() == b.host_str()
399 && a.port_or_known_default() == b.port_or_known_default()
400}
401
402#[cfg(test)]
407fn check_safe_ip(ip: IpAddr) -> Result<(), AcdpError> {
408 let bad = match ip {
409 IpAddr::V4(v4) => classify_unsafe_v4(v4).is_some(),
410 IpAddr::V6(v6) => classify_unsafe_v6(v6).is_some(),
411 };
412 if bad {
413 return Err(AcdpError::SchemaViolation(format!(
414 "SSRF policy: IP address '{ip}' is in a forbidden range"
415 )));
416 }
417 Ok(())
418}
419
420#[cfg(feature = "client")]
439pub fn reject_if_any_forbidden(
440 policy: &SsrfPolicy,
441 host: &str,
442 candidates: &[SocketAddr],
443) -> Result<(), AcdpError> {
444 for addr in candidates {
445 if let Err(e) = policy.check_ip(addr.ip()) {
446 return Err(AcdpError::SchemaViolation(format!(
447 "SSRF policy: DNS answer for '{host}' contains a forbidden address \
448 ({} is disallowed); rejecting the entire resolution. {e}",
449 addr.ip()
450 )));
451 }
452 }
453 Ok(())
454}
455
456#[cfg(feature = "client")]
459#[doc(hidden)]
460pub struct SafeDnsResolver {
461 policy: SsrfPolicy,
462}
463
464#[cfg(feature = "client")]
465impl SafeDnsResolver {
466 #[doc(hidden)]
467 pub fn arc(policy: SsrfPolicy) -> Arc<Self> {
468 Arc::new(Self { policy })
469 }
470}
471
472#[cfg(feature = "client")]
482pub fn safe_client(
483 policy: &SsrfPolicy,
484 timeout: std::time::Duration,
485) -> Result<reqwest::Client, AcdpError> {
486 reqwest::Client::builder()
487 .use_rustls_tls()
488 .no_gzip()
491 .connect_timeout(std::time::Duration::from_secs(5))
492 .timeout(timeout)
493 .redirect(reqwest::redirect::Policy::none())
494 .pool_max_idle_per_host(0)
499 .dns_resolver(SafeDnsResolver::arc(policy.clone()))
500 .build()
501 .map_err(|e| AcdpError::Http(e.to_string()))
502}
503
504#[cfg(feature = "client")]
505impl reqwest::dns::Resolve for SafeDnsResolver {
506 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
507 let policy = self.policy.clone();
508 let host = name.as_str().to_string();
509 Box::pin(async move {
510 let target = format!("{host}:0");
514 let candidates: Vec<SocketAddr> = tokio::net::lookup_host(&target)
515 .await
516 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
517 .collect();
518
519 if candidates.is_empty() {
520 let msg: String = format!("DNS lookup for '{host}' returned no addresses");
521 return Err(msg.into());
522 }
523
524 if let Err(e) = reject_if_any_forbidden(&policy, &host, &candidates) {
535 let msg = match e {
536 AcdpError::SchemaViolation(m) => m,
537 other => other.to_string(),
538 };
539 let refusal: Box<dyn std::error::Error + Send + Sync> =
540 Box::new(acdp_primitives::error::SsrfDnsRefusal::new(msg));
541 return Err(refusal);
542 }
543
544 let addrs: reqwest::dns::Addrs = Box::new(candidates.into_iter());
545 Ok(addrs)
546 })
547 }
548}
549
550fn classify_unsafe_v4(ip: Ipv4Addr) -> Option<SsrfReason> {
556 let o = ip.octets();
557 if o[0] == 0 {
558 Some(SsrfReason::MulticastOrReserved)
560 } else if o[0] == 10 {
561 Some(SsrfReason::Private)
563 } else if o[0] == 100 && (o[1] & 0xc0) == 64 {
564 Some(SsrfReason::Private)
566 } else if o[0] == 127 {
567 Some(SsrfReason::Loopback)
569 } else if o[0] == 169 && o[1] == 254 {
570 Some(SsrfReason::Imds)
572 } else if o[0] == 172 && (o[1] & 0xf0) == 16 {
573 Some(SsrfReason::Private)
575 } else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
576 Some(SsrfReason::MulticastOrReserved)
578 } else if o[0] == 192 && o[1] == 168 {
579 Some(SsrfReason::Private)
581 } else if o[0] == 198 && (o[1] == 18 || o[1] == 19) {
582 Some(SsrfReason::MulticastOrReserved)
584 } else if o[0] >= 224 && o[0] <= 239 {
585 Some(SsrfReason::MulticastOrReserved)
587 } else if o[0] >= 240 {
588 Some(SsrfReason::MulticastOrReserved)
590 } else {
591 None
592 }
593}
594
595fn classify_unsafe_v6(ip: Ipv6Addr) -> Option<SsrfReason> {
599 if ip.is_loopback() {
600 return Some(SsrfReason::Loopback);
601 }
602 if ip.is_unspecified() || ip.is_multicast() {
603 return Some(SsrfReason::MulticastOrReserved);
604 }
605 let segments = ip.segments();
606 if segments[0..5] == [0, 0, 0, 0, 0] && (segments[5] == 0 || segments[5] == 0xffff) {
613 let v4 = Ipv4Addr::new(
614 (segments[6] >> 8) as u8,
615 (segments[6] & 0xff) as u8,
616 (segments[7] >> 8) as u8,
617 (segments[7] & 0xff) as u8,
618 );
619 if !v4.is_unspecified() {
620 return classify_unsafe_v4(v4);
621 }
622 }
623 if segments[0] == 0x0064 && segments[1] == 0xff9b {
628 return Some(SsrfReason::Imds);
629 }
630 if (segments[0] & 0xfe00) == 0xfc00 {
632 return Some(SsrfReason::Private);
633 }
634 if (segments[0] & 0xffc0) == 0xfe80 {
636 return Some(SsrfReason::Imds);
637 }
638 None
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[cfg(feature = "client")]
650 #[tokio::test]
651 async fn safe_client_default_refuses_loopback() {
652 let client =
653 safe_client(&SsrfPolicy::default(), std::time::Duration::from_secs(2)).unwrap();
654 let result = client.get("http://127.0.0.1:9/").send().await;
655 assert!(
656 result.is_err(),
657 "default policy must refuse a loopback target"
658 );
659 }
660
661 #[cfg(feature = "client")]
666 #[tokio::test]
667 async fn dns_refusal_carries_typed_marker_through_reqwest() {
668 let client =
669 safe_client(&SsrfPolicy::default(), std::time::Duration::from_secs(2)).unwrap();
670 let err = client
671 .get("https://localhost:9/")
672 .send()
673 .await
674 .expect_err("default policy must refuse localhost");
675 let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(&err);
676 let mut found = false;
677 while let Some(e) = cur {
678 if let Some(r) = e.downcast_ref::<acdp_primitives::error::SsrfDnsRefusal>() {
679 assert!(r.message().contains("SSRF policy"), "{}", r.message());
680 found = true;
681 break;
682 }
683 cur = e.source();
684 }
685 assert!(found, "typed SsrfDnsRefusal missing from chain: {err:?}");
686 assert!(acdp_primitives::error::is_ssrf_refusal(&err));
687 }
688
689 #[cfg(all(feature = "client", feature = "test-transport"))]
692 #[test]
693 fn safe_client_builds_with_loopback_policy() {
694 assert!(safe_client(
695 &SsrfPolicy::allow_test_loopback(),
696 std::time::Duration::from_secs(2)
697 )
698 .is_ok());
699 }
700
701 #[test]
702 fn https_only_by_default() {
703 let p = SsrfPolicy::default();
704 assert!(p.check_url("https://registry.example.com").is_ok());
705 assert!(p.check_url("http://registry.example.com").is_err());
706 assert!(p.check_url("file:///etc/passwd").is_err());
707 }
708
709 #[test]
710 fn rejects_ip_literals_by_default() {
711 let p = SsrfPolicy::default();
712 assert!(p.check_url("https://192.168.1.1").is_err());
713 assert!(p.check_url("https://[::1]").is_err());
714 }
715
716 #[test]
717 fn private_v4_ranges_rejected() {
718 assert!(check_safe_ip("10.0.0.1".parse().unwrap()).is_err());
720 assert!(check_safe_ip("172.16.5.5".parse().unwrap()).is_err());
721 assert!(check_safe_ip("192.168.1.1".parse().unwrap()).is_err());
722 assert!(check_safe_ip("127.0.0.1".parse().unwrap()).is_err());
724 assert!(check_safe_ip("169.254.169.254".parse().unwrap()).is_err());
726 assert!(check_safe_ip("239.0.0.1".parse().unwrap()).is_err());
728 assert!(check_safe_ip("8.8.8.8".parse().unwrap()).is_ok());
730 assert!(check_safe_ip("203.0.113.1".parse().unwrap()).is_ok());
731 }
732
733 #[test]
734 fn unsafe_v6_rejected() {
735 assert!(check_safe_ip("::1".parse().unwrap()).is_err());
736 assert!(check_safe_ip("fc00::1".parse().unwrap()).is_err());
737 assert!(check_safe_ip("fe80::1".parse().unwrap()).is_err());
738 assert!(check_safe_ip("::ffff:10.0.0.1".parse().unwrap()).is_err());
740 assert!(check_safe_ip("::127.0.0.1".parse().unwrap()).is_err());
742 assert!(check_safe_ip("::7f00:1".parse().unwrap()).is_err());
743 assert!(check_safe_ip("::169.254.169.254".parse().unwrap()).is_err());
744 assert!(check_safe_ip("64:ff9b::a9fe:a9fe".parse().unwrap()).is_err());
746 assert!(check_safe_ip("64:ff9b::169.254.169.254".parse().unwrap()).is_err());
747 assert!(check_safe_ip("2001:db8::1".parse().unwrap()).is_ok());
749 assert!(check_safe_ip("::93.184.216.34".parse().unwrap()).is_ok());
751 }
752
753 #[test]
754 fn cross_authority_redirect_rejected() {
755 let p = SsrfPolicy::default();
756 let orig = url::Url::parse("https://registry.example.com/a").unwrap();
757 let err = p
758 .check_redirect_authority(&orig, "https://attacker.com/x")
759 .unwrap_err();
760 assert!(matches!(err, AcdpError::SchemaViolation(_)));
761 p.check_redirect_authority(&orig, "https://registry.example.com/y")
763 .unwrap();
764 }
765
766 fn u(s: &str) -> url::Url {
768 url::Url::parse(s).unwrap()
769 }
770
771 #[test]
772 fn same_host_same_implicit_port_allowed() {
773 assert!(same_fetch_authority(
774 &u("https://a.example/x"),
775 &u("https://a.example/y")
776 ));
777 }
778
779 #[test]
780 fn same_host_explicit_443_same_as_implicit_allowed() {
781 assert!(same_fetch_authority(
783 &u("https://a.example/x"),
784 &u("https://a.example:443/y")
785 ));
786 }
787
788 #[test]
789 fn same_host_different_port_rejected() {
790 assert!(!same_fetch_authority(
791 &u("https://a.example/x"),
792 &u("https://a.example:8443/y")
793 ));
794 }
795
796 #[test]
797 fn https_to_http_same_host_rejected() {
798 assert!(!same_fetch_authority(
800 &u("https://a.example/x"),
801 &u("http://a.example/y")
802 ));
803 }
804
805 #[test]
806 fn different_host_rejected() {
807 assert!(!same_fetch_authority(
808 &u("https://a.example/x"),
809 &u("https://b.example/y")
810 ));
811 }
812
813 #[test]
814 fn check_redirect_authority_rejects_port_change() {
815 let p = SsrfPolicy::default();
816 let orig = u("https://registry.example.com/a");
817 let err = p
818 .check_redirect_authority(&orig, "https://registry.example.com:8443/b")
819 .unwrap_err();
820 assert!(matches!(err, AcdpError::SchemaViolation(_)));
821 }
822
823 #[cfg(feature = "client")]
825 fn sock(s: &str) -> SocketAddr {
826 s.parse().unwrap()
827 }
828
829 #[cfg(feature = "client")]
830 #[test]
831 fn mixed_public_private_dns_rejected_entirely() {
832 let p = SsrfPolicy::default();
833 let candidates = [sock("203.0.113.10:443"), sock("10.0.0.1:443")];
834 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
835 }
836
837 #[cfg(feature = "client")]
838 #[test]
839 fn mixed_public_loopback_rejected() {
840 let p = SsrfPolicy::default();
841 let candidates = [sock("198.51.100.1:443"), sock("127.0.0.1:443")];
842 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
843 }
844
845 #[cfg(feature = "client")]
846 #[test]
847 fn mixed_public_imds_rejected() {
848 let p = SsrfPolicy::default();
849 let candidates = [sock("198.51.100.1:443"), sock("169.254.169.254:443")];
850 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
851 }
852
853 #[cfg(feature = "client")]
854 #[test]
855 fn single_public_ip_allowed() {
856 let p = SsrfPolicy::default();
857 let candidates = [sock("203.0.113.10:443")];
858 assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
859 }
860
861 #[cfg(feature = "client")]
862 #[test]
863 fn all_public_ips_allowed() {
864 let p = SsrfPolicy::default();
865 let candidates = [sock("203.0.113.10:443"), sock("198.51.100.1:443")];
866 assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
867 }
868
869 #[test]
870 fn allow_http_can_be_opted_into() {
871 let p = SsrfPolicy {
872 allow_http: true,
873 ..SsrfPolicy::default()
874 };
875 assert!(p.check_url("http://registry.example.com").is_ok());
876 }
877
878 fn reason_for_ip(s: &str) -> SsrfReason {
880 SsrfPolicy::default()
881 .classify_ip(s.parse().unwrap())
882 .unwrap_err()
883 .reason
884 }
885
886 #[test]
887 fn classify_ip_maps_stable_reasons() {
888 assert_eq!(reason_for_ip("127.0.0.1"), SsrfReason::Loopback);
889 assert_eq!(reason_for_ip("10.0.0.1"), SsrfReason::Private);
890 assert_eq!(reason_for_ip("172.16.5.5"), SsrfReason::Private);
891 assert_eq!(reason_for_ip("192.168.1.1"), SsrfReason::Private);
892 assert_eq!(reason_for_ip("100.64.0.1"), SsrfReason::Private);
893 assert_eq!(reason_for_ip("169.254.169.254"), SsrfReason::Imds);
894 assert_eq!(reason_for_ip("239.0.0.1"), SsrfReason::MulticastOrReserved);
895 assert_eq!(reason_for_ip("0.0.0.1"), SsrfReason::MulticastOrReserved);
896 assert_eq!(reason_for_ip("240.0.0.1"), SsrfReason::MulticastOrReserved);
897 assert_eq!(reason_for_ip("::1"), SsrfReason::Loopback);
899 assert_eq!(reason_for_ip("fc00::1"), SsrfReason::Private);
900 assert_eq!(reason_for_ip("fe80::1"), SsrfReason::Imds);
901 assert_eq!(reason_for_ip("64:ff9b::a9fe:a9fe"), SsrfReason::Imds);
903 assert_eq!(reason_for_ip("::ffff:10.0.0.1"), SsrfReason::Private);
905 assert!(SsrfPolicy::default()
907 .classify_ip("8.8.8.8".parse().unwrap())
908 .is_ok());
909 assert!(SsrfPolicy::default()
910 .classify_ip("2001:db8::1".parse().unwrap())
911 .is_ok());
912 }
913
914 #[test]
915 fn classify_reason_as_str_is_stable() {
916 assert_eq!(SsrfReason::NonHttps.as_str(), "non_https");
917 assert_eq!(SsrfReason::IpLiteral.as_str(), "ip_literal");
918 assert_eq!(SsrfReason::InvalidUrl.as_str(), "invalid_url");
919 assert_eq!(SsrfReason::Loopback.as_str(), "loopback");
920 assert_eq!(SsrfReason::Private.as_str(), "private");
921 assert_eq!(SsrfReason::Imds.as_str(), "imds");
922 assert_eq!(
923 SsrfReason::MulticastOrReserved.as_str(),
924 "multicast_or_reserved"
925 );
926 assert_eq!(SsrfReason::CrossAuthority.as_str(), "cross_authority");
927 }
928
929 #[test]
930 fn classify_url_maps_stable_reasons() {
931 let p = SsrfPolicy::default();
932 assert_eq!(
933 p.classify_url("http://registry.example.com")
934 .unwrap_err()
935 .reason,
936 SsrfReason::NonHttps
937 );
938 assert_eq!(
939 p.classify_url("https://192.168.1.1").unwrap_err().reason,
940 SsrfReason::IpLiteral
941 );
942 assert_eq!(
943 p.classify_url("https://[::1]").unwrap_err().reason,
944 SsrfReason::IpLiteral
945 );
946 assert_eq!(
947 p.classify_url("not a url").unwrap_err().reason,
948 SsrfReason::InvalidUrl
949 );
950 assert!(p.classify_url("https://registry.example.com").is_ok());
951 }
952
953 #[test]
954 fn classify_redirect_reasons_and_port_parity() {
955 let p = SsrfPolicy::default();
956 assert_eq!(
958 p.classify_redirect("https://a.example/x", "https://b.example/y")
959 .unwrap_err()
960 .reason,
961 SsrfReason::CrossAuthority
962 );
963 assert_eq!(
965 p.classify_redirect("https://a.example/x", "https://a.example:8443/y")
966 .unwrap_err()
967 .reason,
968 SsrfReason::CrossAuthority
969 );
970 assert_eq!(
972 p.classify_redirect("https://a.example/x", "http://a.example/y")
973 .unwrap_err()
974 .reason,
975 SsrfReason::CrossAuthority
976 );
977 assert!(p
979 .classify_redirect("https://a.example/x", "https://a.example:443/y")
980 .is_ok());
981 assert!(p
983 .classify_redirect("https://a.example/x", "https://a.example/y")
984 .is_ok());
985 assert_eq!(
987 p.classify_redirect("::not-a-url", "https://a.example/y")
988 .unwrap_err()
989 .reason,
990 SsrfReason::InvalidUrl
991 );
992 }
993
994 #[test]
995 fn check_wrappers_preserve_schema_violation() {
996 let p = SsrfPolicy::default();
999 let err = p.check_url("http://registry.example.com").unwrap_err();
1000 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1001 let err = p.check_ip("10.0.0.1".parse().unwrap()).unwrap_err();
1002 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1003 }
1004
1005 #[cfg(feature = "client")]
1011 #[tokio::test]
1012 async fn pin_resolved_ip_rejects_loopback_hostname() {
1013 let p = SsrfPolicy::default();
1014 let err = p.pin_resolved_ip("localhost", 443).await.unwrap_err();
1015 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1016 }
1017}