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 .connect_timeout(std::time::Duration::from_secs(5))
489 .timeout(timeout)
490 .redirect(reqwest::redirect::Policy::none())
491 .pool_max_idle_per_host(0)
496 .dns_resolver(SafeDnsResolver::arc(policy.clone()))
497 .build()
498 .map_err(|e| AcdpError::Http(e.to_string()))
499}
500
501#[cfg(feature = "client")]
502impl reqwest::dns::Resolve for SafeDnsResolver {
503 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
504 let policy = self.policy.clone();
505 let host = name.as_str().to_string();
506 Box::pin(async move {
507 let target = format!("{host}:0");
511 let candidates: Vec<SocketAddr> = tokio::net::lookup_host(&target)
512 .await
513 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
514 .collect();
515
516 if candidates.is_empty() {
517 let msg: String = format!("DNS lookup for '{host}' returned no addresses");
518 return Err(msg.into());
519 }
520
521 if let Err(e) = reject_if_any_forbidden(&policy, &host, &candidates) {
529 let msg: String = e.to_string();
530 return Err(msg.into());
531 }
532
533 let addrs: reqwest::dns::Addrs = Box::new(candidates.into_iter());
534 Ok(addrs)
535 })
536 }
537}
538
539fn classify_unsafe_v4(ip: Ipv4Addr) -> Option<SsrfReason> {
545 let o = ip.octets();
546 if o[0] == 0 {
547 Some(SsrfReason::MulticastOrReserved)
549 } else if o[0] == 10 {
550 Some(SsrfReason::Private)
552 } else if o[0] == 100 && (o[1] & 0xc0) == 64 {
553 Some(SsrfReason::Private)
555 } else if o[0] == 127 {
556 Some(SsrfReason::Loopback)
558 } else if o[0] == 169 && o[1] == 254 {
559 Some(SsrfReason::Imds)
561 } else if o[0] == 172 && (o[1] & 0xf0) == 16 {
562 Some(SsrfReason::Private)
564 } else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
565 Some(SsrfReason::MulticastOrReserved)
567 } else if o[0] == 192 && o[1] == 168 {
568 Some(SsrfReason::Private)
570 } else if o[0] == 198 && (o[1] == 18 || o[1] == 19) {
571 Some(SsrfReason::MulticastOrReserved)
573 } else if o[0] >= 224 && o[0] <= 239 {
574 Some(SsrfReason::MulticastOrReserved)
576 } else if o[0] >= 240 {
577 Some(SsrfReason::MulticastOrReserved)
579 } else {
580 None
581 }
582}
583
584fn classify_unsafe_v6(ip: Ipv6Addr) -> Option<SsrfReason> {
588 if ip.is_loopback() {
589 return Some(SsrfReason::Loopback);
590 }
591 if ip.is_unspecified() || ip.is_multicast() {
592 return Some(SsrfReason::MulticastOrReserved);
593 }
594 let segments = ip.segments();
595 if segments[0..5] == [0, 0, 0, 0, 0] && (segments[5] == 0 || segments[5] == 0xffff) {
602 let v4 = Ipv4Addr::new(
603 (segments[6] >> 8) as u8,
604 (segments[6] & 0xff) as u8,
605 (segments[7] >> 8) as u8,
606 (segments[7] & 0xff) as u8,
607 );
608 if !v4.is_unspecified() {
609 return classify_unsafe_v4(v4);
610 }
611 }
612 if segments[0] == 0x0064 && segments[1] == 0xff9b {
617 return Some(SsrfReason::Imds);
618 }
619 if (segments[0] & 0xfe00) == 0xfc00 {
621 return Some(SsrfReason::Private);
622 }
623 if (segments[0] & 0xffc0) == 0xfe80 {
625 return Some(SsrfReason::Imds);
626 }
627 None
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 #[cfg(feature = "client")]
639 #[tokio::test]
640 async fn safe_client_default_refuses_loopback() {
641 let client =
642 safe_client(&SsrfPolicy::default(), std::time::Duration::from_secs(2)).unwrap();
643 let result = client.get("http://127.0.0.1:9/").send().await;
644 assert!(
645 result.is_err(),
646 "default policy must refuse a loopback target"
647 );
648 }
649
650 #[cfg(all(feature = "client", feature = "test-transport"))]
653 #[test]
654 fn safe_client_builds_with_loopback_policy() {
655 assert!(safe_client(
656 &SsrfPolicy::allow_test_loopback(),
657 std::time::Duration::from_secs(2)
658 )
659 .is_ok());
660 }
661
662 #[test]
663 fn https_only_by_default() {
664 let p = SsrfPolicy::default();
665 assert!(p.check_url("https://registry.example.com").is_ok());
666 assert!(p.check_url("http://registry.example.com").is_err());
667 assert!(p.check_url("file:///etc/passwd").is_err());
668 }
669
670 #[test]
671 fn rejects_ip_literals_by_default() {
672 let p = SsrfPolicy::default();
673 assert!(p.check_url("https://192.168.1.1").is_err());
674 assert!(p.check_url("https://[::1]").is_err());
675 }
676
677 #[test]
678 fn private_v4_ranges_rejected() {
679 assert!(check_safe_ip("10.0.0.1".parse().unwrap()).is_err());
681 assert!(check_safe_ip("172.16.5.5".parse().unwrap()).is_err());
682 assert!(check_safe_ip("192.168.1.1".parse().unwrap()).is_err());
683 assert!(check_safe_ip("127.0.0.1".parse().unwrap()).is_err());
685 assert!(check_safe_ip("169.254.169.254".parse().unwrap()).is_err());
687 assert!(check_safe_ip("239.0.0.1".parse().unwrap()).is_err());
689 assert!(check_safe_ip("8.8.8.8".parse().unwrap()).is_ok());
691 assert!(check_safe_ip("203.0.113.1".parse().unwrap()).is_ok());
692 }
693
694 #[test]
695 fn unsafe_v6_rejected() {
696 assert!(check_safe_ip("::1".parse().unwrap()).is_err());
697 assert!(check_safe_ip("fc00::1".parse().unwrap()).is_err());
698 assert!(check_safe_ip("fe80::1".parse().unwrap()).is_err());
699 assert!(check_safe_ip("::ffff:10.0.0.1".parse().unwrap()).is_err());
701 assert!(check_safe_ip("::127.0.0.1".parse().unwrap()).is_err());
703 assert!(check_safe_ip("::7f00:1".parse().unwrap()).is_err());
704 assert!(check_safe_ip("::169.254.169.254".parse().unwrap()).is_err());
705 assert!(check_safe_ip("64:ff9b::a9fe:a9fe".parse().unwrap()).is_err());
707 assert!(check_safe_ip("64:ff9b::169.254.169.254".parse().unwrap()).is_err());
708 assert!(check_safe_ip("2001:db8::1".parse().unwrap()).is_ok());
710 assert!(check_safe_ip("::93.184.216.34".parse().unwrap()).is_ok());
712 }
713
714 #[test]
715 fn cross_authority_redirect_rejected() {
716 let p = SsrfPolicy::default();
717 let orig = url::Url::parse("https://registry.example.com/a").unwrap();
718 let err = p
719 .check_redirect_authority(&orig, "https://attacker.com/x")
720 .unwrap_err();
721 assert!(matches!(err, AcdpError::SchemaViolation(_)));
722 p.check_redirect_authority(&orig, "https://registry.example.com/y")
724 .unwrap();
725 }
726
727 fn u(s: &str) -> url::Url {
729 url::Url::parse(s).unwrap()
730 }
731
732 #[test]
733 fn same_host_same_implicit_port_allowed() {
734 assert!(same_fetch_authority(
735 &u("https://a.example/x"),
736 &u("https://a.example/y")
737 ));
738 }
739
740 #[test]
741 fn same_host_explicit_443_same_as_implicit_allowed() {
742 assert!(same_fetch_authority(
744 &u("https://a.example/x"),
745 &u("https://a.example:443/y")
746 ));
747 }
748
749 #[test]
750 fn same_host_different_port_rejected() {
751 assert!(!same_fetch_authority(
752 &u("https://a.example/x"),
753 &u("https://a.example:8443/y")
754 ));
755 }
756
757 #[test]
758 fn https_to_http_same_host_rejected() {
759 assert!(!same_fetch_authority(
761 &u("https://a.example/x"),
762 &u("http://a.example/y")
763 ));
764 }
765
766 #[test]
767 fn different_host_rejected() {
768 assert!(!same_fetch_authority(
769 &u("https://a.example/x"),
770 &u("https://b.example/y")
771 ));
772 }
773
774 #[test]
775 fn check_redirect_authority_rejects_port_change() {
776 let p = SsrfPolicy::default();
777 let orig = u("https://registry.example.com/a");
778 let err = p
779 .check_redirect_authority(&orig, "https://registry.example.com:8443/b")
780 .unwrap_err();
781 assert!(matches!(err, AcdpError::SchemaViolation(_)));
782 }
783
784 #[cfg(feature = "client")]
786 fn sock(s: &str) -> SocketAddr {
787 s.parse().unwrap()
788 }
789
790 #[cfg(feature = "client")]
791 #[test]
792 fn mixed_public_private_dns_rejected_entirely() {
793 let p = SsrfPolicy::default();
794 let candidates = [sock("203.0.113.10:443"), sock("10.0.0.1:443")];
795 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
796 }
797
798 #[cfg(feature = "client")]
799 #[test]
800 fn mixed_public_loopback_rejected() {
801 let p = SsrfPolicy::default();
802 let candidates = [sock("198.51.100.1:443"), sock("127.0.0.1:443")];
803 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
804 }
805
806 #[cfg(feature = "client")]
807 #[test]
808 fn mixed_public_imds_rejected() {
809 let p = SsrfPolicy::default();
810 let candidates = [sock("198.51.100.1:443"), sock("169.254.169.254:443")];
811 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
812 }
813
814 #[cfg(feature = "client")]
815 #[test]
816 fn single_public_ip_allowed() {
817 let p = SsrfPolicy::default();
818 let candidates = [sock("203.0.113.10:443")];
819 assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
820 }
821
822 #[cfg(feature = "client")]
823 #[test]
824 fn all_public_ips_allowed() {
825 let p = SsrfPolicy::default();
826 let candidates = [sock("203.0.113.10:443"), sock("198.51.100.1:443")];
827 assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
828 }
829
830 #[test]
831 fn allow_http_can_be_opted_into() {
832 let p = SsrfPolicy {
833 allow_http: true,
834 ..SsrfPolicy::default()
835 };
836 assert!(p.check_url("http://registry.example.com").is_ok());
837 }
838
839 fn reason_for_ip(s: &str) -> SsrfReason {
841 SsrfPolicy::default()
842 .classify_ip(s.parse().unwrap())
843 .unwrap_err()
844 .reason
845 }
846
847 #[test]
848 fn classify_ip_maps_stable_reasons() {
849 assert_eq!(reason_for_ip("127.0.0.1"), SsrfReason::Loopback);
850 assert_eq!(reason_for_ip("10.0.0.1"), SsrfReason::Private);
851 assert_eq!(reason_for_ip("172.16.5.5"), SsrfReason::Private);
852 assert_eq!(reason_for_ip("192.168.1.1"), SsrfReason::Private);
853 assert_eq!(reason_for_ip("100.64.0.1"), SsrfReason::Private);
854 assert_eq!(reason_for_ip("169.254.169.254"), SsrfReason::Imds);
855 assert_eq!(reason_for_ip("239.0.0.1"), SsrfReason::MulticastOrReserved);
856 assert_eq!(reason_for_ip("0.0.0.1"), SsrfReason::MulticastOrReserved);
857 assert_eq!(reason_for_ip("240.0.0.1"), SsrfReason::MulticastOrReserved);
858 assert_eq!(reason_for_ip("::1"), SsrfReason::Loopback);
860 assert_eq!(reason_for_ip("fc00::1"), SsrfReason::Private);
861 assert_eq!(reason_for_ip("fe80::1"), SsrfReason::Imds);
862 assert_eq!(reason_for_ip("64:ff9b::a9fe:a9fe"), SsrfReason::Imds);
864 assert_eq!(reason_for_ip("::ffff:10.0.0.1"), SsrfReason::Private);
866 assert!(SsrfPolicy::default()
868 .classify_ip("8.8.8.8".parse().unwrap())
869 .is_ok());
870 assert!(SsrfPolicy::default()
871 .classify_ip("2001:db8::1".parse().unwrap())
872 .is_ok());
873 }
874
875 #[test]
876 fn classify_reason_as_str_is_stable() {
877 assert_eq!(SsrfReason::NonHttps.as_str(), "non_https");
878 assert_eq!(SsrfReason::IpLiteral.as_str(), "ip_literal");
879 assert_eq!(SsrfReason::InvalidUrl.as_str(), "invalid_url");
880 assert_eq!(SsrfReason::Loopback.as_str(), "loopback");
881 assert_eq!(SsrfReason::Private.as_str(), "private");
882 assert_eq!(SsrfReason::Imds.as_str(), "imds");
883 assert_eq!(
884 SsrfReason::MulticastOrReserved.as_str(),
885 "multicast_or_reserved"
886 );
887 assert_eq!(SsrfReason::CrossAuthority.as_str(), "cross_authority");
888 }
889
890 #[test]
891 fn classify_url_maps_stable_reasons() {
892 let p = SsrfPolicy::default();
893 assert_eq!(
894 p.classify_url("http://registry.example.com")
895 .unwrap_err()
896 .reason,
897 SsrfReason::NonHttps
898 );
899 assert_eq!(
900 p.classify_url("https://192.168.1.1").unwrap_err().reason,
901 SsrfReason::IpLiteral
902 );
903 assert_eq!(
904 p.classify_url("https://[::1]").unwrap_err().reason,
905 SsrfReason::IpLiteral
906 );
907 assert_eq!(
908 p.classify_url("not a url").unwrap_err().reason,
909 SsrfReason::InvalidUrl
910 );
911 assert!(p.classify_url("https://registry.example.com").is_ok());
912 }
913
914 #[test]
915 fn classify_redirect_reasons_and_port_parity() {
916 let p = SsrfPolicy::default();
917 assert_eq!(
919 p.classify_redirect("https://a.example/x", "https://b.example/y")
920 .unwrap_err()
921 .reason,
922 SsrfReason::CrossAuthority
923 );
924 assert_eq!(
926 p.classify_redirect("https://a.example/x", "https://a.example:8443/y")
927 .unwrap_err()
928 .reason,
929 SsrfReason::CrossAuthority
930 );
931 assert_eq!(
933 p.classify_redirect("https://a.example/x", "http://a.example/y")
934 .unwrap_err()
935 .reason,
936 SsrfReason::CrossAuthority
937 );
938 assert!(p
940 .classify_redirect("https://a.example/x", "https://a.example:443/y")
941 .is_ok());
942 assert!(p
944 .classify_redirect("https://a.example/x", "https://a.example/y")
945 .is_ok());
946 assert_eq!(
948 p.classify_redirect("::not-a-url", "https://a.example/y")
949 .unwrap_err()
950 .reason,
951 SsrfReason::InvalidUrl
952 );
953 }
954
955 #[test]
956 fn check_wrappers_preserve_schema_violation() {
957 let p = SsrfPolicy::default();
960 let err = p.check_url("http://registry.example.com").unwrap_err();
961 assert!(matches!(err, AcdpError::SchemaViolation(_)));
962 let err = p.check_ip("10.0.0.1".parse().unwrap()).unwrap_err();
963 assert!(matches!(err, AcdpError::SchemaViolation(_)));
964 }
965
966 #[cfg(feature = "client")]
972 #[tokio::test]
973 async fn pin_resolved_ip_rejects_loopback_hostname() {
974 let p = SsrfPolicy::default();
975 let err = p.pin_resolved_ip("localhost", 443).await.unwrap_err();
976 assert!(matches!(err, AcdpError::SchemaViolation(_)));
977 }
978}