Skip to main content

acdp_safe_http/
lib.rs

1//! SSRF defenses for server-side cross-registry resolution
2//! (RFC-ACDP-0006 §7).
3//!
4//! ## Single source of SSRF policy
5//!
6//! This module is the **single source of truth** for ACDP's SSRF policy
7//! across both the `client` and `server` features. The server-scoped
8//! path (`crate::registry::safe_http`) does not reimplement any of this
9//! — it only *re-exports* [`SsrfPolicy`] from here (see
10//! `src/registry/safe_http.rs`). Any change to blocked IP ranges, the
11//! HTTPS-only rule, redirect limits, or DNS-rebinding handling therefore
12//! applies to client and server alike; there is no second copy to keep
13//! in sync. Do not add a divergent implementation under `registry/`.
14//!
15//! When a registry resolves a foreign `acdp://` reference on behalf of a
16//! consumer, it must defend against attacker-supplied URIs that target the
17//! registry's own internal network. This module implements the policy
18//! decisions enumerated by §7:
19//!
20//! - **§7.1** Reject loopback, RFC 1918 / 4193 private ranges, link-local,
21//!   multicast, the AWS / GCP metadata endpoint (`169.254.169.254`), and
22//!   the IPv6 equivalents.
23//! - **§7.2** HTTPS-only.
24//! - **§7.3** Response-size caps.
25//! - **§7.5** Maximum redirects, same-authority only.
26//! - **§7.6** DNS rebinding protection. [`SsrfPolicy::pin_resolved_ip`]
27//!   resolves a hostname once, validates **every** returned IP, and
28//!   returns a [`SocketAddr`] that the caller pins into
29//!   `reqwest::Client::builder().resolve(host, addr)` — so the filter
30//!   and the connection use the same IP, defeating a hostile DNS server
31//!   flipping the answer between the two. Per §7.1 the resolution is
32//!   rejected outright if **any** returned IP is forbidden — a public
33//!   answer cannot mask a private one.
34
35#[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
44// Re-exported from `acdp_primitives::limits` for back-compat.
45pub use acdp_primitives::limits::{MAX_CONTEXT_BYTES, MAX_METADATA_BYTES, MAX_REDIRECTS};
46
47/// Stable, machine-readable reason an SSRF check rejected a target.
48///
49/// Surfaced by the [`SsrfPolicy::classify_url`] / [`SsrfPolicy::classify_ip`]
50/// / [`SsrfPolicy::classify_redirect`] family so callers can react
51/// programmatically — and so language bindings can map a rejection to a
52/// typed exception — instead of string-matching the free-form detail
53/// message. Maps to RFC-ACDP-0006 §7 / RFC-ACDP-0008 §4.8.
54///
55/// `#[non_exhaustive]`: future spec revisions may add ranges; match with a
56/// wildcard arm.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum SsrfReason {
60    /// URL scheme is not `https` (and `allow_http` is off).
61    NonHttps,
62    /// URL embeds an IP literal; a hostname (forcing DNS) is required.
63    IpLiteral,
64    /// URL could not be parsed, has no host, or has an invalid hostname.
65    InvalidUrl,
66    /// Loopback range — IPv4 `127.0.0.0/8` or IPv6 `::1`.
67    Loopback,
68    /// Private range — RFC 1918 (`10/8`, `172.16/12`, `192.168/16`),
69    /// CGNAT `100.64/10`, or IPv6 ULA `fc00::/7`.
70    Private,
71    /// Link-local / cloud instance-metadata reach — IPv4 `169.254.0.0/16`
72    /// (incl. `169.254.169.254`), IPv6 `fe80::/10`, and the NAT64
73    /// well-known prefix `64:ff9b::/96` (which can translate to IMDS).
74    Imds,
75    /// Multicast or otherwise reserved/unusable range (`0.0.0.0/8`,
76    /// `192.0.0.0/24`, `198.18.0.0/15`, `224.0.0.0/4`, `240.0.0.0/4`,
77    /// IPv6 multicast / unspecified).
78    MulticastOrReserved,
79    /// A redirect target whose scheme, host, or effective port differs
80    /// from the originating request's authority (RFC-ACDP-0006 §7.5).
81    CrossAuthority,
82}
83
84impl SsrfReason {
85    /// The stable snake_case identifier for this reason — the contract
86    /// language bindings expose to host code.
87    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/// A rejection produced by the `classify_*` SSRF checks: a stable
108/// [`SsrfReason`] discriminant plus a human-readable detail.
109///
110/// Converts to [`AcdpError::SchemaViolation`] (carrying `detail`) via
111/// `From`, so the back-compat `check_*` wrappers preserve their existing
112/// error shape exactly.
113#[derive(Debug, Clone)]
114pub struct SsrfRejection {
115    /// Stable machine-readable reason code.
116    pub reason: SsrfReason,
117    /// Human-readable explanation (the message the legacy `check_*`
118    /// methods surfaced).
119    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/// SSRF policy applied to outbound HTTP requests.
135#[derive(Debug, Clone)]
136pub struct SsrfPolicy {
137    /// If true, reject IP literals in the URL (forces DNS resolution).
138    pub reject_ip_literals: bool,
139    /// If false, only `https://` URLs are accepted. Default `false`.
140    pub allow_http: bool,
141    /// When true, permit IPv4 `127.0.0.0/8` and IPv6 `::1` (loopback)
142    /// across [`Self::check_ip`] / [`Self::check_resolved_ip`] /
143    /// [`Self::pin_resolved_ip`]. All other forbidden ranges
144    /// (RFC 1918, link-local / IMDS, ULA, CGNAT, multicast, …) still
145    /// apply. Default `false`.
146    ///
147    /// Intended for test harnesses that resolve `did:web:localhost…`
148    /// against a self-signed in-process HTTPS server bound to
149    /// `127.0.0.1`. Production callers MUST keep this `false` — opening
150    /// loopback turns the resolver into an SSRF vector against
151    /// process-internal listeners (RFC-ACDP-0008 §4.8).
152    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    /// A test-only policy: defaults + `allow_loopback_resolved = true`.
167    ///
168    /// `#[doc(hidden)]` because production must never use this — see
169    /// [`Self::allow_loopback_resolved`].
170    #[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    /// Validate a URL string (scheme + host) before issuing a request.
187    ///
188    /// Back-compat wrapper over [`Self::classify_url`]: a rejection maps
189    /// to [`AcdpError::SchemaViolation`] with the same detail message
190    /// callers have always seen.
191    pub fn check_url(&self, url: &str) -> Result<(), AcdpError> {
192        self.classify_url(url).map_err(AcdpError::from)
193    }
194
195    /// Validate a URL string, returning a stable [`SsrfRejection`]
196    /// (reason code + detail) on failure.
197    ///
198    /// Checks scheme (HTTPS-only unless `allow_http`), IP-literal
199    /// rejection, per-IP range filtering for literal hosts, and hostname
200    /// length. Prefer this over [`Self::check_url`] when the caller needs
201    /// to branch on *why* the URL was rejected (e.g. a language binding
202    /// mapping to a typed exception).
203    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    /// Validate an already-resolved [`IpAddr`] — useful when DNS resolution
261    /// is performed externally and the caller wants to filter pre-connect.
262    /// Respects [`Self::allow_loopback_resolved`].
263    pub fn check_resolved_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
264        self.check_ip(ip)
265    }
266
267    /// Range filter for a single [`IpAddr`], respecting the policy's
268    /// [`Self::allow_loopback_resolved`] flag.
269    ///
270    /// Back-compat wrapper over [`Self::classify_ip`].
271    pub fn check_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
272        self.classify_ip(ip).map_err(AcdpError::from)
273    }
274
275    /// Range filter for a single [`IpAddr`], returning a stable
276    /// [`SsrfRejection`] (reason code + detail) when the address falls in
277    /// a forbidden range. Respects [`Self::allow_loopback_resolved`].
278    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    /// DNS rebinding protection per RFC-ACDP-0006 §7.6.
305    ///
306    /// Resolves `host:port`, validates **every** returned address, and
307    /// returns one [`SocketAddr`] to pin. The caller MUST pin this exact
308    /// address into the HTTP client via
309    /// `reqwest::Client::builder().resolve(host, addr)` — otherwise a
310    /// hostile authoritative DNS could flip the answer between the filter
311    /// check and the connect, bypassing §7.1.
312    ///
313    /// RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8: if **any** resolved
314    /// address is in a forbidden range, the **entire** resolution is
315    /// rejected — an attacker MUST NOT be able to bypass the filter by
316    /// mixing one public and one private answer in a single DNS response.
317    ///
318    /// Returns [`AcdpError::Http`] when DNS returns no answers and
319    /// [`AcdpError::SchemaViolation`] when any answer is in a forbidden
320    /// range.
321    #[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        // Validate EVERY resolved address before pinning one. Any failure
334        // aborts the whole resolution (no silent filtering).
335        reject_if_any_forbidden(self, host, &candidates)?;
336        // All candidates passed — pin the first (IPv4-preferred).
337        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    /// Per §7.5: a redirect is permitted only if it stays within the same
347    /// fetch authority as the originating request — identical scheme,
348    /// host, and effective port (RFC-ACDP-0008 §4.8: "host + port").
349    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    /// Same-authority redirect check returning a stable [`SsrfRejection`].
359    /// See [`Self::check_redirect_authority`].
360    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    /// String-in/string-in convenience over [`Self::classify_redirect_authority`]
381    /// for FFI callers that hold both endpoints as strings (no `url::Url`
382    /// on the boundary). Parses `from_url` as the origin authority, then
383    /// applies the same scheme + host + effective-port equality.
384    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/// Returns `true` when `a` and `b` share the same fetch authority:
394/// identical scheme, identical host, and identical effective port
395/// (the scheme default applies — 443 for `https`, 80 for `http`).
396///
397/// RFC-ACDP-0006 §7.5 and RFC-ACDP-0008 §4.8: a "same authority"
398/// redirect must match host **and** port; this also pins the scheme so
399/// an `https → http` downgrade can never be treated as same-authority.
400#[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/// Strict-default range filter (no loopback allowance). Retained as a
408/// test-only helper that pins the legacy `check_safe_ip` semantics —
409/// production callers should use the policy-aware
410/// [`SsrfPolicy::check_ip`] instead.
411#[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// ── DNS-rebinding protection (RFC-ACDP-0006 §7.6 / RFC-ACDP-0008 §4.8) ──────
426//
427// Plumb [`SsrfPolicy::check_ip`] into reqwest's DNS resolver hook so the
428// filter and the actual TCP connect see the SAME resolved IP. A hostile
429// authoritative DNS server can no longer flip the answer between a
430// pre-connect `pin_resolved_ip` check and the real connect: reqwest
431// passes the addresses we return straight to the connector.
432
433/// Reject the **entire** resolution if ANY candidate address is in a
434/// forbidden range (RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8). Shared by
435/// [`SsrfPolicy::pin_resolved_ip`] and [`SafeDnsResolver`]'s resolve hook so
436/// both apply identical reject-all semantics — never silent filtering.
437///
438/// Public because it is the canonical enforcement point the
439/// mixed-answer conformance fixtures pin (`did-ssrf-004`,
440/// `data-ref-ssrf-004`, `fed-007`), and so implementations that resolve
441/// DNS themselves can reuse the reject-all rule instead of
442/// re-implementing it (filter-and-proceed is explicitly non-conformant).
443#[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/// `reqwest::dns::Resolve` implementation that validates every resolved
462/// IP through an [`SsrfPolicy`] before handing them to the connector.
463#[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/// Build a `reqwest::Client` hardened against SSRF for outbound POSTs to
478/// operator-configured endpoints (webhook delivery, federation feeds).
479///
480/// Every resolved IP is filtered through `policy` at DNS time via
481/// `SafeDnsResolver` — defeating DNS rebinding (RFC-ACDP-0008 §4.8) — and
482/// redirects are refused outright: such an endpoint must respond directly, not
483/// bounce the registry to an internal host (e.g. cloud IMDS). `connect` and
484/// request timeouts are bounded. Use [`SsrfPolicy::default`] in production and
485/// [`SsrfPolicy::allow_test_loopback`] in tests that POST to a local listener.
486#[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        // Each outbound POST to an operator endpoint is independent; a fresh
497        // connection per request avoids reusing a pooled connection to an
498        // endpoint that has since gone away (and re-runs the SafeDnsResolver
499        // check every time rather than pinning a once-resolved IP).
500        .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            // Port 0 — reqwest replaces it with the URL's port (or the
513            // scheme default) before connecting. We only care about the
514            // IPs returned.
515            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            // RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8: validate EVERY
527            // resolved address. If any answer is in a forbidden range the
528            // ENTIRE resolution is rejected — never silently filter, or an
529            // attacker bypasses the filter by mixing one public and one
530            // private answer in a single DNS response. reqwest bubbles
531            // this up as a transport error and the caller's error mapper
532            // (e.g. WebResolver) translates it.
533            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
544/// Classify an IPv4 address against the forbidden ranges, returning the
545/// stable [`SsrfReason`] for the first range it falls in (or `None` when
546/// the address is safe to connect to). The set of rejected addresses is
547/// identical to the historical `is_unsafe_v4` predicate — only the reason
548/// granularity is new.
549fn classify_unsafe_v4(ip: Ipv4Addr) -> Option<SsrfReason> {
550    let o = ip.octets();
551    if o[0] == 0 {
552        // 0.0.0.0/8 — current network
553        Some(SsrfReason::MulticastOrReserved)
554    } else if o[0] == 10 {
555        // 10.0.0.0/8 — private
556        Some(SsrfReason::Private)
557    } else if o[0] == 100 && (o[1] & 0xc0) == 64 {
558        // 100.64.0.0/10 — CGNAT
559        Some(SsrfReason::Private)
560    } else if o[0] == 127 {
561        // 127.0.0.0/8 — loopback
562        Some(SsrfReason::Loopback)
563    } else if o[0] == 169 && o[1] == 254 {
564        // 169.254.0.0/16 — link-local + AWS/GCP IMDS
565        Some(SsrfReason::Imds)
566    } else if o[0] == 172 && (o[1] & 0xf0) == 16 {
567        // 172.16.0.0/12 — private
568        Some(SsrfReason::Private)
569    } else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
570        // 192.0.0.0/24 — IETF protocol
571        Some(SsrfReason::MulticastOrReserved)
572    } else if o[0] == 192 && o[1] == 168 {
573        // 192.168.0.0/16 — private
574        Some(SsrfReason::Private)
575    } else if o[0] == 198 && (o[1] == 18 || o[1] == 19) {
576        // 198.18.0.0/15 — benchmarking
577        Some(SsrfReason::MulticastOrReserved)
578    } else if o[0] >= 224 && o[0] <= 239 {
579        // 224.0.0.0/4 — multicast
580        Some(SsrfReason::MulticastOrReserved)
581    } else if o[0] >= 240 {
582        // 240.0.0.0/4 — reserved
583        Some(SsrfReason::MulticastOrReserved)
584    } else {
585        None
586    }
587}
588
589/// Classify an IPv6 address against the forbidden ranges. Mirrors
590/// [`classify_unsafe_v4`]; the rejected set matches the historical
591/// `is_unsafe_v6` predicate exactly.
592fn 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    // Embedded-IPv4 forms — both IPv4-mapped (`::ffff:a.b.c.d`) and the
601    // deprecated IPv4-compatible (`::a.b.c.d`, RFC 4291) carry an IPv4
602    // address in the low 32 bits with the high 80 bits zero. Decode it
603    // and re-run the v4 filter so e.g. `::127.0.0.1` / `::ffff:10.0.0.1`
604    // are caught. The non-zero guard keeps `::` (unspecified, already
605    // handled above) and `::1` (loopback) from being misclassified.
606    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    // NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) and the local-use
618    // 64:ff9b:1::/48 prefix (RFC 8215): a hostile AAAA answer such as
619    // `64:ff9b::a9fe:a9fe` translates to IMDS `169.254.169.254` through a
620    // NAT64/DNS64 gateway, which is routable in IPv6-only / cloud networks.
621    if segments[0] == 0x0064 && segments[1] == 0xff9b {
622        return Some(SsrfReason::Imds);
623    }
624    // fc00::/7 — unique local
625    if (segments[0] & 0xfe00) == 0xfc00 {
626        return Some(SsrfReason::Private);
627    }
628    // fe80::/10 — link-local
629    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    /// safe_client built with the default policy refuses a loopback target at
640    /// DNS time — the SafeDnsResolver rejects 127.0.0.1 before any connect, so
641    /// the request errors. This is the SSRF guard the webhook delivery client
642    /// relies on (#6).
643    #[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    /// allow_test_loopback permits loopback so tests can POST to a local
656    /// listener.
657    #[cfg(feature = "client")]
658    #[test]
659    #[allow(deprecated)] // test-transport constructors; gated in 0.4.0
660    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        // RFC 1918
686        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        // Loopback
690        assert!(check_safe_ip("127.0.0.1".parse().unwrap()).is_err());
691        // Link-local + AWS IMDS
692        assert!(check_safe_ip("169.254.169.254".parse().unwrap()).is_err());
693        // Multicast
694        assert!(check_safe_ip("239.0.0.1".parse().unwrap()).is_err());
695        // Public
696        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        // IPv4-mapped private
706        assert!(check_safe_ip("::ffff:10.0.0.1".parse().unwrap()).is_err());
707        // IPv4-compatible (deprecated `::a.b.c.d`) decoding to loopback / IMDS
708        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        // NAT64 well-known prefix translating to IMDS 169.254.169.254
712        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        // Public v6
715        assert!(check_safe_ip("2001:db8::1".parse().unwrap()).is_ok());
716        // IPv4-compatible decoding to a *public* v4 stays allowed
717        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        // Same authority OK
729        p.check_redirect_authority(&orig, "https://registry.example.com/y")
730            .unwrap();
731    }
732
733    // ── SEC-02 — same_fetch_authority (scheme + host + port) ────────────
734    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        // Explicit :443 must compare equal to the implicit https default.
749        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        // Scheme downgrade is never same-authority.
766        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    // ── SEC-01 — reject the ENTIRE resolution on any forbidden IP ───────
791    #[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    // ── SsrfReason taxonomy (D1) ────────────────────────────────────────
846    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        // IPv6
865        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        // NAT64 well-known prefix → IMDS reach.
869        assert_eq!(reason_for_ip("64:ff9b::a9fe:a9fe"), SsrfReason::Imds);
870        // IPv4-mapped private decodes through to the v4 reason.
871        assert_eq!(reason_for_ip("::ffff:10.0.0.1"), SsrfReason::Private);
872        // Public addresses classify clean.
873        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        // Cross-host → cross_authority.
924        assert_eq!(
925            p.classify_redirect("https://a.example/x", "https://b.example/y")
926                .unwrap_err()
927                .reason,
928            SsrfReason::CrossAuthority
929        );
930        // Port change → cross_authority.
931        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        // Scheme downgrade → cross_authority.
938        assert_eq!(
939            p.classify_redirect("https://a.example/x", "http://a.example/y")
940                .unwrap_err()
941                .reason,
942            SsrfReason::CrossAuthority
943        );
944        // D2: explicit :443 is equal to the implicit https default.
945        assert!(p
946            .classify_redirect("https://a.example/x", "https://a.example:443/y")
947            .is_ok());
948        // Same authority is allowed.
949        assert!(p
950            .classify_redirect("https://a.example/x", "https://a.example/y")
951            .is_ok());
952        // Unparseable origin → invalid_url.
953        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        // The back-compat surface still produces SchemaViolation with the
964        // same detail string, so existing callers are unaffected.
965        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    /// FEAT-07 — `pin_resolved_ip` resolves localhost (which always maps
973    /// to a forbidden range) and rejects it. This proves the §7.6 path
974    /// runs the same range filter as `check_safe_ip`, so an attacker
975    /// cannot use a hostname that only resolves to private IPs to slip
976    /// past the URL-time check by hostname.
977    #[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}