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(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    /// Validate a URL string (scheme + host) before issuing a request.
182    ///
183    /// Back-compat wrapper over [`Self::classify_url`]: a rejection maps
184    /// to [`AcdpError::SchemaViolation`] with the same detail message
185    /// callers have always seen.
186    pub fn check_url(&self, url: &str) -> Result<(), AcdpError> {
187        self.classify_url(url).map_err(AcdpError::from)
188    }
189
190    /// Validate a URL string, returning a stable [`SsrfRejection`]
191    /// (reason code + detail) on failure.
192    ///
193    /// Checks scheme (HTTPS-only unless `allow_http`), IP-literal
194    /// rejection, per-IP range filtering for literal hosts, and hostname
195    /// length. Prefer this over [`Self::check_url`] when the caller needs
196    /// to branch on *why* the URL was rejected (e.g. a language binding
197    /// mapping to a typed exception).
198    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    /// Validate an already-resolved [`IpAddr`] — useful when DNS resolution
256    /// is performed externally and the caller wants to filter pre-connect.
257    /// Respects [`Self::allow_loopback_resolved`].
258    pub fn check_resolved_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
259        self.check_ip(ip)
260    }
261
262    /// Range filter for a single [`IpAddr`], respecting the policy's
263    /// [`Self::allow_loopback_resolved`] flag.
264    ///
265    /// Back-compat wrapper over [`Self::classify_ip`].
266    pub fn check_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
267        self.classify_ip(ip).map_err(AcdpError::from)
268    }
269
270    /// Range filter for a single [`IpAddr`], returning a stable
271    /// [`SsrfRejection`] (reason code + detail) when the address falls in
272    /// a forbidden range. Respects [`Self::allow_loopback_resolved`].
273    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    /// DNS rebinding protection per RFC-ACDP-0006 §7.6.
300    ///
301    /// Resolves `host:port`, validates **every** returned address, and
302    /// returns one [`SocketAddr`] to pin. The caller MUST pin this exact
303    /// address into the HTTP client via
304    /// `reqwest::Client::builder().resolve(host, addr)` — otherwise a
305    /// hostile authoritative DNS could flip the answer between the filter
306    /// check and the connect, bypassing §7.1.
307    ///
308    /// RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8: if **any** resolved
309    /// address is in a forbidden range, the **entire** resolution is
310    /// rejected — an attacker MUST NOT be able to bypass the filter by
311    /// mixing one public and one private answer in a single DNS response.
312    ///
313    /// Returns [`AcdpError::Http`] when DNS returns no answers and
314    /// [`AcdpError::SchemaViolation`] when any answer is in a forbidden
315    /// range.
316    #[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        // Validate EVERY resolved address before pinning one. Any failure
329        // aborts the whole resolution (no silent filtering).
330        reject_if_any_forbidden(self, host, &candidates)?;
331        // All candidates passed — pin the first (IPv4-preferred).
332        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    /// Per §7.5: a redirect is permitted only if it stays within the same
342    /// fetch authority as the originating request — identical scheme,
343    /// host, and effective port (RFC-ACDP-0008 §4.8: "host + port").
344    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    /// Same-authority redirect check returning a stable [`SsrfRejection`].
354    /// See [`Self::check_redirect_authority`].
355    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    /// String-in/string-in convenience over [`Self::classify_redirect_authority`]
376    /// for FFI callers that hold both endpoints as strings (no `url::Url`
377    /// on the boundary). Parses `from_url` as the origin authority, then
378    /// applies the same scheme + host + effective-port equality.
379    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/// Returns `true` when `a` and `b` share the same fetch authority:
389/// identical scheme, identical host, and identical effective port
390/// (the scheme default applies — 443 for `https`, 80 for `http`).
391///
392/// RFC-ACDP-0006 §7.5 and RFC-ACDP-0008 §4.8: a "same authority"
393/// redirect must match host **and** port; this also pins the scheme so
394/// an `https → http` downgrade can never be treated as same-authority.
395#[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/// Strict-default range filter (no loopback allowance). Retained as a
403/// test-only helper that pins the legacy `check_safe_ip` semantics —
404/// production callers should use the policy-aware
405/// [`SsrfPolicy::check_ip`] instead.
406#[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// ── DNS-rebinding protection (RFC-ACDP-0006 §7.6 / RFC-ACDP-0008 §4.8) ──────
421//
422// Plumb [`SsrfPolicy::check_ip`] into reqwest's DNS resolver hook so the
423// filter and the actual TCP connect see the SAME resolved IP. A hostile
424// authoritative DNS server can no longer flip the answer between a
425// pre-connect `pin_resolved_ip` check and the real connect: reqwest
426// passes the addresses we return straight to the connector.
427
428/// Reject the **entire** resolution if ANY candidate address is in a
429/// forbidden range (RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8). Shared by
430/// [`SsrfPolicy::pin_resolved_ip`] and [`SafeDnsResolver`]'s resolve hook so
431/// both apply identical reject-all semantics — never silent filtering.
432///
433/// Public because it is the canonical enforcement point the
434/// mixed-answer conformance fixtures pin (`did-ssrf-004`,
435/// `data-ref-ssrf-004`, `fed-007`), and so implementations that resolve
436/// DNS themselves can reuse the reject-all rule instead of
437/// re-implementing it (filter-and-proceed is explicitly non-conformant).
438#[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/// `reqwest::dns::Resolve` implementation that validates every resolved
457/// IP through an [`SsrfPolicy`] before handing them to the connector.
458#[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/// Build a `reqwest::Client` hardened against SSRF for outbound POSTs to
473/// operator-configured endpoints (webhook delivery, federation feeds).
474///
475/// Every resolved IP is filtered through `policy` at DNS time via
476/// `SafeDnsResolver` — defeating DNS rebinding (RFC-ACDP-0008 §4.8) — and
477/// redirects are refused outright: such an endpoint must respond directly, not
478/// bounce the registry to an internal host (e.g. cloud IMDS). `connect` and
479/// request timeouts are bounded. Use [`SsrfPolicy::default`] in production and
480/// [`SsrfPolicy::allow_test_loopback`] in tests that POST to a local listener.
481#[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        // Each outbound POST to an operator endpoint is independent; a fresh
492        // connection per request avoids reusing a pooled connection to an
493        // endpoint that has since gone away (and re-runs the SafeDnsResolver
494        // check every time rather than pinning a once-resolved IP).
495        .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            // Port 0 — reqwest replaces it with the URL's port (or the
508            // scheme default) before connecting. We only care about the
509            // IPs returned.
510            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            // RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8: validate EVERY
522            // resolved address. If any answer is in a forbidden range the
523            // ENTIRE resolution is rejected — never silently filter, or an
524            // attacker bypasses the filter by mixing one public and one
525            // private answer in a single DNS response. reqwest bubbles
526            // this up as a transport error and the caller's error mapper
527            // (e.g. WebResolver) translates it.
528            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
539/// Classify an IPv4 address against the forbidden ranges, returning the
540/// stable [`SsrfReason`] for the first range it falls in (or `None` when
541/// the address is safe to connect to). The set of rejected addresses is
542/// identical to the historical `is_unsafe_v4` predicate — only the reason
543/// granularity is new.
544fn classify_unsafe_v4(ip: Ipv4Addr) -> Option<SsrfReason> {
545    let o = ip.octets();
546    if o[0] == 0 {
547        // 0.0.0.0/8 — current network
548        Some(SsrfReason::MulticastOrReserved)
549    } else if o[0] == 10 {
550        // 10.0.0.0/8 — private
551        Some(SsrfReason::Private)
552    } else if o[0] == 100 && (o[1] & 0xc0) == 64 {
553        // 100.64.0.0/10 — CGNAT
554        Some(SsrfReason::Private)
555    } else if o[0] == 127 {
556        // 127.0.0.0/8 — loopback
557        Some(SsrfReason::Loopback)
558    } else if o[0] == 169 && o[1] == 254 {
559        // 169.254.0.0/16 — link-local + AWS/GCP IMDS
560        Some(SsrfReason::Imds)
561    } else if o[0] == 172 && (o[1] & 0xf0) == 16 {
562        // 172.16.0.0/12 — private
563        Some(SsrfReason::Private)
564    } else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
565        // 192.0.0.0/24 — IETF protocol
566        Some(SsrfReason::MulticastOrReserved)
567    } else if o[0] == 192 && o[1] == 168 {
568        // 192.168.0.0/16 — private
569        Some(SsrfReason::Private)
570    } else if o[0] == 198 && (o[1] == 18 || o[1] == 19) {
571        // 198.18.0.0/15 — benchmarking
572        Some(SsrfReason::MulticastOrReserved)
573    } else if o[0] >= 224 && o[0] <= 239 {
574        // 224.0.0.0/4 — multicast
575        Some(SsrfReason::MulticastOrReserved)
576    } else if o[0] >= 240 {
577        // 240.0.0.0/4 — reserved
578        Some(SsrfReason::MulticastOrReserved)
579    } else {
580        None
581    }
582}
583
584/// Classify an IPv6 address against the forbidden ranges. Mirrors
585/// [`classify_unsafe_v4`]; the rejected set matches the historical
586/// `is_unsafe_v6` predicate exactly.
587fn 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    // Embedded-IPv4 forms — both IPv4-mapped (`::ffff:a.b.c.d`) and the
596    // deprecated IPv4-compatible (`::a.b.c.d`, RFC 4291) carry an IPv4
597    // address in the low 32 bits with the high 80 bits zero. Decode it
598    // and re-run the v4 filter so e.g. `::127.0.0.1` / `::ffff:10.0.0.1`
599    // are caught. The non-zero guard keeps `::` (unspecified, already
600    // handled above) and `::1` (loopback) from being misclassified.
601    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    // NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) and the local-use
613    // 64:ff9b:1::/48 prefix (RFC 8215): a hostile AAAA answer such as
614    // `64:ff9b::a9fe:a9fe` translates to IMDS `169.254.169.254` through a
615    // NAT64/DNS64 gateway, which is routable in IPv6-only / cloud networks.
616    if segments[0] == 0x0064 && segments[1] == 0xff9b {
617        return Some(SsrfReason::Imds);
618    }
619    // fc00::/7 — unique local
620    if (segments[0] & 0xfe00) == 0xfc00 {
621        return Some(SsrfReason::Private);
622    }
623    // fe80::/10 — link-local
624    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    /// safe_client built with the default policy refuses a loopback target at
635    /// DNS time — the SafeDnsResolver rejects 127.0.0.1 before any connect, so
636    /// the request errors. This is the SSRF guard the webhook delivery client
637    /// relies on (#6).
638    #[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    /// allow_test_loopback permits loopback so tests can POST to a local
651    /// listener.
652    #[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        // RFC 1918
680        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        // Loopback
684        assert!(check_safe_ip("127.0.0.1".parse().unwrap()).is_err());
685        // Link-local + AWS IMDS
686        assert!(check_safe_ip("169.254.169.254".parse().unwrap()).is_err());
687        // Multicast
688        assert!(check_safe_ip("239.0.0.1".parse().unwrap()).is_err());
689        // Public
690        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        // IPv4-mapped private
700        assert!(check_safe_ip("::ffff:10.0.0.1".parse().unwrap()).is_err());
701        // IPv4-compatible (deprecated `::a.b.c.d`) decoding to loopback / IMDS
702        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        // NAT64 well-known prefix translating to IMDS 169.254.169.254
706        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        // Public v6
709        assert!(check_safe_ip("2001:db8::1".parse().unwrap()).is_ok());
710        // IPv4-compatible decoding to a *public* v4 stays allowed
711        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        // Same authority OK
723        p.check_redirect_authority(&orig, "https://registry.example.com/y")
724            .unwrap();
725    }
726
727    // ── SEC-02 — same_fetch_authority (scheme + host + port) ────────────
728    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        // Explicit :443 must compare equal to the implicit https default.
743        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        // Scheme downgrade is never same-authority.
760        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    // ── SEC-01 — reject the ENTIRE resolution on any forbidden IP ───────
785    #[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    // ── SsrfReason taxonomy (D1) ────────────────────────────────────────
840    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        // IPv6
859        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        // NAT64 well-known prefix → IMDS reach.
863        assert_eq!(reason_for_ip("64:ff9b::a9fe:a9fe"), SsrfReason::Imds);
864        // IPv4-mapped private decodes through to the v4 reason.
865        assert_eq!(reason_for_ip("::ffff:10.0.0.1"), SsrfReason::Private);
866        // Public addresses classify clean.
867        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        // Cross-host → cross_authority.
918        assert_eq!(
919            p.classify_redirect("https://a.example/x", "https://b.example/y")
920                .unwrap_err()
921                .reason,
922            SsrfReason::CrossAuthority
923        );
924        // Port change → cross_authority.
925        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        // Scheme downgrade → cross_authority.
932        assert_eq!(
933            p.classify_redirect("https://a.example/x", "http://a.example/y")
934                .unwrap_err()
935                .reason,
936            SsrfReason::CrossAuthority
937        );
938        // D2: explicit :443 is equal to the implicit https default.
939        assert!(p
940            .classify_redirect("https://a.example/x", "https://a.example:443/y")
941            .is_ok());
942        // Same authority is allowed.
943        assert!(p
944            .classify_redirect("https://a.example/x", "https://a.example/y")
945            .is_ok());
946        // Unparseable origin → invalid_url.
947        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        // The back-compat surface still produces SchemaViolation with the
958        // same detail string, so existing callers are unaffected.
959        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    /// FEAT-07 — `pin_resolved_ip` resolves localhost (which always maps
967    /// to a forbidden range) and rejects it. This proves the §7.6 path
968    /// runs the same range filter as `check_safe_ip`, so an attacker
969    /// cannot use a hostname that only resolves to private IPs to slip
970    /// past the URL-time check by hostname.
971    #[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}