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    pub fn allow_test_loopback() -> Self {
172        Self {
173            allow_loopback_resolved: true,
174            ..Self::default()
175        }
176    }
177}
178
179impl SsrfPolicy {
180    /// Validate a URL string (scheme + host) before issuing a request.
181    ///
182    /// Back-compat wrapper over [`Self::classify_url`]: a rejection maps
183    /// to [`AcdpError::SchemaViolation`] with the same detail message
184    /// callers have always seen.
185    pub fn check_url(&self, url: &str) -> Result<(), AcdpError> {
186        self.classify_url(url).map_err(AcdpError::from)
187    }
188
189    /// Validate a URL string, returning a stable [`SsrfRejection`]
190    /// (reason code + detail) on failure.
191    ///
192    /// Checks scheme (HTTPS-only unless `allow_http`), IP-literal
193    /// rejection, per-IP range filtering for literal hosts, and hostname
194    /// length. Prefer this over [`Self::check_url`] when the caller needs
195    /// to branch on *why* the URL was rejected (e.g. a language binding
196    /// mapping to a typed exception).
197    pub fn classify_url(&self, url: &str) -> Result<(), SsrfRejection> {
198        let parsed = url::Url::parse(url).map_err(|e| SsrfRejection {
199            reason: SsrfReason::InvalidUrl,
200            detail: format!("invalid URL: {e}"),
201        })?;
202
203        if !self.allow_http && parsed.scheme() != "https" {
204            return Err(SsrfRejection {
205                reason: SsrfReason::NonHttps,
206                detail: format!(
207                    "SSRF policy: scheme '{}' not permitted; only https",
208                    parsed.scheme()
209                ),
210            });
211        }
212
213        let host = parsed.host().ok_or_else(|| SsrfRejection {
214            reason: SsrfReason::InvalidUrl,
215            detail: format!("URL has no host: {url}"),
216        })?;
217
218        match host {
219            url::Host::Ipv4(v4) => {
220                if self.reject_ip_literals {
221                    return Err(SsrfRejection {
222                        reason: SsrfReason::IpLiteral,
223                        detail: format!(
224                            "SSRF policy: IPv4 literal '{v4}' not permitted; use a hostname"
225                        ),
226                    });
227                }
228                self.classify_ip(IpAddr::V4(v4))?;
229            }
230            url::Host::Ipv6(v6) => {
231                if self.reject_ip_literals {
232                    return Err(SsrfRejection {
233                        reason: SsrfReason::IpLiteral,
234                        detail: format!(
235                            "SSRF policy: IPv6 literal '{v6}' not permitted; use a hostname"
236                        ),
237                    });
238                }
239                self.classify_ip(IpAddr::V6(v6))?;
240            }
241            url::Host::Domain(name) => {
242                if name.is_empty() || name.len() > 253 {
243                    return Err(SsrfRejection {
244                        reason: SsrfReason::InvalidUrl,
245                        detail: format!("SSRF policy: invalid hostname length: {name}"),
246                    });
247                }
248            }
249        }
250
251        Ok(())
252    }
253
254    /// Validate an already-resolved [`IpAddr`] — useful when DNS resolution
255    /// is performed externally and the caller wants to filter pre-connect.
256    /// Respects [`Self::allow_loopback_resolved`].
257    pub fn check_resolved_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
258        self.check_ip(ip)
259    }
260
261    /// Range filter for a single [`IpAddr`], respecting the policy's
262    /// [`Self::allow_loopback_resolved`] flag.
263    ///
264    /// Back-compat wrapper over [`Self::classify_ip`].
265    pub fn check_ip(&self, ip: IpAddr) -> Result<(), AcdpError> {
266        self.classify_ip(ip).map_err(AcdpError::from)
267    }
268
269    /// Range filter for a single [`IpAddr`], returning a stable
270    /// [`SsrfRejection`] (reason code + detail) when the address falls in
271    /// a forbidden range. Respects [`Self::allow_loopback_resolved`].
272    pub fn classify_ip(&self, ip: IpAddr) -> Result<(), SsrfRejection> {
273        let reason = match ip {
274            IpAddr::V4(v4) => {
275                if self.allow_loopback_resolved && v4.is_loopback() {
276                    None
277                } else {
278                    classify_unsafe_v4(v4)
279                }
280            }
281            IpAddr::V6(v6) => {
282                if self.allow_loopback_resolved && v6.is_loopback() {
283                    None
284                } else {
285                    classify_unsafe_v6(v6)
286                }
287            }
288        };
289        match reason {
290            Some(reason) => Err(SsrfRejection {
291                reason,
292                detail: format!("SSRF policy: IP address '{ip}' is in a forbidden range"),
293            }),
294            None => Ok(()),
295        }
296    }
297
298    /// DNS rebinding protection per RFC-ACDP-0006 §7.6.
299    ///
300    /// Resolves `host:port`, validates **every** returned address, and
301    /// returns one [`SocketAddr`] to pin. The caller MUST pin this exact
302    /// address into the HTTP client via
303    /// `reqwest::Client::builder().resolve(host, addr)` — otherwise a
304    /// hostile authoritative DNS could flip the answer between the filter
305    /// check and the connect, bypassing §7.1.
306    ///
307    /// RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8: if **any** resolved
308    /// address is in a forbidden range, the **entire** resolution is
309    /// rejected — an attacker MUST NOT be able to bypass the filter by
310    /// mixing one public and one private answer in a single DNS response.
311    ///
312    /// Returns [`AcdpError::Http`] when DNS returns no answers and
313    /// [`AcdpError::SchemaViolation`] when any answer is in a forbidden
314    /// range.
315    #[cfg(feature = "client")]
316    pub async fn pin_resolved_ip(&self, host: &str, port: u16) -> Result<SocketAddr, AcdpError> {
317        let target = format!("{host}:{port}");
318        let candidates: Vec<SocketAddr> = tokio::net::lookup_host(&target)
319            .await
320            .map_err(|e| AcdpError::Http(format!("DNS lookup for '{host}' failed: {e}")))?
321            .collect();
322        if candidates.is_empty() {
323            return Err(AcdpError::Http(format!(
324                "DNS lookup for '{host}' returned no addresses"
325            )));
326        }
327        // Validate EVERY resolved address before pinning one. Any failure
328        // aborts the whole resolution (no silent filtering).
329        reject_if_any_forbidden(self, host, &candidates)?;
330        // All candidates passed — pin the first (IPv4-preferred).
331        let pinned = candidates
332            .iter()
333            .find(|a| a.is_ipv4())
334            .or_else(|| candidates.first())
335            .copied()
336            .expect("candidates is non-empty");
337        Ok(pinned)
338    }
339
340    /// Per §7.5: a redirect is permitted only if it stays within the same
341    /// fetch authority as the originating request — identical scheme,
342    /// host, and effective port (RFC-ACDP-0008 §4.8: "host + port").
343    pub fn check_redirect_authority(
344        &self,
345        original_url: &url::Url,
346        redirect_url: &str,
347    ) -> Result<(), AcdpError> {
348        self.classify_redirect_authority(original_url, redirect_url)
349            .map_err(AcdpError::from)
350    }
351
352    /// Same-authority redirect check returning a stable [`SsrfRejection`].
353    /// See [`Self::check_redirect_authority`].
354    pub fn classify_redirect_authority(
355        &self,
356        original_url: &url::Url,
357        redirect_url: &str,
358    ) -> Result<(), SsrfRejection> {
359        let redirect = url::Url::parse(redirect_url).map_err(|e| SsrfRejection {
360            reason: SsrfReason::InvalidUrl,
361            detail: format!("invalid redirect URL: {e}"),
362        })?;
363        if !same_fetch_authority(original_url, &redirect) {
364            return Err(SsrfRejection {
365                reason: SsrfReason::CrossAuthority,
366                detail: format!(
367                    "SSRF policy: cross-authority redirect rejected: {original_url} → {redirect}"
368                ),
369            });
370        }
371        Ok(())
372    }
373
374    /// String-in/string-in convenience over [`Self::classify_redirect_authority`]
375    /// for FFI callers that hold both endpoints as strings (no `url::Url`
376    /// on the boundary). Parses `from_url` as the origin authority, then
377    /// applies the same scheme + host + effective-port equality.
378    pub fn classify_redirect(&self, from_url: &str, to_url: &str) -> Result<(), SsrfRejection> {
379        let original = url::Url::parse(from_url).map_err(|e| SsrfRejection {
380            reason: SsrfReason::InvalidUrl,
381            detail: format!("invalid origin URL: {e}"),
382        })?;
383        self.classify_redirect_authority(&original, to_url)
384    }
385}
386
387/// Returns `true` when `a` and `b` share the same fetch authority:
388/// identical scheme, identical host, and identical effective port
389/// (the scheme default applies — 443 for `https`, 80 for `http`).
390///
391/// RFC-ACDP-0006 §7.5 and RFC-ACDP-0008 §4.8: a "same authority"
392/// redirect must match host **and** port; this also pins the scheme so
393/// an `https → http` downgrade can never be treated as same-authority.
394#[doc(hidden)]
395pub fn same_fetch_authority(a: &url::Url, b: &url::Url) -> bool {
396    a.scheme() == b.scheme()
397        && a.host_str() == b.host_str()
398        && a.port_or_known_default() == b.port_or_known_default()
399}
400
401/// Strict-default range filter (no loopback allowance). Retained as a
402/// test-only helper that pins the legacy `check_safe_ip` semantics —
403/// production callers should use the policy-aware
404/// [`SsrfPolicy::check_ip`] instead.
405#[cfg(test)]
406fn check_safe_ip(ip: IpAddr) -> Result<(), AcdpError> {
407    let bad = match ip {
408        IpAddr::V4(v4) => classify_unsafe_v4(v4).is_some(),
409        IpAddr::V6(v6) => classify_unsafe_v6(v6).is_some(),
410    };
411    if bad {
412        return Err(AcdpError::SchemaViolation(format!(
413            "SSRF policy: IP address '{ip}' is in a forbidden range"
414        )));
415    }
416    Ok(())
417}
418
419// ── DNS-rebinding protection (RFC-ACDP-0006 §7.6 / RFC-ACDP-0008 §4.8) ──────
420//
421// Plumb [`SsrfPolicy::check_ip`] into reqwest's DNS resolver hook so the
422// filter and the actual TCP connect see the SAME resolved IP. A hostile
423// authoritative DNS server can no longer flip the answer between a
424// pre-connect `pin_resolved_ip` check and the real connect: reqwest
425// passes the addresses we return straight to the connector.
426
427/// Reject the **entire** resolution if ANY candidate address is in a
428/// forbidden range (RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8). Shared by
429/// [`SsrfPolicy::pin_resolved_ip`] and [`SafeDnsResolver::resolve`] so
430/// both apply identical reject-all semantics — never silent filtering.
431#[cfg(feature = "client")]
432fn reject_if_any_forbidden(
433    policy: &SsrfPolicy,
434    host: &str,
435    candidates: &[SocketAddr],
436) -> Result<(), AcdpError> {
437    for addr in candidates {
438        if let Err(e) = policy.check_ip(addr.ip()) {
439            return Err(AcdpError::SchemaViolation(format!(
440                "SSRF policy: DNS answer for '{host}' contains a forbidden address \
441                 ({} is disallowed); rejecting the entire resolution. {e}",
442                addr.ip()
443            )));
444        }
445    }
446    Ok(())
447}
448
449/// `reqwest::dns::Resolve` implementation that validates every resolved
450/// IP through an [`SsrfPolicy`] before handing them to the connector.
451#[cfg(feature = "client")]
452#[doc(hidden)]
453pub struct SafeDnsResolver {
454    policy: SsrfPolicy,
455}
456
457#[cfg(feature = "client")]
458impl SafeDnsResolver {
459    #[doc(hidden)]
460    pub fn arc(policy: SsrfPolicy) -> Arc<Self> {
461        Arc::new(Self { policy })
462    }
463}
464
465/// Build a `reqwest::Client` hardened against SSRF for outbound POSTs to
466/// operator-configured endpoints (webhook delivery, federation feeds).
467///
468/// Every resolved IP is filtered through `policy` at DNS time via
469/// `SafeDnsResolver` — defeating DNS rebinding (RFC-ACDP-0008 §4.8) — and
470/// redirects are refused outright: such an endpoint must respond directly, not
471/// bounce the registry to an internal host (e.g. cloud IMDS). `connect` and
472/// request timeouts are bounded. Use [`SsrfPolicy::default`] in production and
473/// [`SsrfPolicy::allow_test_loopback`] in tests that POST to a local listener.
474#[cfg(feature = "client")]
475pub fn safe_client(
476    policy: &SsrfPolicy,
477    timeout: std::time::Duration,
478) -> Result<reqwest::Client, AcdpError> {
479    reqwest::Client::builder()
480        .use_rustls_tls()
481        .connect_timeout(std::time::Duration::from_secs(5))
482        .timeout(timeout)
483        .redirect(reqwest::redirect::Policy::none())
484        // Each outbound POST to an operator endpoint is independent; a fresh
485        // connection per request avoids reusing a pooled connection to an
486        // endpoint that has since gone away (and re-runs the SafeDnsResolver
487        // check every time rather than pinning a once-resolved IP).
488        .pool_max_idle_per_host(0)
489        .dns_resolver(SafeDnsResolver::arc(policy.clone()))
490        .build()
491        .map_err(|e| AcdpError::Http(e.to_string()))
492}
493
494#[cfg(feature = "client")]
495impl reqwest::dns::Resolve for SafeDnsResolver {
496    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
497        let policy = self.policy.clone();
498        let host = name.as_str().to_string();
499        Box::pin(async move {
500            // Port 0 — reqwest replaces it with the URL's port (or the
501            // scheme default) before connecting. We only care about the
502            // IPs returned.
503            let target = format!("{host}:0");
504            let candidates: Vec<SocketAddr> = tokio::net::lookup_host(&target)
505                .await
506                .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
507                .collect();
508
509            if candidates.is_empty() {
510                let msg: String = format!("DNS lookup for '{host}' returned no addresses");
511                return Err(msg.into());
512            }
513
514            // RFC-ACDP-0006 §7.1 / RFC-ACDP-0008 §4.8: validate EVERY
515            // resolved address. If any answer is in a forbidden range the
516            // ENTIRE resolution is rejected — never silently filter, or an
517            // attacker bypasses the filter by mixing one public and one
518            // private answer in a single DNS response. reqwest bubbles
519            // this up as a transport error and the caller's error mapper
520            // (e.g. WebResolver) translates it.
521            if let Err(e) = reject_if_any_forbidden(&policy, &host, &candidates) {
522                let msg: String = e.to_string();
523                return Err(msg.into());
524            }
525
526            let addrs: reqwest::dns::Addrs = Box::new(candidates.into_iter());
527            Ok(addrs)
528        })
529    }
530}
531
532/// Classify an IPv4 address against the forbidden ranges, returning the
533/// stable [`SsrfReason`] for the first range it falls in (or `None` when
534/// the address is safe to connect to). The set of rejected addresses is
535/// identical to the historical `is_unsafe_v4` predicate — only the reason
536/// granularity is new.
537fn classify_unsafe_v4(ip: Ipv4Addr) -> Option<SsrfReason> {
538    let o = ip.octets();
539    if o[0] == 0 {
540        // 0.0.0.0/8 — current network
541        Some(SsrfReason::MulticastOrReserved)
542    } else if o[0] == 10 {
543        // 10.0.0.0/8 — private
544        Some(SsrfReason::Private)
545    } else if o[0] == 100 && (o[1] & 0xc0) == 64 {
546        // 100.64.0.0/10 — CGNAT
547        Some(SsrfReason::Private)
548    } else if o[0] == 127 {
549        // 127.0.0.0/8 — loopback
550        Some(SsrfReason::Loopback)
551    } else if o[0] == 169 && o[1] == 254 {
552        // 169.254.0.0/16 — link-local + AWS/GCP IMDS
553        Some(SsrfReason::Imds)
554    } else if o[0] == 172 && (o[1] & 0xf0) == 16 {
555        // 172.16.0.0/12 — private
556        Some(SsrfReason::Private)
557    } else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
558        // 192.0.0.0/24 — IETF protocol
559        Some(SsrfReason::MulticastOrReserved)
560    } else if o[0] == 192 && o[1] == 168 {
561        // 192.168.0.0/16 — private
562        Some(SsrfReason::Private)
563    } else if o[0] == 198 && (o[1] == 18 || o[1] == 19) {
564        // 198.18.0.0/15 — benchmarking
565        Some(SsrfReason::MulticastOrReserved)
566    } else if o[0] >= 224 && o[0] <= 239 {
567        // 224.0.0.0/4 — multicast
568        Some(SsrfReason::MulticastOrReserved)
569    } else if o[0] >= 240 {
570        // 240.0.0.0/4 — reserved
571        Some(SsrfReason::MulticastOrReserved)
572    } else {
573        None
574    }
575}
576
577/// Classify an IPv6 address against the forbidden ranges. Mirrors
578/// [`classify_unsafe_v4`]; the rejected set matches the historical
579/// `is_unsafe_v6` predicate exactly.
580fn classify_unsafe_v6(ip: Ipv6Addr) -> Option<SsrfReason> {
581    if ip.is_loopback() {
582        return Some(SsrfReason::Loopback);
583    }
584    if ip.is_unspecified() || ip.is_multicast() {
585        return Some(SsrfReason::MulticastOrReserved);
586    }
587    let segments = ip.segments();
588    // Embedded-IPv4 forms — both IPv4-mapped (`::ffff:a.b.c.d`) and the
589    // deprecated IPv4-compatible (`::a.b.c.d`, RFC 4291) carry an IPv4
590    // address in the low 32 bits with the high 80 bits zero. Decode it
591    // and re-run the v4 filter so e.g. `::127.0.0.1` / `::ffff:10.0.0.1`
592    // are caught. The non-zero guard keeps `::` (unspecified, already
593    // handled above) and `::1` (loopback) from being misclassified.
594    if segments[0..5] == [0, 0, 0, 0, 0] && (segments[5] == 0 || segments[5] == 0xffff) {
595        let v4 = Ipv4Addr::new(
596            (segments[6] >> 8) as u8,
597            (segments[6] & 0xff) as u8,
598            (segments[7] >> 8) as u8,
599            (segments[7] & 0xff) as u8,
600        );
601        if !v4.is_unspecified() {
602            return classify_unsafe_v4(v4);
603        }
604    }
605    // NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) and the local-use
606    // 64:ff9b:1::/48 prefix (RFC 8215): a hostile AAAA answer such as
607    // `64:ff9b::a9fe:a9fe` translates to IMDS `169.254.169.254` through a
608    // NAT64/DNS64 gateway, which is routable in IPv6-only / cloud networks.
609    if segments[0] == 0x0064 && segments[1] == 0xff9b {
610        return Some(SsrfReason::Imds);
611    }
612    // fc00::/7 — unique local
613    if (segments[0] & 0xfe00) == 0xfc00 {
614        return Some(SsrfReason::Private);
615    }
616    // fe80::/10 — link-local
617    if (segments[0] & 0xffc0) == 0xfe80 {
618        return Some(SsrfReason::Imds);
619    }
620    None
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    /// safe_client built with the default policy refuses a loopback target at
628    /// DNS time — the SafeDnsResolver rejects 127.0.0.1 before any connect, so
629    /// the request errors. This is the SSRF guard the webhook delivery client
630    /// relies on (#6).
631    #[cfg(feature = "client")]
632    #[tokio::test]
633    async fn safe_client_default_refuses_loopback() {
634        let client =
635            safe_client(&SsrfPolicy::default(), std::time::Duration::from_secs(2)).unwrap();
636        let result = client.get("http://127.0.0.1:9/").send().await;
637        assert!(
638            result.is_err(),
639            "default policy must refuse a loopback target"
640        );
641    }
642
643    /// allow_test_loopback permits loopback so tests can POST to a local
644    /// listener.
645    #[cfg(feature = "client")]
646    #[test]
647    fn safe_client_builds_with_loopback_policy() {
648        assert!(safe_client(
649            &SsrfPolicy::allow_test_loopback(),
650            std::time::Duration::from_secs(2)
651        )
652        .is_ok());
653    }
654
655    #[test]
656    fn https_only_by_default() {
657        let p = SsrfPolicy::default();
658        assert!(p.check_url("https://registry.example.com").is_ok());
659        assert!(p.check_url("http://registry.example.com").is_err());
660        assert!(p.check_url("file:///etc/passwd").is_err());
661    }
662
663    #[test]
664    fn rejects_ip_literals_by_default() {
665        let p = SsrfPolicy::default();
666        assert!(p.check_url("https://192.168.1.1").is_err());
667        assert!(p.check_url("https://[::1]").is_err());
668    }
669
670    #[test]
671    fn private_v4_ranges_rejected() {
672        // RFC 1918
673        assert!(check_safe_ip("10.0.0.1".parse().unwrap()).is_err());
674        assert!(check_safe_ip("172.16.5.5".parse().unwrap()).is_err());
675        assert!(check_safe_ip("192.168.1.1".parse().unwrap()).is_err());
676        // Loopback
677        assert!(check_safe_ip("127.0.0.1".parse().unwrap()).is_err());
678        // Link-local + AWS IMDS
679        assert!(check_safe_ip("169.254.169.254".parse().unwrap()).is_err());
680        // Multicast
681        assert!(check_safe_ip("239.0.0.1".parse().unwrap()).is_err());
682        // Public
683        assert!(check_safe_ip("8.8.8.8".parse().unwrap()).is_ok());
684        assert!(check_safe_ip("203.0.113.1".parse().unwrap()).is_ok());
685    }
686
687    #[test]
688    fn unsafe_v6_rejected() {
689        assert!(check_safe_ip("::1".parse().unwrap()).is_err());
690        assert!(check_safe_ip("fc00::1".parse().unwrap()).is_err());
691        assert!(check_safe_ip("fe80::1".parse().unwrap()).is_err());
692        // IPv4-mapped private
693        assert!(check_safe_ip("::ffff:10.0.0.1".parse().unwrap()).is_err());
694        // IPv4-compatible (deprecated `::a.b.c.d`) decoding to loopback / IMDS
695        assert!(check_safe_ip("::127.0.0.1".parse().unwrap()).is_err());
696        assert!(check_safe_ip("::7f00:1".parse().unwrap()).is_err());
697        assert!(check_safe_ip("::169.254.169.254".parse().unwrap()).is_err());
698        // NAT64 well-known prefix translating to IMDS 169.254.169.254
699        assert!(check_safe_ip("64:ff9b::a9fe:a9fe".parse().unwrap()).is_err());
700        assert!(check_safe_ip("64:ff9b::169.254.169.254".parse().unwrap()).is_err());
701        // Public v6
702        assert!(check_safe_ip("2001:db8::1".parse().unwrap()).is_ok());
703        // IPv4-compatible decoding to a *public* v4 stays allowed
704        assert!(check_safe_ip("::93.184.216.34".parse().unwrap()).is_ok());
705    }
706
707    #[test]
708    fn cross_authority_redirect_rejected() {
709        let p = SsrfPolicy::default();
710        let orig = url::Url::parse("https://registry.example.com/a").unwrap();
711        let err = p
712            .check_redirect_authority(&orig, "https://attacker.com/x")
713            .unwrap_err();
714        assert!(matches!(err, AcdpError::SchemaViolation(_)));
715        // Same authority OK
716        p.check_redirect_authority(&orig, "https://registry.example.com/y")
717            .unwrap();
718    }
719
720    // ── SEC-02 — same_fetch_authority (scheme + host + port) ────────────
721    fn u(s: &str) -> url::Url {
722        url::Url::parse(s).unwrap()
723    }
724
725    #[test]
726    fn same_host_same_implicit_port_allowed() {
727        assert!(same_fetch_authority(
728            &u("https://a.example/x"),
729            &u("https://a.example/y")
730        ));
731    }
732
733    #[test]
734    fn same_host_explicit_443_same_as_implicit_allowed() {
735        // Explicit :443 must compare equal to the implicit https default.
736        assert!(same_fetch_authority(
737            &u("https://a.example/x"),
738            &u("https://a.example:443/y")
739        ));
740    }
741
742    #[test]
743    fn same_host_different_port_rejected() {
744        assert!(!same_fetch_authority(
745            &u("https://a.example/x"),
746            &u("https://a.example:8443/y")
747        ));
748    }
749
750    #[test]
751    fn https_to_http_same_host_rejected() {
752        // Scheme downgrade is never same-authority.
753        assert!(!same_fetch_authority(
754            &u("https://a.example/x"),
755            &u("http://a.example/y")
756        ));
757    }
758
759    #[test]
760    fn different_host_rejected() {
761        assert!(!same_fetch_authority(
762            &u("https://a.example/x"),
763            &u("https://b.example/y")
764        ));
765    }
766
767    #[test]
768    fn check_redirect_authority_rejects_port_change() {
769        let p = SsrfPolicy::default();
770        let orig = u("https://registry.example.com/a");
771        let err = p
772            .check_redirect_authority(&orig, "https://registry.example.com:8443/b")
773            .unwrap_err();
774        assert!(matches!(err, AcdpError::SchemaViolation(_)));
775    }
776
777    // ── SEC-01 — reject the ENTIRE resolution on any forbidden IP ───────
778    #[cfg(feature = "client")]
779    fn sock(s: &str) -> SocketAddr {
780        s.parse().unwrap()
781    }
782
783    #[cfg(feature = "client")]
784    #[test]
785    fn mixed_public_private_dns_rejected_entirely() {
786        let p = SsrfPolicy::default();
787        let candidates = [sock("203.0.113.10:443"), sock("10.0.0.1:443")];
788        assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
789    }
790
791    #[cfg(feature = "client")]
792    #[test]
793    fn mixed_public_loopback_rejected() {
794        let p = SsrfPolicy::default();
795        let candidates = [sock("198.51.100.1:443"), sock("127.0.0.1:443")];
796        assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
797    }
798
799    #[cfg(feature = "client")]
800    #[test]
801    fn mixed_public_imds_rejected() {
802        let p = SsrfPolicy::default();
803        let candidates = [sock("198.51.100.1:443"), sock("169.254.169.254:443")];
804        assert!(reject_if_any_forbidden(&p, "evil.example", &candidates).is_err());
805    }
806
807    #[cfg(feature = "client")]
808    #[test]
809    fn single_public_ip_allowed() {
810        let p = SsrfPolicy::default();
811        let candidates = [sock("203.0.113.10:443")];
812        assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
813    }
814
815    #[cfg(feature = "client")]
816    #[test]
817    fn all_public_ips_allowed() {
818        let p = SsrfPolicy::default();
819        let candidates = [sock("203.0.113.10:443"), sock("198.51.100.1:443")];
820        assert!(reject_if_any_forbidden(&p, "ok.example", &candidates).is_ok());
821    }
822
823    #[test]
824    fn allow_http_can_be_opted_into() {
825        let p = SsrfPolicy {
826            allow_http: true,
827            ..SsrfPolicy::default()
828        };
829        assert!(p.check_url("http://registry.example.com").is_ok());
830    }
831
832    // ── SsrfReason taxonomy (D1) ────────────────────────────────────────
833    fn reason_for_ip(s: &str) -> SsrfReason {
834        SsrfPolicy::default()
835            .classify_ip(s.parse().unwrap())
836            .unwrap_err()
837            .reason
838    }
839
840    #[test]
841    fn classify_ip_maps_stable_reasons() {
842        assert_eq!(reason_for_ip("127.0.0.1"), SsrfReason::Loopback);
843        assert_eq!(reason_for_ip("10.0.0.1"), SsrfReason::Private);
844        assert_eq!(reason_for_ip("172.16.5.5"), SsrfReason::Private);
845        assert_eq!(reason_for_ip("192.168.1.1"), SsrfReason::Private);
846        assert_eq!(reason_for_ip("100.64.0.1"), SsrfReason::Private);
847        assert_eq!(reason_for_ip("169.254.169.254"), SsrfReason::Imds);
848        assert_eq!(reason_for_ip("239.0.0.1"), SsrfReason::MulticastOrReserved);
849        assert_eq!(reason_for_ip("0.0.0.1"), SsrfReason::MulticastOrReserved);
850        assert_eq!(reason_for_ip("240.0.0.1"), SsrfReason::MulticastOrReserved);
851        // IPv6
852        assert_eq!(reason_for_ip("::1"), SsrfReason::Loopback);
853        assert_eq!(reason_for_ip("fc00::1"), SsrfReason::Private);
854        assert_eq!(reason_for_ip("fe80::1"), SsrfReason::Imds);
855        // NAT64 well-known prefix → IMDS reach.
856        assert_eq!(reason_for_ip("64:ff9b::a9fe:a9fe"), SsrfReason::Imds);
857        // IPv4-mapped private decodes through to the v4 reason.
858        assert_eq!(reason_for_ip("::ffff:10.0.0.1"), SsrfReason::Private);
859        // Public addresses classify clean.
860        assert!(SsrfPolicy::default()
861            .classify_ip("8.8.8.8".parse().unwrap())
862            .is_ok());
863        assert!(SsrfPolicy::default()
864            .classify_ip("2001:db8::1".parse().unwrap())
865            .is_ok());
866    }
867
868    #[test]
869    fn classify_reason_as_str_is_stable() {
870        assert_eq!(SsrfReason::NonHttps.as_str(), "non_https");
871        assert_eq!(SsrfReason::IpLiteral.as_str(), "ip_literal");
872        assert_eq!(SsrfReason::InvalidUrl.as_str(), "invalid_url");
873        assert_eq!(SsrfReason::Loopback.as_str(), "loopback");
874        assert_eq!(SsrfReason::Private.as_str(), "private");
875        assert_eq!(SsrfReason::Imds.as_str(), "imds");
876        assert_eq!(
877            SsrfReason::MulticastOrReserved.as_str(),
878            "multicast_or_reserved"
879        );
880        assert_eq!(SsrfReason::CrossAuthority.as_str(), "cross_authority");
881    }
882
883    #[test]
884    fn classify_url_maps_stable_reasons() {
885        let p = SsrfPolicy::default();
886        assert_eq!(
887            p.classify_url("http://registry.example.com")
888                .unwrap_err()
889                .reason,
890            SsrfReason::NonHttps
891        );
892        assert_eq!(
893            p.classify_url("https://192.168.1.1").unwrap_err().reason,
894            SsrfReason::IpLiteral
895        );
896        assert_eq!(
897            p.classify_url("https://[::1]").unwrap_err().reason,
898            SsrfReason::IpLiteral
899        );
900        assert_eq!(
901            p.classify_url("not a url").unwrap_err().reason,
902            SsrfReason::InvalidUrl
903        );
904        assert!(p.classify_url("https://registry.example.com").is_ok());
905    }
906
907    #[test]
908    fn classify_redirect_reasons_and_port_parity() {
909        let p = SsrfPolicy::default();
910        // Cross-host → cross_authority.
911        assert_eq!(
912            p.classify_redirect("https://a.example/x", "https://b.example/y")
913                .unwrap_err()
914                .reason,
915            SsrfReason::CrossAuthority
916        );
917        // Port change → cross_authority.
918        assert_eq!(
919            p.classify_redirect("https://a.example/x", "https://a.example:8443/y")
920                .unwrap_err()
921                .reason,
922            SsrfReason::CrossAuthority
923        );
924        // Scheme downgrade → cross_authority.
925        assert_eq!(
926            p.classify_redirect("https://a.example/x", "http://a.example/y")
927                .unwrap_err()
928                .reason,
929            SsrfReason::CrossAuthority
930        );
931        // D2: explicit :443 is equal to the implicit https default.
932        assert!(p
933            .classify_redirect("https://a.example/x", "https://a.example:443/y")
934            .is_ok());
935        // Same authority is allowed.
936        assert!(p
937            .classify_redirect("https://a.example/x", "https://a.example/y")
938            .is_ok());
939        // Unparseable origin → invalid_url.
940        assert_eq!(
941            p.classify_redirect("::not-a-url", "https://a.example/y")
942                .unwrap_err()
943                .reason,
944            SsrfReason::InvalidUrl
945        );
946    }
947
948    #[test]
949    fn check_wrappers_preserve_schema_violation() {
950        // The back-compat surface still produces SchemaViolation with the
951        // same detail string, so existing callers are unaffected.
952        let p = SsrfPolicy::default();
953        let err = p.check_url("http://registry.example.com").unwrap_err();
954        assert!(matches!(err, AcdpError::SchemaViolation(_)));
955        let err = p.check_ip("10.0.0.1".parse().unwrap()).unwrap_err();
956        assert!(matches!(err, AcdpError::SchemaViolation(_)));
957    }
958
959    /// FEAT-07 — `pin_resolved_ip` resolves localhost (which always maps
960    /// to a forbidden range) and rejects it. This proves the §7.6 path
961    /// runs the same range filter as `check_safe_ip`, so an attacker
962    /// cannot use a hostname that only resolves to private IPs to slip
963    /// past the URL-time check by hostname.
964    #[cfg(feature = "client")]
965    #[tokio::test]
966    async fn pin_resolved_ip_rejects_loopback_hostname() {
967        let p = SsrfPolicy::default();
968        let err = p.pin_resolved_ip("localhost", 443).await.unwrap_err();
969        assert!(matches!(err, AcdpError::SchemaViolation(_)));
970    }
971}