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 pub fn allow_test_loopback() -> Self {
172 Self {
173 allow_loopback_resolved: true,
174 ..Self::default()
175 }
176 }
177}
178
179impl SsrfPolicy {
180 pub fn check_url(&self, url: &str) -> Result<(), AcdpError> {
186 self.classify_url(url).map_err(AcdpError::from)
187 }
188
189 pub fn classify_url(&self, url: &str) -> Result<(), SsrfRejection> {
198 let parsed = url::Url::parse(url).map_err(|e| SsrfRejection {
199 reason: SsrfReason::InvalidUrl,
200 detail: format!("invalid URL: {e}"),
201 })?;
202
203 if !self.allow_http && parsed.scheme() != "https" {
204 return Err(SsrfRejection {
205 reason: SsrfReason::NonHttps,
206 detail: format!(
207 "SSRF policy: scheme '{}' not permitted; only https",
208 parsed.scheme()
209 ),
210 });
211 }
212
213 let host = parsed.host().ok_or_else(|| SsrfRejection {
214 reason: SsrfReason::InvalidUrl,
215 detail: format!("URL has no host: {url}"),
216 })?;
217
218 match host {
219 url::Host::Ipv4(v4) => {
220 if self.reject_ip_literals {
221 return Err(SsrfRejection {
222 reason: SsrfReason::IpLiteral,
223 detail: format!(
224 "SSRF policy: IPv4 literal '{v4}' not permitted; use a hostname"
225 ),
226 });
227 }
228 self.classify_ip(IpAddr::V4(v4))?;
229 }
230 url::Host::Ipv6(v6) => {
231 if self.reject_ip_literals {
232 return Err(SsrfRejection {
233 reason: SsrfReason::IpLiteral,
234 detail: format!(
235 "SSRF policy: IPv6 literal '{v6}' not permitted; use a hostname"
236 ),
237 });
238 }
239 self.classify_ip(IpAddr::V6(v6))?;
240 }
241 url::Host::Domain(name) => {
242 if name.is_empty() || name.len() > 253 {
243 return Err(SsrfRejection {
244 reason: SsrfReason::InvalidUrl,
245 detail: format!("SSRF policy: invalid hostname length: {name}"),
246 });
247 }
248 }
249 }
250
251 Ok(())
252 }
253
254 pub fn check_resolved_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
258 self.check_ip(ip)
259 }
260
261 pub fn check_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
266 self.classify_ip(ip).map_err(AcdpError::from)
267 }
268
269 pub fn classify_ip(&self, ip: IpAddr) -> Result<(), SsrfRejection> {
273 let reason = match ip {
274 IpAddr::V4(v4) => {
275 if self.allow_loopback_resolved && v4.is_loopback() {
276 None
277 } else {
278 classify_unsafe_v4(v4)
279 }
280 }
281 IpAddr::V6(v6) => {
282 if self.allow_loopback_resolved && v6.is_loopback() {
283 None
284 } else {
285 classify_unsafe_v6(v6)
286 }
287 }
288 };
289 match reason {
290 Some(reason) => Err(SsrfRejection {
291 reason,
292 detail: format!("SSRF policy: IP address '{ip}' is in a forbidden range"),
293 }),
294 None => Ok(()),
295 }
296 }
297
298 #[cfg(feature = "client")]
316 pub async fn pin_resolved_ip(&self, host: &str, port: u16) -> Result<SocketAddr, AcdpError> {
317 let target = format!("{host}:{port}");
318 let candidates: Vec<SocketAddr> = tokio::net::lookup_host(&target)
319 .await
320 .map_err(|e| AcdpError::Http(format!("DNS lookup for '{host}' failed: {e}")))?
321 .collect();
322 if candidates.is_empty() {
323 return Err(AcdpError::Http(format!(
324 "DNS lookup for '{host}' returned no addresses"
325 )));
326 }
327 reject_if_any_forbidden(self, host, &candidates)?;
330 let pinned = candidates
332 .iter()
333 .find(|a| a.is_ipv4())
334 .or_else(|| candidates.first())
335 .copied()
336 .expect("candidates is non-empty");
337 Ok(pinned)
338 }
339
340 pub fn check_redirect_authority(
344 &self,
345 original_url: &url::Url,
346 redirect_url: &str,
347 ) -> Result<(), AcdpError> {
348 self.classify_redirect_authority(original_url, redirect_url)
349 .map_err(AcdpError::from)
350 }
351
352 pub fn classify_redirect_authority(
355 &self,
356 original_url: &url::Url,
357 redirect_url: &str,
358 ) -> Result<(), SsrfRejection> {
359 let redirect = url::Url::parse(redirect_url).map_err(|e| SsrfRejection {
360 reason: SsrfReason::InvalidUrl,
361 detail: format!("invalid redirect URL: {e}"),
362 })?;
363 if !same_fetch_authority(original_url, &redirect) {
364 return Err(SsrfRejection {
365 reason: SsrfReason::CrossAuthority,
366 detail: format!(
367 "SSRF policy: cross-authority redirect rejected: {original_url} → {redirect}"
368 ),
369 });
370 }
371 Ok(())
372 }
373
374 pub fn classify_redirect(&self, from_url: &str, to_url: &str) -> Result<(), SsrfRejection> {
379 let original = url::Url::parse(from_url).map_err(|e| SsrfRejection {
380 reason: SsrfReason::InvalidUrl,
381 detail: format!("invalid origin URL: {e}"),
382 })?;
383 self.classify_redirect_authority(&original, to_url)
384 }
385}
386
387#[doc(hidden)]
395pub fn same_fetch_authority(a: &url::Url, b: &url::Url) -> bool {
396 a.scheme() == b.scheme()
397 && a.host_str() == b.host_str()
398 && a.port_or_known_default() == b.port_or_known_default()
399}
400
401#[cfg(test)]
406fn check_safe_ip(ip: IpAddr) -> Result<(), AcdpError> {
407 let bad = match ip {
408 IpAddr::V4(v4) => classify_unsafe_v4(v4).is_some(),
409 IpAddr::V6(v6) => classify_unsafe_v6(v6).is_some(),
410 };
411 if bad {
412 return Err(AcdpError::SchemaViolation(format!(
413 "SSRF policy: IP address '{ip}' is in a forbidden range"
414 )));
415 }
416 Ok(())
417}
418
419#[cfg(feature = "client")]
432fn reject_if_any_forbidden(
433 policy: &SsrfPolicy,
434 host: &str,
435 candidates: &[SocketAddr],
436) -> Result<(), AcdpError> {
437 for addr in candidates {
438 if let Err(e) = policy.check_ip(addr.ip()) {
439 return Err(AcdpError::SchemaViolation(format!(
440 "SSRF policy: DNS answer for '{host}' contains a forbidden address \
441 ({} is disallowed); rejecting the entire resolution. {e}",
442 addr.ip()
443 )));
444 }
445 }
446 Ok(())
447}
448
449#[cfg(feature = "client")]
452#[doc(hidden)]
453pub struct SafeDnsResolver {
454 policy: SsrfPolicy,
455}
456
457#[cfg(feature = "client")]
458impl SafeDnsResolver {
459 #[doc(hidden)]
460 pub fn arc(policy: SsrfPolicy) -> Arc<Self> {
461 Arc::new(Self { policy })
462 }
463}
464
465#[cfg(feature = "client")]
475pub fn safe_client(
476 policy: &SsrfPolicy,
477 timeout: std::time::Duration,
478) -> Result<reqwest::Client, AcdpError> {
479 reqwest::Client::builder()
480 .use_rustls_tls()
481 .connect_timeout(std::time::Duration::from_secs(5))
482 .timeout(timeout)
483 .redirect(reqwest::redirect::Policy::none())
484 .pool_max_idle_per_host(0)
489 .dns_resolver(SafeDnsResolver::arc(policy.clone()))
490 .build()
491 .map_err(|e| AcdpError::Http(e.to_string()))
492}
493
494#[cfg(feature = "client")]
495impl reqwest::dns::Resolve for SafeDnsResolver {
496 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
497 let policy = self.policy.clone();
498 let host = name.as_str().to_string();
499 Box::pin(async move {
500 let target = format!("{host}:0");
504 let candidates: Vec<SocketAddr> = tokio::net::lookup_host(&target)
505 .await
506 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
507 .collect();
508
509 if candidates.is_empty() {
510 let msg: String = format!("DNS lookup for '{host}' returned no addresses");
511 return Err(msg.into());
512 }
513
514 if let Err(e) = reject_if_any_forbidden(&policy, &host, &candidates) {
522 let msg: String = e.to_string();
523 return Err(msg.into());
524 }
525
526 let addrs: reqwest::dns::Addrs = Box::new(candidates.into_iter());
527 Ok(addrs)
528 })
529 }
530}
531
532fn classify_unsafe_v4(ip: Ipv4Addr) -> Option<SsrfReason> {
538 let o = ip.octets();
539 if o[0] == 0 {
540 Some(SsrfReason::MulticastOrReserved)
542 } else if o[0] == 10 {
543 Some(SsrfReason::Private)
545 } else if o[0] == 100 && (o[1] & 0xc0) == 64 {
546 Some(SsrfReason::Private)
548 } else if o[0] == 127 {
549 Some(SsrfReason::Loopback)
551 } else if o[0] == 169 && o[1] == 254 {
552 Some(SsrfReason::Imds)
554 } else if o[0] == 172 && (o[1] & 0xf0) == 16 {
555 Some(SsrfReason::Private)
557 } else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
558 Some(SsrfReason::MulticastOrReserved)
560 } else if o[0] == 192 && o[1] == 168 {
561 Some(SsrfReason::Private)
563 } else if o[0] == 198 && (o[1] == 18 || o[1] == 19) {
564 Some(SsrfReason::MulticastOrReserved)
566 } else if o[0] >= 224 && o[0] <= 239 {
567 Some(SsrfReason::MulticastOrReserved)
569 } else if o[0] >= 240 {
570 Some(SsrfReason::MulticastOrReserved)
572 } else {
573 None
574 }
575}
576
577fn classify_unsafe_v6(ip: Ipv6Addr) -> Option<SsrfReason> {
581 if ip.is_loopback() {
582 return Some(SsrfReason::Loopback);
583 }
584 if ip.is_unspecified() || ip.is_multicast() {
585 return Some(SsrfReason::MulticastOrReserved);
586 }
587 let segments = ip.segments();
588 if segments[0..5] == [0, 0, 0, 0, 0] && (segments[5] == 0 || segments[5] == 0xffff) {
595 let v4 = Ipv4Addr::new(
596 (segments[6] >> 8) as u8,
597 (segments[6] & 0xff) as u8,
598 (segments[7] >> 8) as u8,
599 (segments[7] & 0xff) as u8,
600 );
601 if !v4.is_unspecified() {
602 return classify_unsafe_v4(v4);
603 }
604 }
605 if segments[0] == 0x0064 && segments[1] == 0xff9b {
610 return Some(SsrfReason::Imds);
611 }
612 if (segments[0] & 0xfe00) == 0xfc00 {
614 return Some(SsrfReason::Private);
615 }
616 if (segments[0] & 0xffc0) == 0xfe80 {
618 return Some(SsrfReason::Imds);
619 }
620 None
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[cfg(feature = "client")]
632 #[tokio::test]
633 async fn safe_client_default_refuses_loopback() {
634 let client =
635 safe_client(&SsrfPolicy::default(), std::time::Duration::from_secs(2)).unwrap();
636 let result = client.get("http://127.0.0.1:9/").send().await;
637 assert!(
638 result.is_err(),
639 "default policy must refuse a loopback target"
640 );
641 }
642
643 #[cfg(feature = "client")]
646 #[test]
647 fn safe_client_builds_with_loopback_policy() {
648 assert!(safe_client(
649 &SsrfPolicy::allow_test_loopback(),
650 std::time::Duration::from_secs(2)
651 )
652 .is_ok());
653 }
654
655 #[test]
656 fn https_only_by_default() {
657 let p = SsrfPolicy::default();
658 assert!(p.check_url("https://registry.example.com").is_ok());
659 assert!(p.check_url("http://registry.example.com").is_err());
660 assert!(p.check_url("file:///etc/passwd").is_err());
661 }
662
663 #[test]
664 fn rejects_ip_literals_by_default() {
665 let p = SsrfPolicy::default();
666 assert!(p.check_url("https://192.168.1.1").is_err());
667 assert!(p.check_url("https://[::1]").is_err());
668 }
669
670 #[test]
671 fn private_v4_ranges_rejected() {
672 assert!(check_safe_ip("10.0.0.1".parse().unwrap()).is_err());
674 assert!(check_safe_ip("172.16.5.5".parse().unwrap()).is_err());
675 assert!(check_safe_ip("192.168.1.1".parse().unwrap()).is_err());
676 assert!(check_safe_ip("127.0.0.1".parse().unwrap()).is_err());
678 assert!(check_safe_ip("169.254.169.254".parse().unwrap()).is_err());
680 assert!(check_safe_ip("239.0.0.1".parse().unwrap()).is_err());
682 assert!(check_safe_ip("8.8.8.8".parse().unwrap()).is_ok());
684 assert!(check_safe_ip("203.0.113.1".parse().unwrap()).is_ok());
685 }
686
687 #[test]
688 fn unsafe_v6_rejected() {
689 assert!(check_safe_ip("::1".parse().unwrap()).is_err());
690 assert!(check_safe_ip("fc00::1".parse().unwrap()).is_err());
691 assert!(check_safe_ip("fe80::1".parse().unwrap()).is_err());
692 assert!(check_safe_ip("::ffff:10.0.0.1".parse().unwrap()).is_err());
694 assert!(check_safe_ip("::127.0.0.1".parse().unwrap()).is_err());
696 assert!(check_safe_ip("::7f00:1".parse().unwrap()).is_err());
697 assert!(check_safe_ip("::169.254.169.254".parse().unwrap()).is_err());
698 assert!(check_safe_ip("64:ff9b::a9fe:a9fe".parse().unwrap()).is_err());
700 assert!(check_safe_ip("64:ff9b::169.254.169.254".parse().unwrap()).is_err());
701 assert!(check_safe_ip("2001:db8::1".parse().unwrap()).is_ok());
703 assert!(check_safe_ip("::93.184.216.34".parse().unwrap()).is_ok());
705 }
706
707 #[test]
708 fn cross_authority_redirect_rejected() {
709 let p = SsrfPolicy::default();
710 let orig = url::Url::parse("https://registry.example.com/a").unwrap();
711 let err = p
712 .check_redirect_authority(&orig, "https://attacker.com/x")
713 .unwrap_err();
714 assert!(matches!(err, AcdpError::SchemaViolation(_)));
715 p.check_redirect_authority(&orig, "https://registry.example.com/y")
717 .unwrap();
718 }
719
720 fn u(s: &str) -> url::Url {
722 url::Url::parse(s).unwrap()
723 }
724
725 #[test]
726 fn same_host_same_implicit_port_allowed() {
727 assert!(same_fetch_authority(
728 &u("https://a.example/x"),
729 &u("https://a.example/y")
730 ));
731 }
732
733 #[test]
734 fn same_host_explicit_443_same_as_implicit_allowed() {
735 assert!(same_fetch_authority(
737 &u("https://a.example/x"),
738 &u("https://a.example:443/y")
739 ));
740 }
741
742 #[test]
743 fn same_host_different_port_rejected() {
744 assert!(!same_fetch_authority(
745 &u("https://a.example/x"),
746 &u("https://a.example:8443/y")
747 ));
748 }
749
750 #[test]
751 fn https_to_http_same_host_rejected() {
752 assert!(!same_fetch_authority(
754 &u("https://a.example/x"),
755 &u("http://a.example/y")
756 ));
757 }
758
759 #[test]
760 fn different_host_rejected() {
761 assert!(!same_fetch_authority(
762 &u("https://a.example/x"),
763 &u("https://b.example/y")
764 ));
765 }
766
767 #[test]
768 fn check_redirect_authority_rejects_port_change() {
769 let p = SsrfPolicy::default();
770 let orig = u("https://registry.example.com/a");
771 let err = p
772 .check_redirect_authority(&orig, "https://registry.example.com:8443/b")
773 .unwrap_err();
774 assert!(matches!(err, AcdpError::SchemaViolation(_)));
775 }
776
777 #[cfg(feature = "client")]
779 fn sock(s: &str) -> SocketAddr {
780 s.parse().unwrap()
781 }
782
783 #[cfg(feature = "client")]
784 #[test]
785 fn mixed_public_private_dns_rejected_entirely() {
786 let p = SsrfPolicy::default();
787 let candidates = [sock("203.0.113.10:443"), sock("10.0.0.1:443")];
788 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
789 }
790
791 #[cfg(feature = "client")]
792 #[test]
793 fn mixed_public_loopback_rejected() {
794 let p = SsrfPolicy::default();
795 let candidates = [sock("198.51.100.1:443"), sock("127.0.0.1:443")];
796 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
797 }
798
799 #[cfg(feature = "client")]
800 #[test]
801 fn mixed_public_imds_rejected() {
802 let p = SsrfPolicy::default();
803 let candidates = [sock("198.51.100.1:443"), sock("169.254.169.254:443")];
804 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
805 }
806
807 #[cfg(feature = "client")]
808 #[test]
809 fn single_public_ip_allowed() {
810 let p = SsrfPolicy::default();
811 let candidates = [sock("203.0.113.10:443")];
812 assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
813 }
814
815 #[cfg(feature = "client")]
816 #[test]
817 fn all_public_ips_allowed() {
818 let p = SsrfPolicy::default();
819 let candidates = [sock("203.0.113.10:443"), sock("198.51.100.1:443")];
820 assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
821 }
822
823 #[test]
824 fn allow_http_can_be_opted_into() {
825 let p = SsrfPolicy {
826 allow_http: true,
827 ..SsrfPolicy::default()
828 };
829 assert!(p.check_url("http://registry.example.com").is_ok());
830 }
831
832 fn reason_for_ip(s: &str) -> SsrfReason {
834 SsrfPolicy::default()
835 .classify_ip(s.parse().unwrap())
836 .unwrap_err()
837 .reason
838 }
839
840 #[test]
841 fn classify_ip_maps_stable_reasons() {
842 assert_eq!(reason_for_ip("127.0.0.1"), SsrfReason::Loopback);
843 assert_eq!(reason_for_ip("10.0.0.1"), SsrfReason::Private);
844 assert_eq!(reason_for_ip("172.16.5.5"), SsrfReason::Private);
845 assert_eq!(reason_for_ip("192.168.1.1"), SsrfReason::Private);
846 assert_eq!(reason_for_ip("100.64.0.1"), SsrfReason::Private);
847 assert_eq!(reason_for_ip("169.254.169.254"), SsrfReason::Imds);
848 assert_eq!(reason_for_ip("239.0.0.1"), SsrfReason::MulticastOrReserved);
849 assert_eq!(reason_for_ip("0.0.0.1"), SsrfReason::MulticastOrReserved);
850 assert_eq!(reason_for_ip("240.0.0.1"), SsrfReason::MulticastOrReserved);
851 assert_eq!(reason_for_ip("::1"), SsrfReason::Loopback);
853 assert_eq!(reason_for_ip("fc00::1"), SsrfReason::Private);
854 assert_eq!(reason_for_ip("fe80::1"), SsrfReason::Imds);
855 assert_eq!(reason_for_ip("64:ff9b::a9fe:a9fe"), SsrfReason::Imds);
857 assert_eq!(reason_for_ip("::ffff:10.0.0.1"), SsrfReason::Private);
859 assert!(SsrfPolicy::default()
861 .classify_ip("8.8.8.8".parse().unwrap())
862 .is_ok());
863 assert!(SsrfPolicy::default()
864 .classify_ip("2001:db8::1".parse().unwrap())
865 .is_ok());
866 }
867
868 #[test]
869 fn classify_reason_as_str_is_stable() {
870 assert_eq!(SsrfReason::NonHttps.as_str(), "non_https");
871 assert_eq!(SsrfReason::IpLiteral.as_str(), "ip_literal");
872 assert_eq!(SsrfReason::InvalidUrl.as_str(), "invalid_url");
873 assert_eq!(SsrfReason::Loopback.as_str(), "loopback");
874 assert_eq!(SsrfReason::Private.as_str(), "private");
875 assert_eq!(SsrfReason::Imds.as_str(), "imds");
876 assert_eq!(
877 SsrfReason::MulticastOrReserved.as_str(),
878 "multicast_or_reserved"
879 );
880 assert_eq!(SsrfReason::CrossAuthority.as_str(), "cross_authority");
881 }
882
883 #[test]
884 fn classify_url_maps_stable_reasons() {
885 let p = SsrfPolicy::default();
886 assert_eq!(
887 p.classify_url("http://registry.example.com")
888 .unwrap_err()
889 .reason,
890 SsrfReason::NonHttps
891 );
892 assert_eq!(
893 p.classify_url("https://192.168.1.1").unwrap_err().reason,
894 SsrfReason::IpLiteral
895 );
896 assert_eq!(
897 p.classify_url("https://[::1]").unwrap_err().reason,
898 SsrfReason::IpLiteral
899 );
900 assert_eq!(
901 p.classify_url("not a url").unwrap_err().reason,
902 SsrfReason::InvalidUrl
903 );
904 assert!(p.classify_url("https://registry.example.com").is_ok());
905 }
906
907 #[test]
908 fn classify_redirect_reasons_and_port_parity() {
909 let p = SsrfPolicy::default();
910 assert_eq!(
912 p.classify_redirect("https://a.example/x", "https://b.example/y")
913 .unwrap_err()
914 .reason,
915 SsrfReason::CrossAuthority
916 );
917 assert_eq!(
919 p.classify_redirect("https://a.example/x", "https://a.example:8443/y")
920 .unwrap_err()
921 .reason,
922 SsrfReason::CrossAuthority
923 );
924 assert_eq!(
926 p.classify_redirect("https://a.example/x", "http://a.example/y")
927 .unwrap_err()
928 .reason,
929 SsrfReason::CrossAuthority
930 );
931 assert!(p
933 .classify_redirect("https://a.example/x", "https://a.example:443/y")
934 .is_ok());
935 assert!(p
937 .classify_redirect("https://a.example/x", "https://a.example/y")
938 .is_ok());
939 assert_eq!(
941 p.classify_redirect("::not-a-url", "https://a.example/y")
942 .unwrap_err()
943 .reason,
944 SsrfReason::InvalidUrl
945 );
946 }
947
948 #[test]
949 fn check_wrappers_preserve_schema_violation() {
950 let p = SsrfPolicy::default();
953 let err = p.check_url("http://registry.example.com").unwrap_err();
954 assert!(matches!(err, AcdpError::SchemaViolation(_)));
955 let err = p.check_ip("10.0.0.1".parse().unwrap()).unwrap_err();
956 assert!(matches!(err, AcdpError::SchemaViolation(_)));
957 }
958
959 #[cfg(feature = "client")]
965 #[tokio::test]
966 async fn pin_resolved_ip_rejects_loopback_hostname() {
967 let p = SsrfPolicy::default();
968 let err = p.pin_resolved_ip("localhost", 443).await.unwrap_err();
969 assert!(matches!(err, AcdpError::SchemaViolation(_)));
970 }
971}