Skip to main content

solid_pod_rs/security/
ssrf.rs

1//! SSRF guard (F1).
2//!
3//! Validates the resolved IP of a target URL against an
4//! operator-configured policy before the server issues an outbound
5//! request. Defaults are fail-safe: RFC 1918, RFC 4193, loopback,
6//! link-local, multicast, and cloud-metadata ranges are denied.
7//!
8//! Upstream parity: `JavaScriptSolidServer/src/utils/ssrf.js:15-157`.
9//! Design context: `docs/design/jss-parity/01-security-primitives-context.md`.
10
11use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
12
13use thiserror::Error;
14use url::Url;
15
16use crate::metrics::SecurityMetrics;
17
18/// Environment variable: comma-separated hostnames (or `host:port`) whose
19/// resolved IP is permitted regardless of classification. Operator
20/// escape hatch for known-good internal hosts.
21pub const ENV_SSRF_ALLOWLIST: &str = "SSRF_ALLOWLIST";
22
23/// Environment variable: comma-separated hostnames whose resolved IP is
24/// always denied, even when otherwise permitted by policy.
25pub const ENV_SSRF_DENYLIST: &str = "SSRF_DENYLIST";
26
27/// Environment variable: when set to `1`/`true`, permits RFC 1918 and
28/// RFC 4193 private address space.
29pub const ENV_SSRF_ALLOW_PRIVATE: &str = "SSRF_ALLOW_PRIVATE";
30
31/// Environment variable: when set to `1`/`true`, permits loopback
32/// (`127.0.0.0/8`, `::1`).
33pub const ENV_SSRF_ALLOW_LOOPBACK: &str = "SSRF_ALLOW_LOOPBACK";
34
35/// Environment variable: when set to `1`/`true`, permits link-local
36/// (`169.254.0.0/16`, `fe80::/10`). Note: cloud-metadata endpoints on
37/// link-local (169.254.169.254) are classified `Reserved` and cannot be
38/// unlocked by this toggle.
39pub const ENV_SSRF_ALLOW_LINK_LOCAL: &str = "SSRF_ALLOW_LINK_LOCAL";
40
41/// Classification of an IP address against the SSRF-relevant address
42/// space.
43///
44/// Total coverage: `IpClass::from(IpAddr)` (via [`SsrfPolicy::classify`])
45/// is total — every `IpAddr` maps to exactly one variant.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum IpClass {
48    /// Publicly routable unicast (the only default-permitted class).
49    Public,
50    /// RFC 1918 (10/8, 172.16/12, 192.168/16) + RFC 4193 (fc00::/7).
51    Private,
52    /// 127.0.0.0/8 + ::1.
53    Loopback,
54    /// 169.254.0.0/16 + fe80::/10 (excluding well-known metadata IPs,
55    /// which are classified `Reserved`).
56    LinkLocal,
57    /// IPv4 224.0.0.0/4 + IPv6 ff00::/8.
58    Multicast,
59    /// Reserved / unspecified / cloud-metadata (169.254.169.254,
60    /// fd00:ec2::254) / documentation ranges / benchmarking / IETF
61    /// protocol assignments.
62    Reserved,
63}
64
65/// Errors emitted while evaluating an SSRF policy.
66#[derive(Debug, Error)]
67pub enum SsrfError {
68    /// The target URL had no host component (e.g. `file:///…`).
69    #[error("URL has no host component: {0}")]
70    MissingHost(String),
71
72    /// DNS resolution of the URL's host failed (propagates the OS
73    /// error verbatim for operator triage).
74    #[error("DNS resolution failed for host '{host}': {source}")]
75    DnsFailure {
76        host: String,
77        #[source]
78        source: std::io::Error,
79    },
80
81    /// DNS resolution returned zero addresses.
82    #[error("DNS resolution returned no addresses for host '{host}'")]
83    NoAddresses { host: String },
84
85    /// The resolved IP is explicitly denylisted.
86    #[error("host '{host}' (resolved to {ip}) is denylisted")]
87    Denylisted { host: String, ip: IpAddr },
88
89    /// The resolved IP falls into a blocked address class per policy.
90    #[error("host '{host}' (resolved to {ip}) blocked: {class:?}")]
91    BlockedClass {
92        host: String,
93        ip: IpAddr,
94        class: IpClass,
95    },
96}
97
98/// SSRF policy (aggregate root).
99///
100/// Immutable after construction. To change the effective policy, build
101/// a new one and swap it atomically in the enclosing service state.
102#[derive(Debug, Clone)]
103pub struct SsrfPolicy {
104    allow_private: bool,
105    allow_loopback: bool,
106    allow_link_local: bool,
107    allowlist: Vec<String>,
108    denylist: Vec<String>,
109    metrics: Option<SecurityMetrics>,
110}
111
112impl SsrfPolicy {
113    /// Construct a maximally restrictive default policy: all
114    /// non-public classes blocked, no allowlist, no denylist, no
115    /// metrics sink. Prefer [`SsrfPolicy::from_env`] for production;
116    /// use [`SsrfPolicy::new`] only for tests and examples where the
117    /// caller fully controls the policy shape.
118    pub fn new() -> Self {
119        Self {
120            allow_private: false,
121            allow_loopback: false,
122            allow_link_local: false,
123            allowlist: Vec::new(),
124            denylist: Vec::new(),
125            metrics: None,
126        }
127    }
128
129    /// Load policy from the process environment. All toggles default
130    /// to `false`; lists default to empty.
131    ///
132    /// - `SSRF_ALLOW_PRIVATE=1`       — permit RFC 1918 / RFC 4193
133    /// - `SSRF_ALLOW_LOOPBACK=1`      — permit 127/8, ::1
134    /// - `SSRF_ALLOW_LINK_LOCAL=1`    — permit 169.254/16, fe80::/10
135    /// - `SSRF_ALLOWLIST=host1,host2` — hostname-keyed allowlist
136    /// - `SSRF_DENYLIST=host3,host4`  — hostname-keyed denylist
137    pub fn from_env() -> Self {
138        Self {
139            allow_private: env_bool(ENV_SSRF_ALLOW_PRIVATE),
140            allow_loopback: env_bool(ENV_SSRF_ALLOW_LOOPBACK),
141            allow_link_local: env_bool(ENV_SSRF_ALLOW_LINK_LOCAL),
142            allowlist: env_csv(ENV_SSRF_ALLOWLIST),
143            denylist: env_csv(ENV_SSRF_DENYLIST),
144            metrics: None,
145        }
146    }
147
148    /// Attach a metrics sink; counters are incremented on every
149    /// block/deny event, labelled by [`IpClass`].
150    pub fn with_metrics(mut self, metrics: SecurityMetrics) -> Self {
151        self.metrics = Some(metrics);
152        self
153    }
154
155    /// Replace the allowlist. Hostnames are stored verbatim and
156    /// compared case-insensitively at check time.
157    pub fn with_allowlist(mut self, hosts: Vec<String>) -> Self {
158        self.allowlist = hosts;
159        self
160    }
161
162    /// Replace the denylist.
163    pub fn with_denylist(mut self, hosts: Vec<String>) -> Self {
164        self.denylist = hosts;
165        self
166    }
167
168    /// Override the private-space toggle.
169    pub fn with_allow_private(mut self, allow: bool) -> Self {
170        self.allow_private = allow;
171        self
172    }
173
174    /// Override the loopback toggle.
175    pub fn with_allow_loopback(mut self, allow: bool) -> Self {
176        self.allow_loopback = allow;
177        self
178    }
179
180    /// Override the link-local toggle.
181    pub fn with_allow_link_local(mut self, allow: bool) -> Self {
182        self.allow_link_local = allow;
183        self
184    }
185
186    /// Classify an IP. Pure, total function over `IpAddr`.
187    pub fn classify(ip: IpAddr) -> IpClass {
188        match ip {
189            IpAddr::V4(v4) => classify_v4(v4),
190            IpAddr::V6(v6) => classify_v6(v6),
191        }
192    }
193
194    /// Resolve `url`'s host to an IP and enforce the policy.
195    ///
196    /// Returns the resolved `IpAddr` so callers can bind the
197    /// subsequent socket connect to the same address, defeating DNS
198    /// rebinding. On policy violation returns [`SsrfError::BlockedClass`]
199    /// or [`SsrfError::Denylisted`] and increments the metrics counter
200    /// labelled by the violating class.
201    ///
202    /// The allowlist short-circuits classification; a host on the
203    /// allowlist is permitted regardless of IP class. The denylist
204    /// overrides all permissive checks (including the allowlist) — a
205    /// host on both lists is denied.
206    pub async fn resolve_and_check(&self, url: &Url) -> Result<IpAddr, SsrfError> {
207        let host = url
208            .host_str()
209            .ok_or_else(|| SsrfError::MissingHost(url.to_string()))?;
210        let host_lower = host.to_ascii_lowercase();
211
212        // Resolve via tokio. Use a synthetic port so `lookup_host`
213        // accepts the input; we only care about the IP.
214        let port = url.port_or_known_default().unwrap_or(80);
215        let lookup_target = format!("{host}:{port}");
216        let mut addrs =
217            tokio::net::lookup_host(&lookup_target)
218                .await
219                .map_err(|e| SsrfError::DnsFailure {
220                    host: host.to_string(),
221                    source: e,
222                })?;
223        let sock_addr = addrs.next().ok_or_else(|| SsrfError::NoAddresses {
224            host: host.to_string(),
225        })?;
226        let ip = sock_addr.ip();
227
228        // Denylist first: absolute override.
229        if list_contains_host(&self.denylist, &host_lower) {
230            self.record_block(IpClass::Reserved);
231            return Err(SsrfError::Denylisted {
232                host: host.to_string(),
233                ip,
234            });
235        }
236
237        // Allowlist short-circuit (by hostname).
238        if list_contains_host(&self.allowlist, &host_lower) {
239            return Ok(ip);
240        }
241
242        let class = Self::classify(ip);
243        let permitted = match class {
244            IpClass::Public => true,
245            IpClass::Private => self.allow_private,
246            IpClass::Loopback => self.allow_loopback,
247            IpClass::LinkLocal => self.allow_link_local,
248            // Multicast and Reserved (incl. cloud metadata) are
249            // absolute — no toggle unlocks them; operators must
250            // allowlist explicitly by hostname.
251            IpClass::Multicast | IpClass::Reserved => false,
252        };
253
254        if !permitted {
255            self.record_block(class);
256            return Err(SsrfError::BlockedClass {
257                host: host.to_string(),
258                ip,
259                class,
260            });
261        }
262
263        Ok(ip)
264    }
265
266    fn record_block(&self, class: IpClass) {
267        if let Some(m) = &self.metrics {
268            m.record_ssrf_block(class);
269        }
270    }
271}
272
273impl Default for SsrfPolicy {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279// --- classification ------------------------------------------------------
280
281fn classify_v4(v4: Ipv4Addr) -> IpClass {
282    let o = v4.octets();
283
284    // Cloud metadata — AWS / GCP / Azure all use 169.254.169.254.
285    // Classified `Reserved` so no toggle unlocks it; operators who
286    // legitimately need it must allowlist by hostname.
287    if o == [169, 254, 169, 254] {
288        return IpClass::Reserved;
289    }
290
291    if v4.is_unspecified() || v4.is_broadcast() || v4.is_documentation() {
292        return IpClass::Reserved;
293    }
294    if v4.is_loopback() {
295        return IpClass::Loopback;
296    }
297    if v4.is_link_local() {
298        return IpClass::LinkLocal;
299    }
300    if v4.is_multicast() {
301        return IpClass::Multicast;
302    }
303    if v4.is_private() {
304        return IpClass::Private;
305    }
306
307    // Additional IETF-reserved ranges not covered by std predicates:
308    //   0.0.0.0/8          — current network
309    //   100.64.0.0/10      — CGNAT (RFC 6598)
310    //   192.0.0.0/24       — IETF protocol assignments (RFC 6890)
311    //   192.0.2.0/24       — TEST-NET-1 (covered by is_documentation)
312    //   192.88.99.0/24     — deprecated 6to4 anycast
313    //   198.18.0.0/15      — benchmarking (RFC 2544)
314    //   198.51.100.0/24    — TEST-NET-2 (covered by is_documentation)
315    //   203.0.113.0/24     — TEST-NET-3 (covered by is_documentation)
316    //   240.0.0.0/4        — reserved for future use (except broadcast)
317    match o[0] {
318        0 => return IpClass::Reserved,
319        100 if (o[1] & 0xC0) == 0x40 => return IpClass::Reserved, // 100.64/10
320        192 if o[1] == 0 && o[2] == 0 => return IpClass::Reserved,
321        192 if o[1] == 88 && o[2] == 99 => return IpClass::Reserved,
322        198 if o[1] == 18 || o[1] == 19 => return IpClass::Reserved,
323        240..=255 => return IpClass::Reserved,
324        _ => {}
325    }
326
327    IpClass::Public
328}
329
330fn classify_v6(v6: Ipv6Addr) -> IpClass {
331    // AWS EC2 IMDS IPv6 endpoint: fd00:ec2::254.
332    let segs = v6.segments();
333    if segs == [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254] {
334        return IpClass::Reserved;
335    }
336
337    if v6.is_unspecified() {
338        return IpClass::Reserved;
339    }
340    if v6.is_loopback() {
341        return IpClass::Loopback;
342    }
343    if v6.is_multicast() {
344        return IpClass::Multicast;
345    }
346
347    // IPv4-mapped (::ffff:0:0/96): route through IPv4 classification.
348    if let Some(v4) = v6.to_ipv4_mapped() {
349        return classify_v4(v4);
350    }
351
352    // IPv4-compatible (deprecated ::/96 prefix, e.g. ::127.0.0.1).
353    // `to_ipv4_mapped()` only catches ::ffff:x.x.x.x; the deprecated
354    // ::x.x.x.x form is still resolved by many OS network stacks.
355    let octets = v6.octets();
356    if octets[..10] == [0u8; 10] && octets[10] == 0 && octets[11] == 0 {
357        // octets[12..16] hold the embedded IPv4 — but skip ::0.0.0.0
358        // (unspecified, handled above) and ::0.0.0.1 (loopback, handled
359        // above).
360        let v4 = Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]);
361        if !v4.is_unspecified() && v4 != Ipv4Addr::new(0, 0, 0, 1) {
362            return classify_v4(v4);
363        }
364    }
365
366    let first = segs[0];
367
368    // 6to4 (2002::/16): the first 4 bytes after the prefix embed the
369    // IPv4 gateway.  2002:c0a8:0101:: embeds 192.168.1.1.
370    if first == 0x2002 {
371        let v4 = Ipv4Addr::new(
372            (segs[1] >> 8) as u8,
373            (segs[1] & 0xFF) as u8,
374            (segs[2] >> 8) as u8,
375            (segs[2] & 0xFF) as u8,
376        );
377        return classify_v4(v4);
378    }
379
380    // Link-local: fe80::/10
381    if (first & 0xFFC0) == 0xFE80 {
382        return IpClass::LinkLocal;
383    }
384
385    // Unique local: fc00::/7 (includes fd00::/8). RFC 4193.
386    if (first & 0xFE00) == 0xFC00 {
387        return IpClass::Private;
388    }
389
390    // Site-local (deprecated, fec0::/10) — treat as Private for safety.
391    if (first & 0xFFC0) == 0xFEC0 {
392        return IpClass::Private;
393    }
394
395    // Discard / documentation / reserved prefixes.
396    //   100::/64               — discard-only
397    //   2001:db8::/32          — documentation
398    //   2001::/32 (Teredo)     — treat as Reserved (not public routable
399    //                            for SSRF purposes; operators may allowlist)
400    //   ::/128, ::1/128        — handled above
401    if first == 0x0100 && segs[1] == 0 && segs[2] == 0 && segs[3] == 0 {
402        return IpClass::Reserved;
403    }
404    if first == 0x2001 && segs[1] == 0x0db8 {
405        return IpClass::Reserved;
406    }
407
408    IpClass::Public
409}
410
411// --- helpers -------------------------------------------------------------
412
413fn list_contains_host(list: &[String], host_lower: &str) -> bool {
414    list.iter().any(|entry| {
415        let e = entry.trim().to_ascii_lowercase();
416        // Allow entries of the form `host:port` — match on the host part.
417        let e_host = e.split(':').next().unwrap_or(&e);
418        !e_host.is_empty() && e_host == host_lower
419    })
420}
421
422fn env_bool(key: &str) -> bool {
423    std::env::var(key)
424        .ok()
425        .map(|v| {
426            let v = v.trim().to_ascii_lowercase();
427            matches!(v.as_str(), "1" | "true" | "yes" | "on")
428        })
429        .unwrap_or(false)
430}
431
432fn env_csv(key: &str) -> Vec<String> {
433    std::env::var(key)
434        .ok()
435        .map(|raw| {
436            raw.split(',')
437                .map(|s| s.trim().to_string())
438                .filter(|s| !s.is_empty())
439                .collect()
440        })
441        .unwrap_or_default()
442}
443
444// --- Sprint 9: row 114 free-function primitives --------------------------
445//
446// The JSS upstream (`src/utils/ssrf.js:15-157`) exposes two plain functions:
447//   - `isSafeUrl(url)`   — sync URL-shape + IP-literal host check
448//   - `resolveAndCheck(host)` — async DNS lookup + per-IP policy check
449//
450// These mirror that shape on top of the Rust aggregate. They use a
451// maximally restrictive default policy (no toggles, no lists) so every
452// blocked class is actually blocked, matching JSS defaults.
453
454/// Sync primitive: accept a URL string, parse its shape, and refuse any
455/// URL whose host is either absent or an IP literal in a blocked class.
456///
457/// Does **not** perform DNS resolution — use [`resolve_and_check`] for
458/// that. Use this as a cheap pre-flight when you have a URL but not a
459/// DNS resolver in scope (e.g. config validation, audit log emission).
460///
461/// Blocked IP-literal hosts cover: RFC 1918 private (10/8, 172.16/12,
462/// 192.168/16), RFC 4193 unique-local (fc00::/7), loopback (127/8, ::1),
463/// link-local (169.254/16, fe80::/10), multicast (224/4, ff00::/8),
464/// cloud-metadata (169.254.169.254, fd00:ec2::254), and IETF-reserved
465/// ranges. Known cloud-metadata hostnames
466/// (`metadata.google.internal`, bare `metadata`) are also blocked
467/// without DNS resolution.
468///
469/// Upstream parity: `JavaScriptSolidServer/src/utils/ssrf.js:15-157`.
470pub fn is_safe_url(url: &str) -> Result<(), SsrfError> {
471    let parsed = Url::parse(url).map_err(|_| SsrfError::MissingHost(url.to_string()))?;
472    let host = parsed
473        .host()
474        .ok_or_else(|| SsrfError::MissingHost(url.to_string()))?;
475
476    match host {
477        url::Host::Ipv4(v4) => check_ip_safe(&v4.to_string(), IpAddr::V4(v4)),
478        url::Host::Ipv6(v6) => check_ip_safe(&v6.to_string(), IpAddr::V6(v6)),
479        url::Host::Domain(d) => {
480            if is_known_metadata_hostname(d) {
481                return Err(SsrfError::BlockedClass {
482                    host: d.to_string(),
483                    ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
484                    class: IpClass::Reserved,
485                });
486            }
487            Ok(())
488        }
489    }
490}
491
492/// Async primitive: resolve `host` via DNS and check every returned
493/// address against the restrictive default policy. Returns the first
494/// resolved address on success; if any resolved address is blocked the
495/// whole lookup is denied (we bind to the first address, so we must
496/// refuse as soon as any rebinding target is known-bad).
497///
498/// Accepts either `host` or `host:port`. When no port is supplied, a
499/// synthetic `:80` is used for the socket lookup — only the IP is
500/// consulted.
501///
502/// Upstream parity: `JavaScriptSolidServer/src/utils/ssrf.js:15-157`.
503pub async fn resolve_and_check(host: &str) -> Result<IpAddr, SsrfError> {
504    // Cloud-metadata hostname short-circuit: refuse without a lookup
505    // so operators cannot unintentionally leak metadata via a
506    // malicious DNS record.
507    if is_known_metadata_hostname(host) {
508        return Err(SsrfError::BlockedClass {
509            host: host.to_string(),
510            ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
511            class: IpClass::Reserved,
512        });
513    }
514
515    let lookup_target = if host.contains(':') {
516        host.to_string()
517    } else {
518        format!("{host}:80")
519    };
520    let addrs =
521        tokio::net::lookup_host(&lookup_target)
522            .await
523            .map_err(|e| SsrfError::DnsFailure {
524                host: host.to_string(),
525                source: e,
526            })?;
527
528    let mut first: Option<IpAddr> = None;
529    for sock in addrs {
530        let ip = sock.ip();
531        check_ip_safe(host, ip)?;
532        if first.is_none() {
533            first = Some(ip);
534        }
535    }
536    first.ok_or_else(|| SsrfError::NoAddresses {
537        host: host.to_string(),
538    })
539}
540
541fn check_ip_safe(host: &str, ip: IpAddr) -> Result<(), SsrfError> {
542    let class = SsrfPolicy::classify(ip);
543    match class {
544        IpClass::Public => Ok(()),
545        IpClass::Private
546        | IpClass::Loopback
547        | IpClass::LinkLocal
548        | IpClass::Multicast
549        | IpClass::Reserved => Err(SsrfError::BlockedClass {
550            host: host.to_string(),
551            ip,
552            class,
553        }),
554    }
555}
556
557fn is_known_metadata_hostname(host: &str) -> bool {
558    // Strip optional port suffix for the hostname match.
559    let host_only = host.split(':').next().unwrap_or(host);
560    let lc = host_only.to_ascii_lowercase();
561    // GCP publishes `metadata.google.internal` and `metadata` →
562    // 169.254.169.254. AWS and Azure both use the bare IP literal,
563    // which `check_ip_safe` already covers.
564    matches!(
565        lc.as_str(),
566        "metadata.google.internal" | "metadata" | "metadata.goog"
567    )
568}
569
570// --- unit tests ----------------------------------------------------------
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use std::net::{Ipv4Addr, Ipv6Addr};
576
577    #[test]
578    fn classify_rfc1918_private() {
579        assert_eq!(
580            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
581            IpClass::Private
582        );
583        assert_eq!(
584            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))),
585            IpClass::Private
586        );
587        assert_eq!(
588            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))),
589            IpClass::Private
590        );
591    }
592
593    #[test]
594    fn classify_loopback() {
595        assert_eq!(
596            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
597            IpClass::Loopback
598        );
599        assert_eq!(
600            SsrfPolicy::classify(IpAddr::V6(Ipv6Addr::LOCALHOST)),
601            IpClass::Loopback
602        );
603    }
604
605    #[test]
606    fn classify_public() {
607        assert_eq!(
608            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))),
609            IpClass::Public
610        );
611        assert_eq!(
612            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))),
613            IpClass::Public
614        );
615    }
616
617    #[test]
618    fn classify_cloud_metadata() {
619        assert_eq!(
620            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))),
621            IpClass::Reserved
622        );
623    }
624
625    #[test]
626    fn classify_ipv6_link_local() {
627        assert_eq!(
628            SsrfPolicy::classify(IpAddr::V6("fe80::1".parse().unwrap())),
629            IpClass::LinkLocal
630        );
631    }
632
633    #[test]
634    fn classify_ipv6_ula() {
635        assert_eq!(
636            SsrfPolicy::classify(IpAddr::V6("fc00::1".parse().unwrap())),
637            IpClass::Private
638        );
639        assert_eq!(
640            SsrfPolicy::classify(IpAddr::V6("fd12:3456:789a::1".parse().unwrap())),
641            IpClass::Private
642        );
643    }
644
645    #[test]
646    fn classify_ipv6_public() {
647        assert_eq!(
648            SsrfPolicy::classify(IpAddr::V6("2001:4860:4860::8888".parse().unwrap())),
649            IpClass::Public
650        );
651    }
652
653    // ----- R2-P0-04: IPv4-compatible IPv6 bypass ----------------------------
654
655    #[test]
656    fn classify_ipv4_compatible_loopback() {
657        // ::127.0.0.1 — deprecated IPv4-compatible form of loopback
658        let addr: Ipv6Addr = "::127.0.0.1".parse().unwrap();
659        assert_eq!(
660            SsrfPolicy::classify(IpAddr::V6(addr)),
661            IpClass::Loopback,
662            "::127.0.0.1 must be classified as Loopback"
663        );
664    }
665
666    #[test]
667    fn classify_ipv4_compatible_private_10() {
668        // ::10.0.0.1 — deprecated IPv4-compatible form of RFC 1918
669        let addr: Ipv6Addr = "::10.0.0.1".parse().unwrap();
670        assert_eq!(
671            SsrfPolicy::classify(IpAddr::V6(addr)),
672            IpClass::Private,
673            "::10.0.0.1 must be classified as Private"
674        );
675    }
676
677    #[test]
678    fn classify_ipv4_compatible_link_local_metadata() {
679        // ::169.254.169.254 — deprecated IPv4-compatible cloud metadata
680        let addr: Ipv6Addr = "::169.254.169.254".parse().unwrap();
681        assert_eq!(
682            SsrfPolicy::classify(IpAddr::V6(addr)),
683            IpClass::Reserved,
684            "::169.254.169.254 must be classified as Reserved (cloud metadata)"
685        );
686    }
687
688    #[test]
689    fn classify_ipv4_compatible_public() {
690        // ::8.8.8.8 — public address in IPv4-compatible form
691        let addr: Ipv6Addr = "::8.8.8.8".parse().unwrap();
692        assert_eq!(
693            SsrfPolicy::classify(IpAddr::V6(addr)),
694            IpClass::Public,
695            "::8.8.8.8 should be classified as Public"
696        );
697    }
698
699    #[test]
700    fn classify_6to4_private() {
701        // 2002:c0a8:0101:: embeds 192.168.1.1
702        let addr: Ipv6Addr = "2002:c0a8:0101::".parse().unwrap();
703        assert_eq!(
704            SsrfPolicy::classify(IpAddr::V6(addr)),
705            IpClass::Private,
706            "2002:c0a8:0101:: (6to4 embedding 192.168.1.1) must be Private"
707        );
708    }
709
710    #[test]
711    fn classify_6to4_loopback() {
712        // 2002:7f00:0001:: embeds 127.0.0.1
713        let addr: Ipv6Addr = "2002:7f00:0001::".parse().unwrap();
714        assert_eq!(
715            SsrfPolicy::classify(IpAddr::V6(addr)),
716            IpClass::Loopback,
717            "2002:7f00:0001:: (6to4 embedding 127.0.0.1) must be Loopback"
718        );
719    }
720
721    #[test]
722    fn classify_6to4_metadata() {
723        // 2002:a9fe:a9fe:: embeds 169.254.169.254
724        let addr: Ipv6Addr = "2002:a9fe:a9fe::".parse().unwrap();
725        assert_eq!(
726            SsrfPolicy::classify(IpAddr::V6(addr)),
727            IpClass::Reserved,
728            "2002:a9fe:a9fe:: (6to4 embedding 169.254.169.254) must be Reserved"
729        );
730    }
731
732    #[test]
733    fn classify_6to4_public() {
734        // 2002:0808:0808:: embeds 8.8.8.8
735        let addr: Ipv6Addr = "2002:0808:0808::".parse().unwrap();
736        assert_eq!(
737            SsrfPolicy::classify(IpAddr::V6(addr)),
738            IpClass::Public,
739            "2002:0808:0808:: (6to4 embedding 8.8.8.8) should be Public"
740        );
741    }
742
743    #[test]
744    fn blocks_ipv4_compatible_in_url() {
745        // Verify the URL-level check catches the IPv4-compatible bypass
746        assert_blocked("http://[::127.0.0.1]/", IpClass::Loopback);
747        assert_blocked("http://[::10.0.0.1]/", IpClass::Private);
748        assert_blocked("http://[::169.254.169.254]/", IpClass::Reserved);
749    }
750
751    #[test]
752    fn blocks_6to4_in_url() {
753        assert_blocked("http://[2002:c0a8:0101::]/", IpClass::Private);
754        assert_blocked("http://[2002:7f00:0001::]/", IpClass::Loopback);
755        assert_blocked("http://[2002:a9fe:a9fe::]/", IpClass::Reserved);
756    }
757
758    #[test]
759    fn default_policy_blocks_private() {
760        let p = SsrfPolicy::new();
761        assert!(!p.allow_private);
762        assert!(!p.allow_loopback);
763        assert!(!p.allow_link_local);
764    }
765
766    // ----- Sprint 9 row 114: free-function primitives -------------------
767
768    fn assert_blocked(url: &str, want_class: IpClass) {
769        match is_safe_url(url) {
770            Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(
771                class, want_class,
772                "url {url} blocked with {class:?}, wanted {want_class:?}"
773            ),
774            Err(other) => panic!("url {url} rejected with unexpected error: {other}"),
775            Ok(()) => panic!("url {url} accepted but expected block for {want_class:?}"),
776        }
777    }
778
779    #[test]
780    fn blocks_rfc1918_addresses() {
781        let cases = [
782            "http://10.0.0.1/",
783            "http://10.255.255.255/",
784            "http://172.16.0.1/",
785            "http://172.31.255.255/",
786            "http://192.168.0.1/",
787            "http://192.168.255.255/",
788            "http://[fc00::1]/",
789            "http://[fd00::1]/",
790        ];
791        for url in cases {
792            assert_blocked(url, IpClass::Private);
793        }
794    }
795
796    #[test]
797    fn blocks_loopback() {
798        assert_blocked("http://127.0.0.1/", IpClass::Loopback);
799        assert_blocked("http://127.255.255.254/", IpClass::Loopback);
800        assert_blocked("http://[::1]/", IpClass::Loopback);
801    }
802
803    #[test]
804    fn blocks_link_local() {
805        assert_blocked("http://169.254.1.1/", IpClass::LinkLocal);
806        assert_blocked("http://169.254.254.254/", IpClass::LinkLocal);
807        assert_blocked("http://[fe80::1]/", IpClass::LinkLocal);
808    }
809
810    #[test]
811    fn blocks_aws_metadata_ip() {
812        // AWS/Azure/GCP all share the 169.254.169.254 literal.
813        assert_blocked(
814            "http://169.254.169.254/latest/meta-data/",
815            IpClass::Reserved,
816        );
817        assert_blocked(
818            "http://[fd00:ec2::254]/latest/meta-data/",
819            IpClass::Reserved,
820        );
821    }
822
823    #[tokio::test]
824    async fn blocks_aws_metadata_hostname() {
825        assert_blocked(
826            "http://metadata.google.internal/computeMetadata/v1/",
827            IpClass::Reserved,
828        );
829        match resolve_and_check("metadata.google.internal").await {
830            Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(class, IpClass::Reserved),
831            other => panic!("expected BlockedClass for metadata.google.internal, got {other:?}"),
832        }
833        match resolve_and_check("metadata").await {
834            Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(class, IpClass::Reserved),
835            other => panic!("expected BlockedClass for bare 'metadata', got {other:?}"),
836        }
837    }
838
839    #[test]
840    fn allows_public_ipv4() {
841        assert!(is_safe_url("https://8.8.8.8/").is_ok());
842        assert!(is_safe_url("https://1.1.1.1/").is_ok());
843        assert!(is_safe_url("https://93.184.216.34/").is_ok());
844    }
845
846    #[test]
847    fn allows_public_ipv6() {
848        assert!(is_safe_url("https://[2001:4860:4860::8888]/").is_ok());
849        assert!(is_safe_url("https://[2606:4700:4700::1111]/").is_ok());
850    }
851
852    #[test]
853    fn rejects_malformed_url() {
854        match is_safe_url("not a url") {
855            Err(SsrfError::MissingHost(_)) => {}
856            other => panic!("expected MissingHost for malformed url, got {other:?}"),
857        }
858        match is_safe_url("") {
859            Err(SsrfError::MissingHost(_)) => {}
860            other => panic!("expected MissingHost for empty url, got {other:?}"),
861        }
862    }
863
864    #[test]
865    fn rejects_http_without_host() {
866        match is_safe_url("file:///etc/passwd") {
867            Err(SsrfError::MissingHost(_)) => {}
868            other => panic!("expected MissingHost for file URL, got {other:?}"),
869        }
870    }
871
872    // ----- Sprint 12: DNS resolution failure blocks request ----------------
873    //
874    // JSS commit 4dbf039: when DNS resolution fails the request must be
875    // blocked — never fall through to a permissive default.
876
877    #[tokio::test]
878    async fn dns_failure_blocks_request() {
879        // Use an unresolvable hostname (RFC 6761 §6.4: `.invalid` is
880        // guaranteed to never resolve).
881        let result = resolve_and_check("this-host-does-not-exist.invalid").await;
882        match result {
883            Err(SsrfError::DnsFailure { host, .. }) => {
884                assert_eq!(host, "this-host-does-not-exist.invalid");
885            }
886            Err(SsrfError::NoAddresses { host, .. }) => {
887                // Some resolvers return empty results instead of an
888                // error — both are acceptable block outcomes.
889                assert_eq!(host, "this-host-does-not-exist.invalid");
890            }
891            Err(other) => {
892                panic!("expected DnsFailure or NoAddresses for unresolvable host, got {other:?}")
893            }
894            Ok(ip) => panic!("expected DNS failure for unresolvable host, got Ok({ip})"),
895        }
896    }
897
898    #[tokio::test]
899    async fn policy_dns_failure_blocks_request() {
900        // Same test through the SsrfPolicy aggregate.
901        let policy = SsrfPolicy::new();
902        let url =
903            Url::parse("https://this-host-does-not-exist.invalid/resource").expect("valid URL");
904        let result = policy.resolve_and_check(&url).await;
905        match result {
906            Err(SsrfError::DnsFailure { host, .. }) => {
907                assert!(host.contains("this-host-does-not-exist.invalid"));
908            }
909            Err(SsrfError::NoAddresses { host, .. }) => {
910                assert!(host.contains("this-host-does-not-exist.invalid"));
911            }
912            Err(other) => panic!("expected DnsFailure/NoAddresses through policy, got {other:?}"),
913            Ok(ip) => panic!("expected DNS failure through policy, got Ok({ip})"),
914        }
915    }
916}