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 = tokio::net::lookup_host(&lookup_target)
217            .await
218            .map_err(|e| SsrfError::DnsFailure {
219                host: host.to_string(),
220                source: e,
221            })?;
222        let sock_addr = addrs.next().ok_or_else(|| SsrfError::NoAddresses {
223            host: host.to_string(),
224        })?;
225        let ip = sock_addr.ip();
226
227        // Denylist first: absolute override.
228        if list_contains_host(&self.denylist, &host_lower) {
229            self.record_block(IpClass::Reserved);
230            return Err(SsrfError::Denylisted {
231                host: host.to_string(),
232                ip,
233            });
234        }
235
236        // Allowlist short-circuit (by hostname).
237        if list_contains_host(&self.allowlist, &host_lower) {
238            return Ok(ip);
239        }
240
241        let class = Self::classify(ip);
242        let permitted = match class {
243            IpClass::Public => true,
244            IpClass::Private => self.allow_private,
245            IpClass::Loopback => self.allow_loopback,
246            IpClass::LinkLocal => self.allow_link_local,
247            // Multicast and Reserved (incl. cloud metadata) are
248            // absolute — no toggle unlocks them; operators must
249            // allowlist explicitly by hostname.
250            IpClass::Multicast | IpClass::Reserved => false,
251        };
252
253        if !permitted {
254            self.record_block(class);
255            return Err(SsrfError::BlockedClass {
256                host: host.to_string(),
257                ip,
258                class,
259            });
260        }
261
262        Ok(ip)
263    }
264
265    fn record_block(&self, class: IpClass) {
266        if let Some(m) = &self.metrics {
267            m.record_ssrf_block(class);
268        }
269    }
270}
271
272impl Default for SsrfPolicy {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278// --- classification ------------------------------------------------------
279
280fn classify_v4(v4: Ipv4Addr) -> IpClass {
281    let o = v4.octets();
282
283    // Cloud metadata — AWS / GCP / Azure all use 169.254.169.254.
284    // Classified `Reserved` so no toggle unlocks it; operators who
285    // legitimately need it must allowlist by hostname.
286    if o == [169, 254, 169, 254] {
287        return IpClass::Reserved;
288    }
289
290    if v4.is_unspecified() || v4.is_broadcast() || v4.is_documentation() {
291        return IpClass::Reserved;
292    }
293    if v4.is_loopback() {
294        return IpClass::Loopback;
295    }
296    if v4.is_link_local() {
297        return IpClass::LinkLocal;
298    }
299    if v4.is_multicast() {
300        return IpClass::Multicast;
301    }
302    if v4.is_private() {
303        return IpClass::Private;
304    }
305
306    // Additional IETF-reserved ranges not covered by std predicates:
307    //   0.0.0.0/8          — current network
308    //   100.64.0.0/10      — CGNAT (RFC 6598)
309    //   192.0.0.0/24       — IETF protocol assignments (RFC 6890)
310    //   192.0.2.0/24       — TEST-NET-1 (covered by is_documentation)
311    //   192.88.99.0/24     — deprecated 6to4 anycast
312    //   198.18.0.0/15      — benchmarking (RFC 2544)
313    //   198.51.100.0/24    — TEST-NET-2 (covered by is_documentation)
314    //   203.0.113.0/24     — TEST-NET-3 (covered by is_documentation)
315    //   240.0.0.0/4        — reserved for future use (except broadcast)
316    match o[0] {
317        0 => return IpClass::Reserved,
318        100 if (o[1] & 0xC0) == 0x40 => return IpClass::Reserved, // 100.64/10
319        192 if o[1] == 0 && o[2] == 0 => return IpClass::Reserved,
320        192 if o[1] == 88 && o[2] == 99 => return IpClass::Reserved,
321        198 if o[1] == 18 || o[1] == 19 => return IpClass::Reserved,
322        240..=255 => return IpClass::Reserved,
323        _ => {}
324    }
325
326    IpClass::Public
327}
328
329fn classify_v6(v6: Ipv6Addr) -> IpClass {
330    // AWS EC2 IMDS IPv6 endpoint: fd00:ec2::254.
331    let segs = v6.segments();
332    if segs == [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254] {
333        return IpClass::Reserved;
334    }
335
336    if v6.is_unspecified() {
337        return IpClass::Reserved;
338    }
339    if v6.is_loopback() {
340        return IpClass::Loopback;
341    }
342    if v6.is_multicast() {
343        return IpClass::Multicast;
344    }
345
346    // IPv4-mapped (::ffff:0:0/96): route through IPv4 classification.
347    if let Some(v4) = v6.to_ipv4_mapped() {
348        return classify_v4(v4);
349    }
350
351    // IPv4-compatible (deprecated ::/96 prefix, e.g. ::127.0.0.1).
352    // `to_ipv4_mapped()` only catches ::ffff:x.x.x.x; the deprecated
353    // ::x.x.x.x form is still resolved by many OS network stacks.
354    let octets = v6.octets();
355    if octets[..10] == [0u8; 10] && octets[10] == 0 && octets[11] == 0 {
356        // octets[12..16] hold the embedded IPv4 — but skip ::0.0.0.0
357        // (unspecified, handled above) and ::0.0.0.1 (loopback, handled
358        // above).
359        let v4 = Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]);
360        if !v4.is_unspecified() && v4 != Ipv4Addr::new(0, 0, 0, 1) {
361            return classify_v4(v4);
362        }
363    }
364
365    let first = segs[0];
366
367    // 6to4 (2002::/16): the first 4 bytes after the prefix embed the
368    // IPv4 gateway.  2002:c0a8:0101:: embeds 192.168.1.1.
369    if first == 0x2002 {
370        let v4 = Ipv4Addr::new(
371            (segs[1] >> 8) as u8,
372            (segs[1] & 0xFF) as u8,
373            (segs[2] >> 8) as u8,
374            (segs[2] & 0xFF) as u8,
375        );
376        return classify_v4(v4);
377    }
378
379    // Link-local: fe80::/10
380    if (first & 0xFFC0) == 0xFE80 {
381        return IpClass::LinkLocal;
382    }
383
384    // Unique local: fc00::/7 (includes fd00::/8). RFC 4193.
385    if (first & 0xFE00) == 0xFC00 {
386        return IpClass::Private;
387    }
388
389    // Site-local (deprecated, fec0::/10) — treat as Private for safety.
390    if (first & 0xFFC0) == 0xFEC0 {
391        return IpClass::Private;
392    }
393
394    // Discard / documentation / reserved prefixes.
395    //   100::/64               — discard-only
396    //   2001:db8::/32          — documentation
397    //   2001::/32 (Teredo)     — treat as Reserved (not public routable
398    //                            for SSRF purposes; operators may allowlist)
399    //   ::/128, ::1/128        — handled above
400    if first == 0x0100 && segs[1] == 0 && segs[2] == 0 && segs[3] == 0 {
401        return IpClass::Reserved;
402    }
403    if first == 0x2001 && segs[1] == 0x0db8 {
404        return IpClass::Reserved;
405    }
406
407    IpClass::Public
408}
409
410// --- helpers -------------------------------------------------------------
411
412fn list_contains_host(list: &[String], host_lower: &str) -> bool {
413    list.iter().any(|entry| {
414        let e = entry.trim().to_ascii_lowercase();
415        // Allow entries of the form `host:port` — match on the host part.
416        let e_host = e.split(':').next().unwrap_or(&e);
417        !e_host.is_empty() && e_host == host_lower
418    })
419}
420
421fn env_bool(key: &str) -> bool {
422    std::env::var(key)
423        .ok()
424        .map(|v| {
425            let v = v.trim().to_ascii_lowercase();
426            matches!(v.as_str(), "1" | "true" | "yes" | "on")
427        })
428        .unwrap_or(false)
429}
430
431fn env_csv(key: &str) -> Vec<String> {
432    std::env::var(key)
433        .ok()
434        .map(|raw| {
435            raw.split(',')
436                .map(|s| s.trim().to_string())
437                .filter(|s| !s.is_empty())
438                .collect()
439        })
440        .unwrap_or_default()
441}
442
443// --- Sprint 9: row 114 free-function primitives --------------------------
444//
445// The JSS upstream (`src/utils/ssrf.js:15-157`) exposes two plain functions:
446//   - `isSafeUrl(url)`   — sync URL-shape + IP-literal host check
447//   - `resolveAndCheck(host)` — async DNS lookup + per-IP policy check
448//
449// These mirror that shape on top of the Rust aggregate. They use a
450// maximally restrictive default policy (no toggles, no lists) so every
451// blocked class is actually blocked, matching JSS defaults.
452
453/// Sync primitive: accept a URL string, parse its shape, and refuse any
454/// URL whose host is either absent or an IP literal in a blocked class.
455///
456/// Does **not** perform DNS resolution — use [`resolve_and_check`] for
457/// that. Use this as a cheap pre-flight when you have a URL but not a
458/// DNS resolver in scope (e.g. config validation, audit log emission).
459///
460/// Blocked IP-literal hosts cover: RFC 1918 private (10/8, 172.16/12,
461/// 192.168/16), RFC 4193 unique-local (fc00::/7), loopback (127/8, ::1),
462/// link-local (169.254/16, fe80::/10), multicast (224/4, ff00::/8),
463/// cloud-metadata (169.254.169.254, fd00:ec2::254), and IETF-reserved
464/// ranges. Known cloud-metadata hostnames
465/// (`metadata.google.internal`, bare `metadata`) are also blocked
466/// without DNS resolution.
467///
468/// Upstream parity: `JavaScriptSolidServer/src/utils/ssrf.js:15-157`.
469pub fn is_safe_url(url: &str) -> Result<(), SsrfError> {
470    let parsed = Url::parse(url).map_err(|_| SsrfError::MissingHost(url.to_string()))?;
471    let host = parsed
472        .host()
473        .ok_or_else(|| SsrfError::MissingHost(url.to_string()))?;
474
475    match host {
476        url::Host::Ipv4(v4) => check_ip_safe(&v4.to_string(), IpAddr::V4(v4)),
477        url::Host::Ipv6(v6) => check_ip_safe(&v6.to_string(), IpAddr::V6(v6)),
478        url::Host::Domain(d) => {
479            if is_known_metadata_hostname(d) {
480                return Err(SsrfError::BlockedClass {
481                    host: d.to_string(),
482                    ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
483                    class: IpClass::Reserved,
484                });
485            }
486            Ok(())
487        }
488    }
489}
490
491/// Async primitive: resolve `host` via DNS and check every returned
492/// address against the restrictive default policy. Returns the first
493/// resolved address on success; if any resolved address is blocked the
494/// whole lookup is denied (we bind to the first address, so we must
495/// refuse as soon as any rebinding target is known-bad).
496///
497/// Accepts either `host` or `host:port`. When no port is supplied, a
498/// synthetic `:80` is used for the socket lookup — only the IP is
499/// consulted.
500///
501/// Upstream parity: `JavaScriptSolidServer/src/utils/ssrf.js:15-157`.
502pub async fn resolve_and_check(host: &str) -> Result<IpAddr, SsrfError> {
503    // Cloud-metadata hostname short-circuit: refuse without a lookup
504    // so operators cannot unintentionally leak metadata via a
505    // malicious DNS record.
506    if is_known_metadata_hostname(host) {
507        return Err(SsrfError::BlockedClass {
508            host: host.to_string(),
509            ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
510            class: IpClass::Reserved,
511        });
512    }
513
514    let lookup_target = if host.contains(':') {
515        host.to_string()
516    } else {
517        format!("{host}:80")
518    };
519    let addrs = tokio::net::lookup_host(&lookup_target)
520        .await
521        .map_err(|e| SsrfError::DnsFailure {
522            host: host.to_string(),
523            source: e,
524        })?;
525
526    let mut first: Option<IpAddr> = None;
527    for sock in addrs {
528        let ip = sock.ip();
529        check_ip_safe(host, ip)?;
530        if first.is_none() {
531            first = Some(ip);
532        }
533    }
534    first.ok_or_else(|| SsrfError::NoAddresses {
535        host: host.to_string(),
536    })
537}
538
539fn check_ip_safe(host: &str, ip: IpAddr) -> Result<(), SsrfError> {
540    let class = SsrfPolicy::classify(ip);
541    match class {
542        IpClass::Public => Ok(()),
543        IpClass::Private
544        | IpClass::Loopback
545        | IpClass::LinkLocal
546        | IpClass::Multicast
547        | IpClass::Reserved => Err(SsrfError::BlockedClass {
548            host: host.to_string(),
549            ip,
550            class,
551        }),
552    }
553}
554
555fn is_known_metadata_hostname(host: &str) -> bool {
556    // Strip optional port suffix for the hostname match.
557    let host_only = host.split(':').next().unwrap_or(host);
558    let lc = host_only.to_ascii_lowercase();
559    // GCP publishes `metadata.google.internal` and `metadata` →
560    // 169.254.169.254. AWS and Azure both use the bare IP literal,
561    // which `check_ip_safe` already covers.
562    matches!(
563        lc.as_str(),
564        "metadata.google.internal" | "metadata" | "metadata.goog"
565    )
566}
567
568// --- unit tests ----------------------------------------------------------
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use std::net::{Ipv4Addr, Ipv6Addr};
574
575    #[test]
576    fn classify_rfc1918_private() {
577        assert_eq!(
578            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))),
579            IpClass::Private
580        );
581        assert_eq!(
582            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))),
583            IpClass::Private
584        );
585        assert_eq!(
586            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))),
587            IpClass::Private
588        );
589    }
590
591    #[test]
592    fn classify_loopback() {
593        assert_eq!(
594            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
595            IpClass::Loopback
596        );
597        assert_eq!(
598            SsrfPolicy::classify(IpAddr::V6(Ipv6Addr::LOCALHOST)),
599            IpClass::Loopback
600        );
601    }
602
603    #[test]
604    fn classify_public() {
605        assert_eq!(
606            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))),
607            IpClass::Public
608        );
609        assert_eq!(
610            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))),
611            IpClass::Public
612        );
613    }
614
615    #[test]
616    fn classify_cloud_metadata() {
617        assert_eq!(
618            SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))),
619            IpClass::Reserved
620        );
621    }
622
623    #[test]
624    fn classify_ipv6_link_local() {
625        assert_eq!(
626            SsrfPolicy::classify(IpAddr::V6("fe80::1".parse().unwrap())),
627            IpClass::LinkLocal
628        );
629    }
630
631    #[test]
632    fn classify_ipv6_ula() {
633        assert_eq!(
634            SsrfPolicy::classify(IpAddr::V6("fc00::1".parse().unwrap())),
635            IpClass::Private
636        );
637        assert_eq!(
638            SsrfPolicy::classify(IpAddr::V6("fd12:3456:789a::1".parse().unwrap())),
639            IpClass::Private
640        );
641    }
642
643    #[test]
644    fn classify_ipv6_public() {
645        assert_eq!(
646            SsrfPolicy::classify(IpAddr::V6("2001:4860:4860::8888".parse().unwrap())),
647            IpClass::Public
648        );
649    }
650
651    // ----- R2-P0-04: IPv4-compatible IPv6 bypass ----------------------------
652
653    #[test]
654    fn classify_ipv4_compatible_loopback() {
655        // ::127.0.0.1 — deprecated IPv4-compatible form of loopback
656        let addr: Ipv6Addr = "::127.0.0.1".parse().unwrap();
657        assert_eq!(
658            SsrfPolicy::classify(IpAddr::V6(addr)),
659            IpClass::Loopback,
660            "::127.0.0.1 must be classified as Loopback"
661        );
662    }
663
664    #[test]
665    fn classify_ipv4_compatible_private_10() {
666        // ::10.0.0.1 — deprecated IPv4-compatible form of RFC 1918
667        let addr: Ipv6Addr = "::10.0.0.1".parse().unwrap();
668        assert_eq!(
669            SsrfPolicy::classify(IpAddr::V6(addr)),
670            IpClass::Private,
671            "::10.0.0.1 must be classified as Private"
672        );
673    }
674
675    #[test]
676    fn classify_ipv4_compatible_link_local_metadata() {
677        // ::169.254.169.254 — deprecated IPv4-compatible cloud metadata
678        let addr: Ipv6Addr = "::169.254.169.254".parse().unwrap();
679        assert_eq!(
680            SsrfPolicy::classify(IpAddr::V6(addr)),
681            IpClass::Reserved,
682            "::169.254.169.254 must be classified as Reserved (cloud metadata)"
683        );
684    }
685
686    #[test]
687    fn classify_ipv4_compatible_public() {
688        // ::8.8.8.8 — public address in IPv4-compatible form
689        let addr: Ipv6Addr = "::8.8.8.8".parse().unwrap();
690        assert_eq!(
691            SsrfPolicy::classify(IpAddr::V6(addr)),
692            IpClass::Public,
693            "::8.8.8.8 should be classified as Public"
694        );
695    }
696
697    #[test]
698    fn classify_6to4_private() {
699        // 2002:c0a8:0101:: embeds 192.168.1.1
700        let addr: Ipv6Addr = "2002:c0a8:0101::".parse().unwrap();
701        assert_eq!(
702            SsrfPolicy::classify(IpAddr::V6(addr)),
703            IpClass::Private,
704            "2002:c0a8:0101:: (6to4 embedding 192.168.1.1) must be Private"
705        );
706    }
707
708    #[test]
709    fn classify_6to4_loopback() {
710        // 2002:7f00:0001:: embeds 127.0.0.1
711        let addr: Ipv6Addr = "2002:7f00:0001::".parse().unwrap();
712        assert_eq!(
713            SsrfPolicy::classify(IpAddr::V6(addr)),
714            IpClass::Loopback,
715            "2002:7f00:0001:: (6to4 embedding 127.0.0.1) must be Loopback"
716        );
717    }
718
719    #[test]
720    fn classify_6to4_metadata() {
721        // 2002:a9fe:a9fe:: embeds 169.254.169.254
722        let addr: Ipv6Addr = "2002:a9fe:a9fe::".parse().unwrap();
723        assert_eq!(
724            SsrfPolicy::classify(IpAddr::V6(addr)),
725            IpClass::Reserved,
726            "2002:a9fe:a9fe:: (6to4 embedding 169.254.169.254) must be Reserved"
727        );
728    }
729
730    #[test]
731    fn classify_6to4_public() {
732        // 2002:0808:0808:: embeds 8.8.8.8
733        let addr: Ipv6Addr = "2002:0808:0808::".parse().unwrap();
734        assert_eq!(
735            SsrfPolicy::classify(IpAddr::V6(addr)),
736            IpClass::Public,
737            "2002:0808:0808:: (6to4 embedding 8.8.8.8) should be Public"
738        );
739    }
740
741    #[test]
742    fn blocks_ipv4_compatible_in_url() {
743        // Verify the URL-level check catches the IPv4-compatible bypass
744        assert_blocked("http://[::127.0.0.1]/", IpClass::Loopback);
745        assert_blocked("http://[::10.0.0.1]/", IpClass::Private);
746        assert_blocked("http://[::169.254.169.254]/", IpClass::Reserved);
747    }
748
749    #[test]
750    fn blocks_6to4_in_url() {
751        assert_blocked("http://[2002:c0a8:0101::]/", IpClass::Private);
752        assert_blocked("http://[2002:7f00:0001::]/", IpClass::Loopback);
753        assert_blocked("http://[2002:a9fe:a9fe::]/", IpClass::Reserved);
754    }
755
756    #[test]
757    fn default_policy_blocks_private() {
758        let p = SsrfPolicy::new();
759        assert!(!p.allow_private);
760        assert!(!p.allow_loopback);
761        assert!(!p.allow_link_local);
762    }
763
764    // ----- Sprint 9 row 114: free-function primitives -------------------
765
766    fn assert_blocked(url: &str, want_class: IpClass) {
767        match is_safe_url(url) {
768            Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(
769                class, want_class,
770                "url {url} blocked with {class:?}, wanted {want_class:?}"
771            ),
772            Err(other) => panic!("url {url} rejected with unexpected error: {other}"),
773            Ok(()) => panic!("url {url} accepted but expected block for {want_class:?}"),
774        }
775    }
776
777    #[test]
778    fn blocks_rfc1918_addresses() {
779        let cases = [
780            "http://10.0.0.1/",
781            "http://10.255.255.255/",
782            "http://172.16.0.1/",
783            "http://172.31.255.255/",
784            "http://192.168.0.1/",
785            "http://192.168.255.255/",
786            "http://[fc00::1]/",
787            "http://[fd00::1]/",
788        ];
789        for url in cases {
790            assert_blocked(url, IpClass::Private);
791        }
792    }
793
794    #[test]
795    fn blocks_loopback() {
796        assert_blocked("http://127.0.0.1/", IpClass::Loopback);
797        assert_blocked("http://127.255.255.254/", IpClass::Loopback);
798        assert_blocked("http://[::1]/", IpClass::Loopback);
799    }
800
801    #[test]
802    fn blocks_link_local() {
803        assert_blocked("http://169.254.1.1/", IpClass::LinkLocal);
804        assert_blocked("http://169.254.254.254/", IpClass::LinkLocal);
805        assert_blocked("http://[fe80::1]/", IpClass::LinkLocal);
806    }
807
808    #[test]
809    fn blocks_aws_metadata_ip() {
810        // AWS/Azure/GCP all share the 169.254.169.254 literal.
811        assert_blocked("http://169.254.169.254/latest/meta-data/", IpClass::Reserved);
812        assert_blocked("http://[fd00:ec2::254]/latest/meta-data/", IpClass::Reserved);
813    }
814
815    #[tokio::test]
816    async fn blocks_aws_metadata_hostname() {
817        assert_blocked(
818            "http://metadata.google.internal/computeMetadata/v1/",
819            IpClass::Reserved,
820        );
821        match resolve_and_check("metadata.google.internal").await {
822            Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(class, IpClass::Reserved),
823            other => panic!("expected BlockedClass for metadata.google.internal, got {other:?}"),
824        }
825        match resolve_and_check("metadata").await {
826            Err(SsrfError::BlockedClass { class, .. }) => assert_eq!(class, IpClass::Reserved),
827            other => panic!("expected BlockedClass for bare 'metadata', got {other:?}"),
828        }
829    }
830
831    #[test]
832    fn allows_public_ipv4() {
833        assert!(is_safe_url("https://8.8.8.8/").is_ok());
834        assert!(is_safe_url("https://1.1.1.1/").is_ok());
835        assert!(is_safe_url("https://93.184.216.34/").is_ok());
836    }
837
838    #[test]
839    fn allows_public_ipv6() {
840        assert!(is_safe_url("https://[2001:4860:4860::8888]/").is_ok());
841        assert!(is_safe_url("https://[2606:4700:4700::1111]/").is_ok());
842    }
843
844    #[test]
845    fn rejects_malformed_url() {
846        match is_safe_url("not a url") {
847            Err(SsrfError::MissingHost(_)) => {}
848            other => panic!("expected MissingHost for malformed url, got {other:?}"),
849        }
850        match is_safe_url("") {
851            Err(SsrfError::MissingHost(_)) => {}
852            other => panic!("expected MissingHost for empty url, got {other:?}"),
853        }
854    }
855
856    #[test]
857    fn rejects_http_without_host() {
858        match is_safe_url("file:///etc/passwd") {
859            Err(SsrfError::MissingHost(_)) => {}
860            other => panic!("expected MissingHost for file URL, got {other:?}"),
861        }
862    }
863
864    // ----- Sprint 12: DNS resolution failure blocks request ----------------
865    //
866    // JSS commit 4dbf039: when DNS resolution fails the request must be
867    // blocked — never fall through to a permissive default.
868
869    #[tokio::test]
870    async fn dns_failure_blocks_request() {
871        // Use an unresolvable hostname (RFC 6761 §6.4: `.invalid` is
872        // guaranteed to never resolve).
873        let result = resolve_and_check("this-host-does-not-exist.invalid").await;
874        match result {
875            Err(SsrfError::DnsFailure { host, .. }) => {
876                assert_eq!(host, "this-host-does-not-exist.invalid");
877            }
878            Err(SsrfError::NoAddresses { host, .. }) => {
879                // Some resolvers return empty results instead of an
880                // error — both are acceptable block outcomes.
881                assert_eq!(host, "this-host-does-not-exist.invalid");
882            }
883            Err(other) => panic!(
884                "expected DnsFailure or NoAddresses for unresolvable host, got {other:?}"
885            ),
886            Ok(ip) => panic!(
887                "expected DNS failure for unresolvable host, got Ok({ip})"
888            ),
889        }
890    }
891
892    #[tokio::test]
893    async fn policy_dns_failure_blocks_request() {
894        // Same test through the SsrfPolicy aggregate.
895        let policy = SsrfPolicy::new();
896        let url = Url::parse("https://this-host-does-not-exist.invalid/resource")
897            .expect("valid URL");
898        let result = policy.resolve_and_check(&url).await;
899        match result {
900            Err(SsrfError::DnsFailure { host, .. }) => {
901                assert!(host.contains("this-host-does-not-exist.invalid"));
902            }
903            Err(SsrfError::NoAddresses { host, .. }) => {
904                assert!(host.contains("this-host-does-not-exist.invalid"));
905            }
906            Err(other) => panic!(
907                "expected DnsFailure/NoAddresses through policy, got {other:?}"
908            ),
909            Ok(ip) => panic!(
910                "expected DNS failure through policy, got Ok({ip})"
911            ),
912        }
913    }
914}