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; returning the typed
527            // `SsrfDnsRefusal` marker lets every caller's error mapper
528            // (`From<reqwest::Error>`, `WebResolver`) recognize it via
529            // `acdp_primitives::error::is_ssrf_refusal` and classify it as
530            // permanent rather than as a retryable transport error.
531            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
547/// Classify an IPv4 address against the forbidden ranges, returning the
548/// stable [`SsrfReason`] for the first range it falls in (or `None` when
549/// the address is safe to connect to). The set of rejected addresses is
550/// identical to the historical `is_unsafe_v4` predicate — only the reason
551/// granularity is new.
552fn classify_unsafe_v4(ip: Ipv4Addr) -> Option<SsrfReason> {
553    let o = ip.octets();
554    if o[0] == 0 {
555        // 0.0.0.0/8 — current network
556        Some(SsrfReason::MulticastOrReserved)
557    } else if o[0] == 10 {
558        // 10.0.0.0/8 — private
559        Some(SsrfReason::Private)
560    } else if o[0] == 100 && (o[1] & 0xc0) == 64 {
561        // 100.64.0.0/10 — CGNAT
562        Some(SsrfReason::Private)
563    } else if o[0] == 127 {
564        // 127.0.0.0/8 — loopback
565        Some(SsrfReason::Loopback)
566    } else if o[0] == 169 && o[1] == 254 {
567        // 169.254.0.0/16 — link-local + AWS/GCP IMDS
568        Some(SsrfReason::Imds)
569    } else if o[0] == 172 && (o[1] & 0xf0) == 16 {
570        // 172.16.0.0/12 — private
571        Some(SsrfReason::Private)
572    } else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
573        // 192.0.0.0/24 — IETF protocol
574        Some(SsrfReason::MulticastOrReserved)
575    } else if o[0] == 192 && o[1] == 168 {
576        // 192.168.0.0/16 — private
577        Some(SsrfReason::Private)
578    } else if o[0] == 198 && (o[1] == 18 || o[1] == 19) {
579        // 198.18.0.0/15 — benchmarking
580        Some(SsrfReason::MulticastOrReserved)
581    } else if o[0] >= 224 && o[0] <= 239 {
582        // 224.0.0.0/4 — multicast
583        Some(SsrfReason::MulticastOrReserved)
584    } else if o[0] >= 240 {
585        // 240.0.0.0/4 — reserved
586        Some(SsrfReason::MulticastOrReserved)
587    } else {
588        None
589    }
590}
591
592/// Classify an IPv6 address against the forbidden ranges. Mirrors
593/// [`classify_unsafe_v4`]; the rejected set matches the historical
594/// `is_unsafe_v6` predicate exactly.
595fn 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    // Embedded-IPv4 forms — both IPv4-mapped (`::ffff:a.b.c.d`) and the
604    // deprecated IPv4-compatible (`::a.b.c.d`, RFC 4291) carry an IPv4
605    // address in the low 32 bits with the high 80 bits zero. Decode it
606    // and re-run the v4 filter so e.g. `::127.0.0.1` / `::ffff:10.0.0.1`
607    // are caught. The non-zero guard keeps `::` (unspecified, already
608    // handled above) and `::1` (loopback) from being misclassified.
609    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    // NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) and the local-use
621    // 64:ff9b:1::/48 prefix (RFC 8215): a hostile AAAA answer such as
622    // `64:ff9b::a9fe:a9fe` translates to IMDS `169.254.169.254` through a
623    // NAT64/DNS64 gateway, which is routable in IPv6-only / cloud networks.
624    if segments[0] == 0x0064 && segments[1] == 0xff9b {
625        return Some(SsrfReason::Imds);
626    }
627    // fc00::/7 — unique local
628    if (segments[0] & 0xfe00) == 0xfc00 {
629        return Some(SsrfReason::Private);
630    }
631    // fe80::/10 — link-local
632    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    /// safe_client built with the default policy refuses a loopback target at
643    /// DNS time — the SafeDnsResolver rejects 127.0.0.1 before any connect, so
644    /// the request errors. This is the SSRF guard the webhook delivery client
645    /// relies on (#6).
646    #[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    /// Issue #321: the refusal reaches the caller with the typed
659    /// `SsrfDnsRefusal` marker still downcastable somewhere in the
660    /// `reqwest::Error` source chain — so classification does not depend
661    /// on the message-text fallback.
662    #[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    /// allow_test_loopback permits loopback so tests can POST to a local
687    /// listener.
688    #[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        // RFC 1918
716        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        // Loopback
720        assert!(check_safe_ip("127.0.0.1".parse().unwrap()).is_err());
721        // Link-local + AWS IMDS
722        assert!(check_safe_ip("169.254.169.254".parse().unwrap()).is_err());
723        // Multicast
724        assert!(check_safe_ip("239.0.0.1".parse().unwrap()).is_err());
725        // Public
726        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        // IPv4-mapped private
736        assert!(check_safe_ip("::ffff:10.0.0.1".parse().unwrap()).is_err());
737        // IPv4-compatible (deprecated `::a.b.c.d`) decoding to loopback / IMDS
738        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        // NAT64 well-known prefix translating to IMDS 169.254.169.254
742        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        // Public v6
745        assert!(check_safe_ip("2001:db8::1".parse().unwrap()).is_ok());
746        // IPv4-compatible decoding to a *public* v4 stays allowed
747        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        // Same authority OK
759        p.check_redirect_authority(&orig, "https://registry.example.com/y")
760            .unwrap();
761    }
762
763    // ── SEC-02 — same_fetch_authority (scheme + host + port) ────────────
764    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        // Explicit :443 must compare equal to the implicit https default.
779        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        // Scheme downgrade is never same-authority.
796        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    // ── SEC-01 — reject the ENTIRE resolution on any forbidden IP ───────
821    #[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    // ── SsrfReason taxonomy (D1) ────────────────────────────────────────
876    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        // IPv6
895        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        // NAT64 well-known prefix → IMDS reach.
899        assert_eq!(reason_for_ip("64:ff9b::a9fe:a9fe"), SsrfReason::Imds);
900        // IPv4-mapped private decodes through to the v4 reason.
901        assert_eq!(reason_for_ip("::ffff:10.0.0.1"), SsrfReason::Private);
902        // Public addresses classify clean.
903        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        // Cross-host → cross_authority.
954        assert_eq!(
955            p.classify_redirect("https://a.example/x", "https://b.example/y")
956                .unwrap_err()
957                .reason,
958            SsrfReason::CrossAuthority
959        );
960        // Port change → cross_authority.
961        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        // Scheme downgrade → cross_authority.
968        assert_eq!(
969            p.classify_redirect("https://a.example/x", "http://a.example/y")
970                .unwrap_err()
971                .reason,
972            SsrfReason::CrossAuthority
973        );
974        // D2: explicit :443 is equal to the implicit https default.
975        assert!(p
976            .classify_redirect("https://a.example/x", "https://a.example:443/y")
977            .is_ok());
978        // Same authority is allowed.
979        assert!(p
980            .classify_redirect("https://a.example/x", "https://a.example/y")
981            .is_ok());
982        // Unparseable origin → invalid_url.
983        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        // The back-compat surface still produces SchemaViolation with the
994        // same detail string, so existing callers are unaffected.
995        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    /// FEAT-07 — `pin_resolved_ip` resolves localhost (which always maps
1003    /// to a forbidden range) and rejects it. This proves the §7.6 path
1004    /// runs the same range filter as `check_safe_ip`, so an attacker
1005    /// cannot use a hostname that only resolves to private IPs to slip
1006    /// past the URL-time check by hostname.
1007    #[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}