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_attr(
172 not(feature = "test-transport"),
173 deprecated(
174 note = "SSRF-relaxed test-only constructor: enable the `test-transport` feature to use it without this warning; the ungated fallback is removed in 0.4.0"
175 )
176 )]
177 pub fn allow_test_loopback() -> Self {
178 Self {
179 allow_loopback_resolved: true,
180 ..Self::default()
181 }
182 }
183}
184
185impl SsrfPolicy {
186 pub fn check_url(&self, url: &str) -> Result<(), AcdpError> {
192 self.classify_url(url).map_err(AcdpError::from)
193 }
194
195 pub fn classify_url(&self, url: &str) -> Result<(), SsrfRejection> {
204 let parsed = url::Url::parse(url).map_err(|e| SsrfRejection {
205 reason: SsrfReason::InvalidUrl,
206 detail: format!("invalid URL: {e}"),
207 })?;
208
209 if !self.allow_http && parsed.scheme() != "https" {
210 return Err(SsrfRejection {
211 reason: SsrfReason::NonHttps,
212 detail: format!(
213 "SSRF policy: scheme '{}' not permitted; only https",
214 parsed.scheme()
215 ),
216 });
217 }
218
219 let host = parsed.host().ok_or_else(|| SsrfRejection {
220 reason: SsrfReason::InvalidUrl,
221 detail: format!("URL has no host: {url}"),
222 })?;
223
224 match host {
225 url::Host::Ipv4(v4) => {
226 if self.reject_ip_literals {
227 return Err(SsrfRejection {
228 reason: SsrfReason::IpLiteral,
229 detail: format!(
230 "SSRF policy: IPv4 literal '{v4}' not permitted; use a hostname"
231 ),
232 });
233 }
234 self.classify_ip(IpAddr::V4(v4))?;
235 }
236 url::Host::Ipv6(v6) => {
237 if self.reject_ip_literals {
238 return Err(SsrfRejection {
239 reason: SsrfReason::IpLiteral,
240 detail: format!(
241 "SSRF policy: IPv6 literal '{v6}' not permitted; use a hostname"
242 ),
243 });
244 }
245 self.classify_ip(IpAddr::V6(v6))?;
246 }
247 url::Host::Domain(name) => {
248 if name.is_empty() || name.len() > 253 {
249 return Err(SsrfRejection {
250 reason: SsrfReason::InvalidUrl,
251 detail: format!("SSRF policy: invalid hostname length: {name}"),
252 });
253 }
254 }
255 }
256
257 Ok(())
258 }
259
260 pub fn check_resolved_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
264 self.check_ip(ip)
265 }
266
267 pub fn check_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
272 self.classify_ip(ip).map_err(AcdpError::from)
273 }
274
275 pub fn classify_ip(&self, ip: IpAddr) -> Result<(), SsrfRejection> {
279 let reason = match ip {
280 IpAddr::V4(v4) => {
281 if self.allow_loopback_resolved && v4.is_loopback() {
282 None
283 } else {
284 classify_unsafe_v4(v4)
285 }
286 }
287 IpAddr::V6(v6) => {
288 if self.allow_loopback_resolved && v6.is_loopback() {
289 None
290 } else {
291 classify_unsafe_v6(v6)
292 }
293 }
294 };
295 match reason {
296 Some(reason) => Err(SsrfRejection {
297 reason,
298 detail: format!("SSRF policy: IP address '{ip}' is in a forbidden range"),
299 }),
300 None => Ok(()),
301 }
302 }
303
304 #[cfg(feature = "client")]
322 pub async fn pin_resolved_ip(&self, host: &str, port: u16) -> Result<SocketAddr, AcdpError> {
323 let target = format!("{host}:{port}");
324 let candidates: Vec<SocketAddr> = tokio::net::lookup_host(&target)
325 .await
326 .map_err(|e| AcdpError::Http(format!("DNS lookup for '{host}' failed: {e}")))?
327 .collect();
328 if candidates.is_empty() {
329 return Err(AcdpError::Http(format!(
330 "DNS lookup for '{host}' returned no addresses"
331 )));
332 }
333 reject_if_any_forbidden(self, host, &candidates)?;
336 let pinned = candidates
338 .iter()
339 .find(|a| a.is_ipv4())
340 .or_else(|| candidates.first())
341 .copied()
342 .expect("candidates is non-empty");
343 Ok(pinned)
344 }
345
346 pub fn check_redirect_authority(
350 &self,
351 original_url: &url::Url,
352 redirect_url: &str,
353 ) -> Result<(), AcdpError> {
354 self.classify_redirect_authority(original_url, redirect_url)
355 .map_err(AcdpError::from)
356 }
357
358 pub fn classify_redirect_authority(
361 &self,
362 original_url: &url::Url,
363 redirect_url: &str,
364 ) -> Result<(), SsrfRejection> {
365 let redirect = url::Url::parse(redirect_url).map_err(|e| SsrfRejection {
366 reason: SsrfReason::InvalidUrl,
367 detail: format!("invalid redirect URL: {e}"),
368 })?;
369 if !same_fetch_authority(original_url, &redirect) {
370 return Err(SsrfRejection {
371 reason: SsrfReason::CrossAuthority,
372 detail: format!(
373 "SSRF policy: cross-authority redirect rejected: {original_url} → {redirect}"
374 ),
375 });
376 }
377 Ok(())
378 }
379
380 pub fn classify_redirect(&self, from_url: &str, to_url: &str) -> Result<(), SsrfRejection> {
385 let original = url::Url::parse(from_url).map_err(|e| SsrfRejection {
386 reason: SsrfReason::InvalidUrl,
387 detail: format!("invalid origin URL: {e}"),
388 })?;
389 self.classify_redirect_authority(&original, to_url)
390 }
391}
392
393#[doc(hidden)]
401pub fn same_fetch_authority(a: &url::Url, b: &url::Url) -> bool {
402 a.scheme() == b.scheme()
403 && a.host_str() == b.host_str()
404 && a.port_or_known_default() == b.port_or_known_default()
405}
406
407#[cfg(test)]
412fn check_safe_ip(ip: IpAddr) -> Result<(), AcdpError> {
413 let bad = match ip {
414 IpAddr::V4(v4) => classify_unsafe_v4(v4).is_some(),
415 IpAddr::V6(v6) => classify_unsafe_v6(v6).is_some(),
416 };
417 if bad {
418 return Err(AcdpError::SchemaViolation(format!(
419 "SSRF policy: IP address '{ip}' is in a forbidden range"
420 )));
421 }
422 Ok(())
423}
424
425#[cfg(feature = "client")]
444pub fn reject_if_any_forbidden(
445 policy: &SsrfPolicy,
446 host: &str,
447 candidates: &[SocketAddr],
448) -> Result<(), AcdpError> {
449 for addr in candidates {
450 if let Err(e) = policy.check_ip(addr.ip()) {
451 return Err(AcdpError::SchemaViolation(format!(
452 "SSRF policy: DNS answer for '{host}' contains a forbidden address \
453 ({} is disallowed); rejecting the entire resolution. {e}",
454 addr.ip()
455 )));
456 }
457 }
458 Ok(())
459}
460
461#[cfg(feature = "client")]
464#[doc(hidden)]
465pub struct SafeDnsResolver {
466 policy: SsrfPolicy,
467}
468
469#[cfg(feature = "client")]
470impl SafeDnsResolver {
471 #[doc(hidden)]
472 pub fn arc(policy: SsrfPolicy) -> Arc<Self> {
473 Arc::new(Self { policy })
474 }
475}
476
477#[cfg(feature = "client")]
487pub fn safe_client(
488 policy: &SsrfPolicy,
489 timeout: std::time::Duration,
490) -> Result<reqwest::Client, AcdpError> {
491 reqwest::Client::builder()
492 .use_rustls_tls()
493 .connect_timeout(std::time::Duration::from_secs(5))
494 .timeout(timeout)
495 .redirect(reqwest::redirect::Policy::none())
496 .pool_max_idle_per_host(0)
501 .dns_resolver(SafeDnsResolver::arc(policy.clone()))
502 .build()
503 .map_err(|e| AcdpError::Http(e.to_string()))
504}
505
506#[cfg(feature = "client")]
507impl reqwest::dns::Resolve for SafeDnsResolver {
508 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
509 let policy = self.policy.clone();
510 let host = name.as_str().to_string();
511 Box::pin(async move {
512 let target = format!("{host}:0");
516 let candidates: Vec<SocketAddr> = tokio::net::lookup_host(&target)
517 .await
518 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
519 .collect();
520
521 if candidates.is_empty() {
522 let msg: String = format!("DNS lookup for '{host}' returned no addresses");
523 return Err(msg.into());
524 }
525
526 if let Err(e) = reject_if_any_forbidden(&policy, &host, &candidates) {
534 let msg: String = e.to_string();
535 return Err(msg.into());
536 }
537
538 let addrs: reqwest::dns::Addrs = Box::new(candidates.into_iter());
539 Ok(addrs)
540 })
541 }
542}
543
544fn classify_unsafe_v4(ip: Ipv4Addr) -> Option<SsrfReason> {
550 let o = ip.octets();
551 if o[0] == 0 {
552 Some(SsrfReason::MulticastOrReserved)
554 } else if o[0] == 10 {
555 Some(SsrfReason::Private)
557 } else if o[0] == 100 && (o[1] & 0xc0) == 64 {
558 Some(SsrfReason::Private)
560 } else if o[0] == 127 {
561 Some(SsrfReason::Loopback)
563 } else if o[0] == 169 && o[1] == 254 {
564 Some(SsrfReason::Imds)
566 } else if o[0] == 172 && (o[1] & 0xf0) == 16 {
567 Some(SsrfReason::Private)
569 } else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
570 Some(SsrfReason::MulticastOrReserved)
572 } else if o[0] == 192 && o[1] == 168 {
573 Some(SsrfReason::Private)
575 } else if o[0] == 198 && (o[1] == 18 || o[1] == 19) {
576 Some(SsrfReason::MulticastOrReserved)
578 } else if o[0] >= 224 && o[0] <= 239 {
579 Some(SsrfReason::MulticastOrReserved)
581 } else if o[0] >= 240 {
582 Some(SsrfReason::MulticastOrReserved)
584 } else {
585 None
586 }
587}
588
589fn classify_unsafe_v6(ip: Ipv6Addr) -> Option<SsrfReason> {
593 if ip.is_loopback() {
594 return Some(SsrfReason::Loopback);
595 }
596 if ip.is_unspecified() || ip.is_multicast() {
597 return Some(SsrfReason::MulticastOrReserved);
598 }
599 let segments = ip.segments();
600 if segments[0..5] == [0, 0, 0, 0, 0] && (segments[5] == 0 || segments[5] == 0xffff) {
607 let v4 = Ipv4Addr::new(
608 (segments[6] >> 8) as u8,
609 (segments[6] & 0xff) as u8,
610 (segments[7] >> 8) as u8,
611 (segments[7] & 0xff) as u8,
612 );
613 if !v4.is_unspecified() {
614 return classify_unsafe_v4(v4);
615 }
616 }
617 if segments[0] == 0x0064 && segments[1] == 0xff9b {
622 return Some(SsrfReason::Imds);
623 }
624 if (segments[0] & 0xfe00) == 0xfc00 {
626 return Some(SsrfReason::Private);
627 }
628 if (segments[0] & 0xffc0) == 0xfe80 {
630 return Some(SsrfReason::Imds);
631 }
632 None
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 #[cfg(feature = "client")]
644 #[tokio::test]
645 async fn safe_client_default_refuses_loopback() {
646 let client =
647 safe_client(&SsrfPolicy::default(), std::time::Duration::from_secs(2)).unwrap();
648 let result = client.get("http://127.0.0.1:9/").send().await;
649 assert!(
650 result.is_err(),
651 "default policy must refuse a loopback target"
652 );
653 }
654
655 #[cfg(feature = "client")]
658 #[test]
659 #[allow(deprecated)] fn safe_client_builds_with_loopback_policy() {
661 assert!(safe_client(
662 &SsrfPolicy::allow_test_loopback(),
663 std::time::Duration::from_secs(2)
664 )
665 .is_ok());
666 }
667
668 #[test]
669 fn https_only_by_default() {
670 let p = SsrfPolicy::default();
671 assert!(p.check_url("https://registry.example.com").is_ok());
672 assert!(p.check_url("http://registry.example.com").is_err());
673 assert!(p.check_url("file:///etc/passwd").is_err());
674 }
675
676 #[test]
677 fn rejects_ip_literals_by_default() {
678 let p = SsrfPolicy::default();
679 assert!(p.check_url("https://192.168.1.1").is_err());
680 assert!(p.check_url("https://[::1]").is_err());
681 }
682
683 #[test]
684 fn private_v4_ranges_rejected() {
685 assert!(check_safe_ip("10.0.0.1".parse().unwrap()).is_err());
687 assert!(check_safe_ip("172.16.5.5".parse().unwrap()).is_err());
688 assert!(check_safe_ip("192.168.1.1".parse().unwrap()).is_err());
689 assert!(check_safe_ip("127.0.0.1".parse().unwrap()).is_err());
691 assert!(check_safe_ip("169.254.169.254".parse().unwrap()).is_err());
693 assert!(check_safe_ip("239.0.0.1".parse().unwrap()).is_err());
695 assert!(check_safe_ip("8.8.8.8".parse().unwrap()).is_ok());
697 assert!(check_safe_ip("203.0.113.1".parse().unwrap()).is_ok());
698 }
699
700 #[test]
701 fn unsafe_v6_rejected() {
702 assert!(check_safe_ip("::1".parse().unwrap()).is_err());
703 assert!(check_safe_ip("fc00::1".parse().unwrap()).is_err());
704 assert!(check_safe_ip("fe80::1".parse().unwrap()).is_err());
705 assert!(check_safe_ip("::ffff:10.0.0.1".parse().unwrap()).is_err());
707 assert!(check_safe_ip("::127.0.0.1".parse().unwrap()).is_err());
709 assert!(check_safe_ip("::7f00:1".parse().unwrap()).is_err());
710 assert!(check_safe_ip("::169.254.169.254".parse().unwrap()).is_err());
711 assert!(check_safe_ip("64:ff9b::a9fe:a9fe".parse().unwrap()).is_err());
713 assert!(check_safe_ip("64:ff9b::169.254.169.254".parse().unwrap()).is_err());
714 assert!(check_safe_ip("2001:db8::1".parse().unwrap()).is_ok());
716 assert!(check_safe_ip("::93.184.216.34".parse().unwrap()).is_ok());
718 }
719
720 #[test]
721 fn cross_authority_redirect_rejected() {
722 let p = SsrfPolicy::default();
723 let orig = url::Url::parse("https://registry.example.com/a").unwrap();
724 let err = p
725 .check_redirect_authority(&orig, "https://attacker.com/x")
726 .unwrap_err();
727 assert!(matches!(err, AcdpError::SchemaViolation(_)));
728 p.check_redirect_authority(&orig, "https://registry.example.com/y")
730 .unwrap();
731 }
732
733 fn u(s: &str) -> url::Url {
735 url::Url::parse(s).unwrap()
736 }
737
738 #[test]
739 fn same_host_same_implicit_port_allowed() {
740 assert!(same_fetch_authority(
741 &u("https://a.example/x"),
742 &u("https://a.example/y")
743 ));
744 }
745
746 #[test]
747 fn same_host_explicit_443_same_as_implicit_allowed() {
748 assert!(same_fetch_authority(
750 &u("https://a.example/x"),
751 &u("https://a.example:443/y")
752 ));
753 }
754
755 #[test]
756 fn same_host_different_port_rejected() {
757 assert!(!same_fetch_authority(
758 &u("https://a.example/x"),
759 &u("https://a.example:8443/y")
760 ));
761 }
762
763 #[test]
764 fn https_to_http_same_host_rejected() {
765 assert!(!same_fetch_authority(
767 &u("https://a.example/x"),
768 &u("http://a.example/y")
769 ));
770 }
771
772 #[test]
773 fn different_host_rejected() {
774 assert!(!same_fetch_authority(
775 &u("https://a.example/x"),
776 &u("https://b.example/y")
777 ));
778 }
779
780 #[test]
781 fn check_redirect_authority_rejects_port_change() {
782 let p = SsrfPolicy::default();
783 let orig = u("https://registry.example.com/a");
784 let err = p
785 .check_redirect_authority(&orig, "https://registry.example.com:8443/b")
786 .unwrap_err();
787 assert!(matches!(err, AcdpError::SchemaViolation(_)));
788 }
789
790 #[cfg(feature = "client")]
792 fn sock(s: &str) -> SocketAddr {
793 s.parse().unwrap()
794 }
795
796 #[cfg(feature = "client")]
797 #[test]
798 fn mixed_public_private_dns_rejected_entirely() {
799 let p = SsrfPolicy::default();
800 let candidates = [sock("203.0.113.10:443"), sock("10.0.0.1:443")];
801 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
802 }
803
804 #[cfg(feature = "client")]
805 #[test]
806 fn mixed_public_loopback_rejected() {
807 let p = SsrfPolicy::default();
808 let candidates = [sock("198.51.100.1:443"), sock("127.0.0.1:443")];
809 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
810 }
811
812 #[cfg(feature = "client")]
813 #[test]
814 fn mixed_public_imds_rejected() {
815 let p = SsrfPolicy::default();
816 let candidates = [sock("198.51.100.1:443"), sock("169.254.169.254:443")];
817 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
818 }
819
820 #[cfg(feature = "client")]
821 #[test]
822 fn single_public_ip_allowed() {
823 let p = SsrfPolicy::default();
824 let candidates = [sock("203.0.113.10:443")];
825 assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
826 }
827
828 #[cfg(feature = "client")]
829 #[test]
830 fn all_public_ips_allowed() {
831 let p = SsrfPolicy::default();
832 let candidates = [sock("203.0.113.10:443"), sock("198.51.100.1:443")];
833 assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
834 }
835
836 #[test]
837 fn allow_http_can_be_opted_into() {
838 let p = SsrfPolicy {
839 allow_http: true,
840 ..SsrfPolicy::default()
841 };
842 assert!(p.check_url("http://registry.example.com").is_ok());
843 }
844
845 fn reason_for_ip(s: &str) -> SsrfReason {
847 SsrfPolicy::default()
848 .classify_ip(s.parse().unwrap())
849 .unwrap_err()
850 .reason
851 }
852
853 #[test]
854 fn classify_ip_maps_stable_reasons() {
855 assert_eq!(reason_for_ip("127.0.0.1"), SsrfReason::Loopback);
856 assert_eq!(reason_for_ip("10.0.0.1"), SsrfReason::Private);
857 assert_eq!(reason_for_ip("172.16.5.5"), SsrfReason::Private);
858 assert_eq!(reason_for_ip("192.168.1.1"), SsrfReason::Private);
859 assert_eq!(reason_for_ip("100.64.0.1"), SsrfReason::Private);
860 assert_eq!(reason_for_ip("169.254.169.254"), SsrfReason::Imds);
861 assert_eq!(reason_for_ip("239.0.0.1"), SsrfReason::MulticastOrReserved);
862 assert_eq!(reason_for_ip("0.0.0.1"), SsrfReason::MulticastOrReserved);
863 assert_eq!(reason_for_ip("240.0.0.1"), SsrfReason::MulticastOrReserved);
864 assert_eq!(reason_for_ip("::1"), SsrfReason::Loopback);
866 assert_eq!(reason_for_ip("fc00::1"), SsrfReason::Private);
867 assert_eq!(reason_for_ip("fe80::1"), SsrfReason::Imds);
868 assert_eq!(reason_for_ip("64:ff9b::a9fe:a9fe"), SsrfReason::Imds);
870 assert_eq!(reason_for_ip("::ffff:10.0.0.1"), SsrfReason::Private);
872 assert!(SsrfPolicy::default()
874 .classify_ip("8.8.8.8".parse().unwrap())
875 .is_ok());
876 assert!(SsrfPolicy::default()
877 .classify_ip("2001:db8::1".parse().unwrap())
878 .is_ok());
879 }
880
881 #[test]
882 fn classify_reason_as_str_is_stable() {
883 assert_eq!(SsrfReason::NonHttps.as_str(), "non_https");
884 assert_eq!(SsrfReason::IpLiteral.as_str(), "ip_literal");
885 assert_eq!(SsrfReason::InvalidUrl.as_str(), "invalid_url");
886 assert_eq!(SsrfReason::Loopback.as_str(), "loopback");
887 assert_eq!(SsrfReason::Private.as_str(), "private");
888 assert_eq!(SsrfReason::Imds.as_str(), "imds");
889 assert_eq!(
890 SsrfReason::MulticastOrReserved.as_str(),
891 "multicast_or_reserved"
892 );
893 assert_eq!(SsrfReason::CrossAuthority.as_str(), "cross_authority");
894 }
895
896 #[test]
897 fn classify_url_maps_stable_reasons() {
898 let p = SsrfPolicy::default();
899 assert_eq!(
900 p.classify_url("http://registry.example.com")
901 .unwrap_err()
902 .reason,
903 SsrfReason::NonHttps
904 );
905 assert_eq!(
906 p.classify_url("https://192.168.1.1").unwrap_err().reason,
907 SsrfReason::IpLiteral
908 );
909 assert_eq!(
910 p.classify_url("https://[::1]").unwrap_err().reason,
911 SsrfReason::IpLiteral
912 );
913 assert_eq!(
914 p.classify_url("not a url").unwrap_err().reason,
915 SsrfReason::InvalidUrl
916 );
917 assert!(p.classify_url("https://registry.example.com").is_ok());
918 }
919
920 #[test]
921 fn classify_redirect_reasons_and_port_parity() {
922 let p = SsrfPolicy::default();
923 assert_eq!(
925 p.classify_redirect("https://a.example/x", "https://b.example/y")
926 .unwrap_err()
927 .reason,
928 SsrfReason::CrossAuthority
929 );
930 assert_eq!(
932 p.classify_redirect("https://a.example/x", "https://a.example:8443/y")
933 .unwrap_err()
934 .reason,
935 SsrfReason::CrossAuthority
936 );
937 assert_eq!(
939 p.classify_redirect("https://a.example/x", "http://a.example/y")
940 .unwrap_err()
941 .reason,
942 SsrfReason::CrossAuthority
943 );
944 assert!(p
946 .classify_redirect("https://a.example/x", "https://a.example:443/y")
947 .is_ok());
948 assert!(p
950 .classify_redirect("https://a.example/x", "https://a.example/y")
951 .is_ok());
952 assert_eq!(
954 p.classify_redirect("::not-a-url", "https://a.example/y")
955 .unwrap_err()
956 .reason,
957 SsrfReason::InvalidUrl
958 );
959 }
960
961 #[test]
962 fn check_wrappers_preserve_schema_violation() {
963 let p = SsrfPolicy::default();
966 let err = p.check_url("http://registry.example.com").unwrap_err();
967 assert!(matches!(err, AcdpError::SchemaViolation(_)));
968 let err = p.check_ip("10.0.0.1".parse().unwrap()).unwrap_err();
969 assert!(matches!(err, AcdpError::SchemaViolation(_)));
970 }
971
972 #[cfg(feature = "client")]
978 #[tokio::test]
979 async fn pin_resolved_ip_rejects_loopback_hostname() {
980 let p = SsrfPolicy::default();
981 let err = p.pin_resolved_ip("localhost", 443).await.unwrap_err();
982 assert!(matches!(err, AcdpError::SchemaViolation(_)));
983 }
984}