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) {
532 let msg = match e {
533 AcdpError::SchemaViolation(m) => m,
534 other => other.to_string(),
535 };
536 let refusal: Box<dyn std::error::Error + Send + Sync> =
537 Box::new(acdp_primitives::error::SsrfDnsRefusal::new(msg));
538 return Err(refusal);
539 }
540
541 let addrs: reqwest::dns::Addrs = Box::new(candidates.into_iter());
542 Ok(addrs)
543 })
544 }
545}
546
547fn classify_unsafe_v4(ip: Ipv4Addr) -> Option<SsrfReason> {
553 let o = ip.octets();
554 if o[0] == 0 {
555 Some(SsrfReason::MulticastOrReserved)
557 } else if o[0] == 10 {
558 Some(SsrfReason::Private)
560 } else if o[0] == 100 && (o[1] & 0xc0) == 64 {
561 Some(SsrfReason::Private)
563 } else if o[0] == 127 {
564 Some(SsrfReason::Loopback)
566 } else if o[0] == 169 && o[1] == 254 {
567 Some(SsrfReason::Imds)
569 } else if o[0] == 172 && (o[1] & 0xf0) == 16 {
570 Some(SsrfReason::Private)
572 } else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
573 Some(SsrfReason::MulticastOrReserved)
575 } else if o[0] == 192 && o[1] == 168 {
576 Some(SsrfReason::Private)
578 } else if o[0] == 198 && (o[1] == 18 || o[1] == 19) {
579 Some(SsrfReason::MulticastOrReserved)
581 } else if o[0] >= 224 && o[0] <= 239 {
582 Some(SsrfReason::MulticastOrReserved)
584 } else if o[0] >= 240 {
585 Some(SsrfReason::MulticastOrReserved)
587 } else {
588 None
589 }
590}
591
592fn classify_unsafe_v6(ip: Ipv6Addr) -> Option<SsrfReason> {
596 if ip.is_loopback() {
597 return Some(SsrfReason::Loopback);
598 }
599 if ip.is_unspecified() || ip.is_multicast() {
600 return Some(SsrfReason::MulticastOrReserved);
601 }
602 let segments = ip.segments();
603 if segments[0..5] == [0, 0, 0, 0, 0] && (segments[5] == 0 || segments[5] == 0xffff) {
610 let v4 = Ipv4Addr::new(
611 (segments[6] >> 8) as u8,
612 (segments[6] & 0xff) as u8,
613 (segments[7] >> 8) as u8,
614 (segments[7] & 0xff) as u8,
615 );
616 if !v4.is_unspecified() {
617 return classify_unsafe_v4(v4);
618 }
619 }
620 if segments[0] == 0x0064 && segments[1] == 0xff9b {
625 return Some(SsrfReason::Imds);
626 }
627 if (segments[0] & 0xfe00) == 0xfc00 {
629 return Some(SsrfReason::Private);
630 }
631 if (segments[0] & 0xffc0) == 0xfe80 {
633 return Some(SsrfReason::Imds);
634 }
635 None
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[cfg(feature = "client")]
647 #[tokio::test]
648 async fn safe_client_default_refuses_loopback() {
649 let client =
650 safe_client(&SsrfPolicy::default(), std::time::Duration::from_secs(2)).unwrap();
651 let result = client.get("http://127.0.0.1:9/").send().await;
652 assert!(
653 result.is_err(),
654 "default policy must refuse a loopback target"
655 );
656 }
657
658 #[cfg(feature = "client")]
663 #[tokio::test]
664 async fn dns_refusal_carries_typed_marker_through_reqwest() {
665 let client =
666 safe_client(&SsrfPolicy::default(), std::time::Duration::from_secs(2)).unwrap();
667 let err = client
668 .get("https://localhost:9/")
669 .send()
670 .await
671 .expect_err("default policy must refuse localhost");
672 let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(&err);
673 let mut found = false;
674 while let Some(e) = cur {
675 if let Some(r) = e.downcast_ref::<acdp_primitives::error::SsrfDnsRefusal>() {
676 assert!(r.message().contains("SSRF policy"), "{}", r.message());
677 found = true;
678 break;
679 }
680 cur = e.source();
681 }
682 assert!(found, "typed SsrfDnsRefusal missing from chain: {err:?}");
683 assert!(acdp_primitives::error::is_ssrf_refusal(&err));
684 }
685
686 #[cfg(all(feature = "client", feature = "test-transport"))]
689 #[test]
690 fn safe_client_builds_with_loopback_policy() {
691 assert!(safe_client(
692 &SsrfPolicy::allow_test_loopback(),
693 std::time::Duration::from_secs(2)
694 )
695 .is_ok());
696 }
697
698 #[test]
699 fn https_only_by_default() {
700 let p = SsrfPolicy::default();
701 assert!(p.check_url("https://registry.example.com").is_ok());
702 assert!(p.check_url("http://registry.example.com").is_err());
703 assert!(p.check_url("file:///etc/passwd").is_err());
704 }
705
706 #[test]
707 fn rejects_ip_literals_by_default() {
708 let p = SsrfPolicy::default();
709 assert!(p.check_url("https://192.168.1.1").is_err());
710 assert!(p.check_url("https://[::1]").is_err());
711 }
712
713 #[test]
714 fn private_v4_ranges_rejected() {
715 assert!(check_safe_ip("10.0.0.1".parse().unwrap()).is_err());
717 assert!(check_safe_ip("172.16.5.5".parse().unwrap()).is_err());
718 assert!(check_safe_ip("192.168.1.1".parse().unwrap()).is_err());
719 assert!(check_safe_ip("127.0.0.1".parse().unwrap()).is_err());
721 assert!(check_safe_ip("169.254.169.254".parse().unwrap()).is_err());
723 assert!(check_safe_ip("239.0.0.1".parse().unwrap()).is_err());
725 assert!(check_safe_ip("8.8.8.8".parse().unwrap()).is_ok());
727 assert!(check_safe_ip("203.0.113.1".parse().unwrap()).is_ok());
728 }
729
730 #[test]
731 fn unsafe_v6_rejected() {
732 assert!(check_safe_ip("::1".parse().unwrap()).is_err());
733 assert!(check_safe_ip("fc00::1".parse().unwrap()).is_err());
734 assert!(check_safe_ip("fe80::1".parse().unwrap()).is_err());
735 assert!(check_safe_ip("::ffff:10.0.0.1".parse().unwrap()).is_err());
737 assert!(check_safe_ip("::127.0.0.1".parse().unwrap()).is_err());
739 assert!(check_safe_ip("::7f00:1".parse().unwrap()).is_err());
740 assert!(check_safe_ip("::169.254.169.254".parse().unwrap()).is_err());
741 assert!(check_safe_ip("64:ff9b::a9fe:a9fe".parse().unwrap()).is_err());
743 assert!(check_safe_ip("64:ff9b::169.254.169.254".parse().unwrap()).is_err());
744 assert!(check_safe_ip("2001:db8::1".parse().unwrap()).is_ok());
746 assert!(check_safe_ip("::93.184.216.34".parse().unwrap()).is_ok());
748 }
749
750 #[test]
751 fn cross_authority_redirect_rejected() {
752 let p = SsrfPolicy::default();
753 let orig = url::Url::parse("https://registry.example.com/a").unwrap();
754 let err = p
755 .check_redirect_authority(&orig, "https://attacker.com/x")
756 .unwrap_err();
757 assert!(matches!(err, AcdpError::SchemaViolation(_)));
758 p.check_redirect_authority(&orig, "https://registry.example.com/y")
760 .unwrap();
761 }
762
763 fn u(s: &str) -> url::Url {
765 url::Url::parse(s).unwrap()
766 }
767
768 #[test]
769 fn same_host_same_implicit_port_allowed() {
770 assert!(same_fetch_authority(
771 &u("https://a.example/x"),
772 &u("https://a.example/y")
773 ));
774 }
775
776 #[test]
777 fn same_host_explicit_443_same_as_implicit_allowed() {
778 assert!(same_fetch_authority(
780 &u("https://a.example/x"),
781 &u("https://a.example:443/y")
782 ));
783 }
784
785 #[test]
786 fn same_host_different_port_rejected() {
787 assert!(!same_fetch_authority(
788 &u("https://a.example/x"),
789 &u("https://a.example:8443/y")
790 ));
791 }
792
793 #[test]
794 fn https_to_http_same_host_rejected() {
795 assert!(!same_fetch_authority(
797 &u("https://a.example/x"),
798 &u("http://a.example/y")
799 ));
800 }
801
802 #[test]
803 fn different_host_rejected() {
804 assert!(!same_fetch_authority(
805 &u("https://a.example/x"),
806 &u("https://b.example/y")
807 ));
808 }
809
810 #[test]
811 fn check_redirect_authority_rejects_port_change() {
812 let p = SsrfPolicy::default();
813 let orig = u("https://registry.example.com/a");
814 let err = p
815 .check_redirect_authority(&orig, "https://registry.example.com:8443/b")
816 .unwrap_err();
817 assert!(matches!(err, AcdpError::SchemaViolation(_)));
818 }
819
820 #[cfg(feature = "client")]
822 fn sock(s: &str) -> SocketAddr {
823 s.parse().unwrap()
824 }
825
826 #[cfg(feature = "client")]
827 #[test]
828 fn mixed_public_private_dns_rejected_entirely() {
829 let p = SsrfPolicy::default();
830 let candidates = [sock("203.0.113.10:443"), sock("10.0.0.1:443")];
831 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
832 }
833
834 #[cfg(feature = "client")]
835 #[test]
836 fn mixed_public_loopback_rejected() {
837 let p = SsrfPolicy::default();
838 let candidates = [sock("198.51.100.1:443"), sock("127.0.0.1:443")];
839 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
840 }
841
842 #[cfg(feature = "client")]
843 #[test]
844 fn mixed_public_imds_rejected() {
845 let p = SsrfPolicy::default();
846 let candidates = [sock("198.51.100.1:443"), sock("169.254.169.254:443")];
847 assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
848 }
849
850 #[cfg(feature = "client")]
851 #[test]
852 fn single_public_ip_allowed() {
853 let p = SsrfPolicy::default();
854 let candidates = [sock("203.0.113.10:443")];
855 assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
856 }
857
858 #[cfg(feature = "client")]
859 #[test]
860 fn all_public_ips_allowed() {
861 let p = SsrfPolicy::default();
862 let candidates = [sock("203.0.113.10:443"), sock("198.51.100.1:443")];
863 assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
864 }
865
866 #[test]
867 fn allow_http_can_be_opted_into() {
868 let p = SsrfPolicy {
869 allow_http: true,
870 ..SsrfPolicy::default()
871 };
872 assert!(p.check_url("http://registry.example.com").is_ok());
873 }
874
875 fn reason_for_ip(s: &str) -> SsrfReason {
877 SsrfPolicy::default()
878 .classify_ip(s.parse().unwrap())
879 .unwrap_err()
880 .reason
881 }
882
883 #[test]
884 fn classify_ip_maps_stable_reasons() {
885 assert_eq!(reason_for_ip("127.0.0.1"), SsrfReason::Loopback);
886 assert_eq!(reason_for_ip("10.0.0.1"), SsrfReason::Private);
887 assert_eq!(reason_for_ip("172.16.5.5"), SsrfReason::Private);
888 assert_eq!(reason_for_ip("192.168.1.1"), SsrfReason::Private);
889 assert_eq!(reason_for_ip("100.64.0.1"), SsrfReason::Private);
890 assert_eq!(reason_for_ip("169.254.169.254"), SsrfReason::Imds);
891 assert_eq!(reason_for_ip("239.0.0.1"), SsrfReason::MulticastOrReserved);
892 assert_eq!(reason_for_ip("0.0.0.1"), SsrfReason::MulticastOrReserved);
893 assert_eq!(reason_for_ip("240.0.0.1"), SsrfReason::MulticastOrReserved);
894 assert_eq!(reason_for_ip("::1"), SsrfReason::Loopback);
896 assert_eq!(reason_for_ip("fc00::1"), SsrfReason::Private);
897 assert_eq!(reason_for_ip("fe80::1"), SsrfReason::Imds);
898 assert_eq!(reason_for_ip("64:ff9b::a9fe:a9fe"), SsrfReason::Imds);
900 assert_eq!(reason_for_ip("::ffff:10.0.0.1"), SsrfReason::Private);
902 assert!(SsrfPolicy::default()
904 .classify_ip("8.8.8.8".parse().unwrap())
905 .is_ok());
906 assert!(SsrfPolicy::default()
907 .classify_ip("2001:db8::1".parse().unwrap())
908 .is_ok());
909 }
910
911 #[test]
912 fn classify_reason_as_str_is_stable() {
913 assert_eq!(SsrfReason::NonHttps.as_str(), "non_https");
914 assert_eq!(SsrfReason::IpLiteral.as_str(), "ip_literal");
915 assert_eq!(SsrfReason::InvalidUrl.as_str(), "invalid_url");
916 assert_eq!(SsrfReason::Loopback.as_str(), "loopback");
917 assert_eq!(SsrfReason::Private.as_str(), "private");
918 assert_eq!(SsrfReason::Imds.as_str(), "imds");
919 assert_eq!(
920 SsrfReason::MulticastOrReserved.as_str(),
921 "multicast_or_reserved"
922 );
923 assert_eq!(SsrfReason::CrossAuthority.as_str(), "cross_authority");
924 }
925
926 #[test]
927 fn classify_url_maps_stable_reasons() {
928 let p = SsrfPolicy::default();
929 assert_eq!(
930 p.classify_url("http://registry.example.com")
931 .unwrap_err()
932 .reason,
933 SsrfReason::NonHttps
934 );
935 assert_eq!(
936 p.classify_url("https://192.168.1.1").unwrap_err().reason,
937 SsrfReason::IpLiteral
938 );
939 assert_eq!(
940 p.classify_url("https://[::1]").unwrap_err().reason,
941 SsrfReason::IpLiteral
942 );
943 assert_eq!(
944 p.classify_url("not a url").unwrap_err().reason,
945 SsrfReason::InvalidUrl
946 );
947 assert!(p.classify_url("https://registry.example.com").is_ok());
948 }
949
950 #[test]
951 fn classify_redirect_reasons_and_port_parity() {
952 let p = SsrfPolicy::default();
953 assert_eq!(
955 p.classify_redirect("https://a.example/x", "https://b.example/y")
956 .unwrap_err()
957 .reason,
958 SsrfReason::CrossAuthority
959 );
960 assert_eq!(
962 p.classify_redirect("https://a.example/x", "https://a.example:8443/y")
963 .unwrap_err()
964 .reason,
965 SsrfReason::CrossAuthority
966 );
967 assert_eq!(
969 p.classify_redirect("https://a.example/x", "http://a.example/y")
970 .unwrap_err()
971 .reason,
972 SsrfReason::CrossAuthority
973 );
974 assert!(p
976 .classify_redirect("https://a.example/x", "https://a.example:443/y")
977 .is_ok());
978 assert!(p
980 .classify_redirect("https://a.example/x", "https://a.example/y")
981 .is_ok());
982 assert_eq!(
984 p.classify_redirect("::not-a-url", "https://a.example/y")
985 .unwrap_err()
986 .reason,
987 SsrfReason::InvalidUrl
988 );
989 }
990
991 #[test]
992 fn check_wrappers_preserve_schema_violation() {
993 let p = SsrfPolicy::default();
996 let err = p.check_url("http://registry.example.com").unwrap_err();
997 assert!(matches!(err, AcdpError::SchemaViolation(_)));
998 let err = p.check_ip("10.0.0.1".parse().unwrap()).unwrap_err();
999 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1000 }
1001
1002 #[cfg(feature = "client")]
1008 #[tokio::test]
1009 async fn pin_resolved_ip_rejects_loopback_hostname() {
1010 let p = SsrfPolicy::default();
1011 let err = p.pin_resolved_ip("localhost", 443).await.unwrap_err();
1012 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1013 }
1014}