Skip to main content

mockforge_bench/
ssrf.rs

1//! SSRF (Server-Side Request Forgery) guard for cloud-driven runs.
2//!
3//! When `mockforge-test-runner` (or any other cloud-side caller) accepts a
4//! user-supplied target URL and dispatches HTTP traffic at it, an attacker
5//! can point the runner at internal infrastructure: metadata endpoints
6//! (`169.254.169.254`), localhost services, RFC1918 / ULA private ranges,
7//! and so on. Without a guard, we hand them a free internal-network
8//! scanner running inside our Fly.io org.
9//!
10//! [`validate_target_url`] is the single chokepoint. It:
11//!
12//! 1. Parses the URL and rejects anything that isn't `http://` or
13//!    `https://` (no `file://`, `gopher://`, etc.).
14//! 2. Resolves the hostname via DNS (asynchronously) and rejects if any
15//!    resolved IP is in a blocked range.
16//! 3. Treats literal-IP hostnames (`http://10.0.0.1/`) the same way —
17//!    parsing them as `IpAddr` directly so DNS resolution isn't needed.
18//!
19//! Blocked ranges:
20//!
21//! * IPv4: loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16` —
22//!   includes the AWS/GCP/Fly metadata IP `169.254.169.254`), unspecified
23//!   (`0.0.0.0/8`), broadcast, RFC1918 private (`10/8`, `172.16/12`,
24//!   `192.168/16`), CGNAT (`100.64.0.0/10`), benchmark (`198.18/15`).
25//! * IPv6: loopback (`::1`), unspecified (`::`), link-local (`fe80::/10`),
26//!   ULA (`fc00::/7`), IPv4-mapped (`::ffff:0:0/96` — caller could smuggle
27//!   in a private v4 address).
28//!
29//! There is no escape hatch — production callers MUST ensure their target
30//! is publicly reachable. Tests can override with the `loopback-ok` env
31//! var (see [`Policy::for_test`]) for integration-test endpoints on
32//! `127.0.0.1`.
33
34use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
35use std::sync::Arc;
36
37use thiserror::Error;
38
39/// Reasons a target URL can be rejected by [`validate_target_url`].
40#[derive(Debug, Error)]
41pub enum SsrfError {
42    #[error("invalid URL: {0}")]
43    InvalidUrl(String),
44
45    #[error("URL scheme '{0}' not allowed — only http/https")]
46    DisallowedScheme(String),
47
48    #[error("URL has no host component")]
49    MissingHost,
50
51    #[error("DNS resolution failed for '{host}': {source}")]
52    DnsResolutionFailed {
53        host: String,
54        #[source]
55        source: std::io::Error,
56    },
57
58    #[error("DNS resolution returned no addresses for '{0}'")]
59    NoAddressesResolved(String),
60
61    #[error(
62        "target '{host}' resolves to {ip} which is in a blocked range ({reason}); \
63         pointing the cloud runner at internal addresses is not allowed"
64    )]
65    BlockedAddress {
66        host: String,
67        ip: IpAddr,
68        reason: &'static str,
69    },
70}
71
72/// Knobs for [`validate_target_url`]. Default policy is the strict
73/// production one; tests can relax it via [`Policy::allow_loopback`].
74#[derive(Debug, Clone, Copy, Default)]
75pub struct Policy {
76    /// When true, addresses in the IPv4/IPv6 loopback range are allowed.
77    /// Production callers should leave this `false`. Tests against
78    /// `127.0.0.1` set it via [`Policy::for_test`].
79    pub allow_loopback: bool,
80}
81
82impl Policy {
83    /// Strict production policy: nothing private, nothing local.
84    pub const fn strict() -> Self {
85        Self {
86            allow_loopback: false,
87        }
88    }
89
90    /// Test policy: allows loopback so integration tests against
91    /// `127.0.0.1:<port>` mock servers can run.
92    pub const fn for_test() -> Self {
93        Self {
94            allow_loopback: true,
95        }
96    }
97}
98
99/// Validate that a URL is safe for a cloud runner to hit. Returns `Ok(())`
100/// when the target is a publicly-routable HTTP/S endpoint.
101///
102/// Performs DNS resolution, so this is async. Cache results at the call
103/// site if the same URL is validated repeatedly within one request.
104pub async fn validate_target_url(url: &str, policy: Policy) -> Result<(), SsrfError> {
105    let parsed = url::Url::parse(url).map_err(|e| SsrfError::InvalidUrl(e.to_string()))?;
106
107    let scheme = parsed.scheme();
108    if scheme != "http" && scheme != "https" {
109        return Err(SsrfError::DisallowedScheme(scheme.to_string()));
110    }
111
112    let host = parsed.host_str().ok_or(SsrfError::MissingHost)?.to_string();
113    let port = parsed.port_or_known_default().unwrap_or(80);
114
115    // Literal IP — no DNS needed.
116    if let Ok(ip) = host.parse::<IpAddr>() {
117        check_ip(&host, ip, policy)?;
118        return Ok(());
119    }
120
121    // Hostname — resolve and check every resolved address. An attacker
122    // can register a public DNS name pointing at 169.254.169.254 (a.k.a.
123    // "DNS rebinding" / "0.0.0.0 day"), so we MUST inspect resolved IPs,
124    // not just the literal-IP form.
125    let lookup_target = format!("{}:{}", host, port);
126    let addrs: Vec<SocketAddr> = tokio::net::lookup_host(&lookup_target)
127        .await
128        .map_err(|source| SsrfError::DnsResolutionFailed {
129            host: host.clone(),
130            source,
131        })?
132        .collect();
133
134    if addrs.is_empty() {
135        return Err(SsrfError::NoAddressesResolved(host));
136    }
137
138    for addr in addrs {
139        check_ip(&host, addr.ip(), policy)?;
140    }
141
142    Ok(())
143}
144
145/// Check a URL without DNS: the scheme must be http/https, and a literal-IP
146/// host must not be in a blocked range. Hostnames pass here; a client from
147/// [`guarded_client_builder`] checks what they resolve to at connect time.
148///
149/// reqwest never calls the DNS resolver for a literal IP, so this is the half
150/// of the guard the resolver cannot do. Run it on every URL handed to a
151/// guarded client; the redirect policy runs it on every hop.
152pub fn check_url(url: &url::Url, policy: Policy) -> Result<(), SsrfError> {
153    let scheme = url.scheme();
154    if scheme != "http" && scheme != "https" {
155        return Err(SsrfError::DisallowedScheme(scheme.to_string()));
156    }
157    match url.host() {
158        None => Err(SsrfError::MissingHost),
159        Some(url::Host::Ipv4(ip)) => check_ip(&ip.to_string(), IpAddr::V4(ip), policy),
160        Some(url::Host::Ipv6(ip)) => check_ip(&ip.to_string(), IpAddr::V6(ip), policy),
161        Some(url::Host::Domain(_)) => Ok(()),
162    }
163}
164
165static PROCESS_GUARD: std::sync::OnceLock<Policy> = std::sync::OnceLock::new();
166
167/// Make every outbound client this crate builds for a target (native
168/// conformance, the pre-flight latency probe) enforce `policy`, and refuse
169/// to start k6 without an egress proxy (see
170/// [`crate::executor::install_k6_egress_proxy`]).
171///
172/// The cloud runner calls this once at startup. The `mockforge bench` CLI
173/// never does, because pointing it at `localhost` is its normal use.
174/// Returns `false` if a guard was already installed.
175pub fn install_process_guard(policy: Policy) -> bool {
176    PROCESS_GUARD.set(policy).is_ok()
177}
178
179/// The policy installed by [`install_process_guard`], if any.
180pub fn process_guard() -> Option<Policy> {
181    PROCESS_GUARD.get().copied()
182}
183
184/// [`guarded_client_builder`] when `guard` is set, else a plain builder.
185/// For crate code that serves both the CLI (unguarded) and the cloud runner.
186pub fn client_builder_for(guard: Option<Policy>) -> reqwest::ClientBuilder {
187    match guard {
188        Some(policy) => guarded_client_builder(policy),
189        None => reqwest::Client::builder(),
190    }
191}
192
193/// reqwest DNS resolver that fails the lookup when any resolved address is in
194/// a blocked range. Checking at connect time, on the addresses the connection
195/// will actually use, closes the DNS-rebinding gap that a separate
196/// [`validate_target_url`] pre-check leaves open.
197#[derive(Debug, Clone, Copy)]
198pub struct GuardedResolver {
199    policy: Policy,
200}
201
202impl GuardedResolver {
203    /// Resolver enforcing `policy`.
204    pub const fn new(policy: Policy) -> Self {
205        Self { policy }
206    }
207}
208
209impl reqwest::dns::Resolve for GuardedResolver {
210    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
211        let policy = self.policy;
212        let host = name.as_str().to_string();
213        Box::pin(async move {
214            let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0))
215                .await
216                .map_err(|source| SsrfError::DnsResolutionFailed {
217                    host: host.clone(),
218                    source,
219                })?
220                .collect();
221            if addrs.is_empty() {
222                return Err(SsrfError::NoAddressesResolved(host).into());
223            }
224            for addr in &addrs {
225                check_ip(&host, addr.ip(), policy)?;
226            }
227            let addrs: reqwest::dns::Addrs = Box::new(addrs.into_iter());
228            Ok(addrs)
229        })
230    }
231}
232
233/// A reqwest redirect policy that follows at most `max` redirects and runs
234/// [`check_url`] on every hop, so a public target cannot bounce the client to
235/// `http://10.0.0.1/`.
236pub fn guarded_redirect_policy(policy: Policy, max: usize) -> reqwest::redirect::Policy {
237    reqwest::redirect::Policy::custom(move |attempt| {
238        if attempt.previous().len() >= max {
239            return attempt.error(format!("more than {max} redirects"));
240        }
241        match check_url(attempt.url(), policy) {
242            Ok(()) => attempt.follow(),
243            Err(e) => attempt.error(e),
244        }
245    })
246}
247
248/// A reqwest client builder whose connections can only reach addresses the
249/// policy allows: the [`GuardedResolver`], a [`guarded_redirect_policy`] of 10
250/// hops, and no proxy (a proxy would resolve the target itself, out of the
251/// resolver's sight). Callers must still run [`check_url`] on each URL for the
252/// literal-IP case, and must not replace the resolver, redirect policy, or
253/// proxy setting on the returned builder.
254pub fn guarded_client_builder(policy: Policy) -> reqwest::ClientBuilder {
255    reqwest::Client::builder()
256        .no_proxy()
257        .dns_resolver(Arc::new(GuardedResolver::new(policy)))
258        .redirect(guarded_redirect_policy(policy, 10))
259}
260
261fn check_ip(host: &str, ip: IpAddr, policy: Policy) -> Result<(), SsrfError> {
262    if let Some(reason) = blocked_reason(ip, policy) {
263        return Err(SsrfError::BlockedAddress {
264            host: host.to_string(),
265            ip,
266            reason,
267        });
268    }
269    Ok(())
270}
271
272fn blocked_reason(ip: IpAddr, policy: Policy) -> Option<&'static str> {
273    match ip {
274        IpAddr::V4(v4) => blocked_reason_v4(v4, policy),
275        IpAddr::V6(v6) => blocked_reason_v6(v6, policy),
276    }
277}
278
279fn blocked_reason_v4(ip: Ipv4Addr, policy: Policy) -> Option<&'static str> {
280    if ip.is_loopback() {
281        if policy.allow_loopback {
282            return None;
283        }
284        return Some("IPv4 loopback (127.0.0.0/8)");
285    }
286    if ip.octets()[0] == 0 {
287        return Some("IPv4 \"this network\" (0.0.0.0/8)");
288    }
289    if ip.is_broadcast() {
290        return Some("IPv4 broadcast");
291    }
292    if ip.is_link_local() {
293        return Some("IPv4 link-local (169.254.0.0/16, includes cloud metadata IP)");
294    }
295    if ip.is_private() {
296        return Some("IPv4 RFC1918 private (10/8, 172.16/12, 192.168/16)");
297    }
298    if ip.is_documentation() {
299        return Some("IPv4 documentation range (RFC5737)");
300    }
301    // CGNAT range (100.64.0.0/10) — not is_private but still
302    // not-publicly-routable. Cloud providers sometimes use it for inter-VM
303    // links, which is exactly the kind of thing we don't want to expose.
304    let octets = ip.octets();
305    if octets[0] == 100 && (64..=127).contains(&octets[1]) {
306        return Some("IPv4 CGNAT (100.64.0.0/10)");
307    }
308    // Benchmark range 198.18.0.0/15 (RFC2544).
309    if octets[0] == 198 && (octets[1] == 18 || octets[1] == 19) {
310        return Some("IPv4 benchmark (198.18.0.0/15)");
311    }
312    None
313}
314
315fn blocked_reason_v6(ip: Ipv6Addr, policy: Policy) -> Option<&'static str> {
316    if ip.is_loopback() {
317        if policy.allow_loopback {
318            return None;
319        }
320        return Some("IPv6 loopback (::1)");
321    }
322    if ip.is_unspecified() {
323        return Some("IPv6 unspecified (::)");
324    }
325    let segments = ip.segments();
326    // Link-local fe80::/10
327    if (segments[0] & 0xffc0) == 0xfe80 {
328        return Some("IPv6 link-local (fe80::/10)");
329    }
330    // ULA fc00::/7
331    if (segments[0] & 0xfe00) == 0xfc00 {
332        return Some("IPv6 unique-local (fc00::/7)");
333    }
334    // IPv4-mapped ::ffff:0:0/96 — recurse so an attacker can't smuggle a
335    // private v4 through the v6 form (`http://[::ffff:10.0.0.1]/`).
336    if let Some(v4) = ip.to_ipv4_mapped() {
337        return blocked_reason_v4(v4, policy);
338    }
339    let [a, b, c, d, e, f, g, h] = segments;
340    let embedded = Ipv4Addr::new((g >> 8) as u8, g as u8, (h >> 8) as u8, h as u8);
341    // NAT64 well-known prefix 64:ff9b::/96 (RFC 6052): a DNS64 resolver
342    // synthesizes these for v4-only names, and the NAT64 gateway forwards
343    // them to the embedded v4 address, private or not.
344    if [a, b, c, d, e, f] == [0x64, 0xff9b, 0, 0, 0, 0] {
345        return blocked_reason_v4(embedded, policy);
346    }
347    // NAT64 local-use prefix 64:ff9b:1::/48 (RFC 8215). The embedding
348    // position depends on the operator's prefix length, so block it whole.
349    if [a, b, c] == [0x64, 0xff9b, 1] {
350        return Some("IPv6 NAT64 local-use (64:ff9b:1::/48)");
351    }
352    // IPv4-compatible ::a.b.c.d (deprecated, RFC 4291). :: and ::1 were
353    // handled above.
354    if [a, b, c, d, e, f] == [0; 6] {
355        return blocked_reason_v4(embedded, policy);
356    }
357    None
358}
359
360/// Tiny HTTP servers for SSRF tests across this crate.
361#[cfg(test)]
362pub(crate) mod test_server {
363    use std::sync::atomic::{AtomicUsize, Ordering};
364    use std::sync::Arc;
365
366    pub(crate) const OK: &str =
367        "HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok";
368
369    /// A 302 to `location`.
370    pub(crate) fn redirect_to(location: &str) -> &'static str {
371        Box::leak(
372            format!(
373                "HTTP/1.1 302 Found\r\nlocation: {location}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
374            )
375            .into_boxed_str(),
376        )
377    }
378
379    /// Serve `response` to every connection on 127.0.0.1. Returns the port
380    /// and a count of connections that sent a request.
381    pub(crate) async fn serve(response: &'static str) -> (u16, Arc<AtomicUsize>) {
382        use tokio::io::{AsyncReadExt, AsyncWriteExt};
383        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
384        let port = listener.local_addr().unwrap().port();
385        let hits = Arc::new(AtomicUsize::new(0));
386        let counter = hits.clone();
387        tokio::spawn(async move {
388            while let Ok((mut sock, _)) = listener.accept().await {
389                let mut buf = [0u8; 1024];
390                if matches!(sock.read(&mut buf).await, Ok(n) if n > 0) {
391                    counter.fetch_add(1, Ordering::SeqCst);
392                }
393                let _ = sock.write_all(response.as_bytes()).await;
394            }
395        });
396        (port, hits)
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn assert_blocked(addr: &str, policy: Policy, fragment: &str) {
405        let ip: IpAddr = addr.parse().unwrap();
406        let reason =
407            blocked_reason(ip, policy).unwrap_or_else(|| panic!("expected {addr} to be blocked"));
408        assert!(
409            reason.contains(fragment),
410            "{addr} blocked but reason '{reason}' missing fragment '{fragment}'"
411        );
412    }
413
414    fn assert_allowed(addr: &str, policy: Policy) {
415        let ip: IpAddr = addr.parse().unwrap();
416        assert!(blocked_reason(ip, policy).is_none(), "{addr} unexpectedly blocked");
417    }
418
419    #[test]
420    fn blocks_loopback_v4_strict() {
421        assert_blocked("127.0.0.1", Policy::strict(), "loopback");
422        assert_blocked("127.255.255.254", Policy::strict(), "loopback");
423    }
424
425    #[test]
426    fn allows_loopback_v4_in_test_policy() {
427        assert_allowed("127.0.0.1", Policy::for_test());
428    }
429
430    #[test]
431    fn blocks_link_local_aws_metadata() {
432        assert_blocked("169.254.169.254", Policy::strict(), "link-local");
433    }
434
435    #[test]
436    fn blocks_rfc1918_ranges() {
437        assert_blocked("10.0.0.1", Policy::strict(), "RFC1918");
438        assert_blocked("172.16.0.1", Policy::strict(), "RFC1918");
439        assert_blocked("172.31.255.255", Policy::strict(), "RFC1918");
440        assert_blocked("192.168.0.1", Policy::strict(), "RFC1918");
441    }
442
443    #[test]
444    fn blocks_cgnat() {
445        assert_blocked("100.64.0.1", Policy::strict(), "CGNAT");
446        assert_blocked("100.127.255.255", Policy::strict(), "CGNAT");
447    }
448
449    #[test]
450    fn allows_ranges_outside_cgnat() {
451        // 100.0.0.0/8 outside the CGNAT slice is publicly routable.
452        assert_allowed("100.63.255.255", Policy::strict());
453        assert_allowed("100.128.0.1", Policy::strict());
454    }
455
456    #[test]
457    fn blocks_benchmark_range() {
458        assert_blocked("198.18.0.1", Policy::strict(), "benchmark");
459        assert_blocked("198.19.255.255", Policy::strict(), "benchmark");
460    }
461
462    #[test]
463    fn allows_public_v4() {
464        assert_allowed("8.8.8.8", Policy::strict());
465        assert_allowed("1.1.1.1", Policy::strict());
466        assert_allowed("142.250.190.78", Policy::strict()); // google.com
467    }
468
469    #[test]
470    fn blocks_loopback_v6_strict() {
471        assert_blocked("::1", Policy::strict(), "loopback");
472    }
473
474    #[test]
475    fn blocks_link_local_v6() {
476        assert_blocked("fe80::1", Policy::strict(), "link-local");
477        assert_blocked("febf::1", Policy::strict(), "link-local");
478    }
479
480    #[test]
481    fn blocks_ula() {
482        assert_blocked("fc00::1", Policy::strict(), "unique-local");
483        assert_blocked("fd12:3456::1", Policy::strict(), "unique-local");
484    }
485
486    #[test]
487    fn blocks_ipv4_mapped_private() {
488        assert_blocked("::ffff:10.0.0.1", Policy::strict(), "RFC1918");
489        assert_blocked("::ffff:127.0.0.1", Policy::strict(), "loopback");
490    }
491
492    #[test]
493    fn allows_public_v6() {
494        assert_allowed("2606:4700:4700::1111", Policy::strict()); // cloudflare
495        assert_allowed("2001:4860:4860::8888", Policy::strict()); // google
496    }
497
498    #[tokio::test]
499    async fn validate_rejects_non_http_scheme() {
500        let err = validate_target_url("file:///etc/passwd", Policy::strict()).await.unwrap_err();
501        assert!(matches!(err, SsrfError::DisallowedScheme(s) if s == "file"));
502    }
503
504    #[tokio::test]
505    async fn validate_rejects_garbage_url() {
506        let err = validate_target_url("not a url", Policy::strict()).await.unwrap_err();
507        assert!(matches!(err, SsrfError::InvalidUrl(_)));
508    }
509
510    #[tokio::test]
511    async fn validate_rejects_literal_loopback() {
512        let err = validate_target_url("http://127.0.0.1/", Policy::strict()).await.unwrap_err();
513        assert!(matches!(err, SsrfError::BlockedAddress { .. }));
514    }
515
516    #[tokio::test]
517    async fn validate_rejects_literal_metadata_ip() {
518        let err = validate_target_url("http://169.254.169.254/latest/meta-data/", Policy::strict())
519            .await
520            .unwrap_err();
521        match err {
522            SsrfError::BlockedAddress { reason, .. } => assert!(reason.contains("link-local")),
523            other => panic!("expected BlockedAddress, got {other:?}"),
524        }
525    }
526
527    #[tokio::test]
528    async fn validate_rejects_literal_rfc1918() {
529        let err = validate_target_url("http://10.0.0.1/", Policy::strict()).await.unwrap_err();
530        assert!(matches!(err, SsrfError::BlockedAddress { .. }));
531    }
532
533    #[tokio::test]
534    async fn validate_allows_loopback_in_test_policy() {
535        validate_target_url("http://127.0.0.1:8080/", Policy::for_test()).await.unwrap();
536    }
537
538    fn url(s: &str) -> url::Url {
539        url::Url::parse(s).unwrap()
540    }
541
542    #[test]
543    fn check_url_blocks_literal_ips_in_every_spelling() {
544        for u in [
545            "http://127.0.0.1/",
546            "http://2130706433/",
547            "http://0x7f000001/",
548            "http://10.0.0.1:6379/",
549            "http://[::1]/",
550            "http://[::ffff:172.18.0.2]/",
551            "http://169.254.169.254/latest/meta-data/",
552            "http://example.com@10.0.0.1/",
553        ] {
554            let err = check_url(&url(u), Policy::strict()).unwrap_err();
555            assert!(matches!(err, SsrfError::BlockedAddress { .. }), "{u}: {err:?}");
556        }
557    }
558
559    #[test]
560    fn check_url_rejects_other_schemes_and_defers_hostnames_to_the_resolver() {
561        let err = check_url(&url("file:///etc/passwd"), Policy::strict()).unwrap_err();
562        assert!(matches!(err, SsrfError::DisallowedScheme(_)));
563        check_url(&url("https://example.com/"), Policy::strict()).unwrap();
564        check_url(&url("http://127.0.0.1:3000/"), Policy::for_test()).unwrap();
565    }
566
567    async fn serve(response: &'static str) -> u16 {
568        test_server::serve(response).await.0
569    }
570
571    use test_server::OK;
572
573    #[tokio::test]
574    async fn guarded_client_refuses_a_name_resolving_to_loopback() {
575        let port = serve(OK).await;
576        let client = guarded_client_builder(Policy::strict()).build().unwrap();
577        let err = client.get(format!("http://localhost:{port}/")).send().await.unwrap_err();
578        assert!(err.is_connect(), "{err:?}");
579
580        let relaxed = guarded_client_builder(Policy::for_test()).build().unwrap();
581        let resp = relaxed.get(format!("http://localhost:{port}/")).send().await.unwrap();
582        assert_eq!(resp.status(), 200);
583    }
584
585    #[tokio::test]
586    async fn guarded_client_refuses_a_redirect_to_a_private_address() {
587        use std::sync::atomic::Ordering;
588        let (inner, hits) = test_server::serve(OK).await;
589        for location in [
590            "http://10.0.0.1/".to_string(),
591            format!("http://0.0.0.0:{inner}/"),
592        ] {
593            let port = serve(test_server::redirect_to(&location)).await;
594            // Loopback allowed so the first hop connects; the hop never is.
595            let client = guarded_client_builder(Policy::for_test()).build().unwrap();
596            let err = client.get(format!("http://127.0.0.1:{port}/")).send().await.unwrap_err();
597            assert!(err.is_redirect(), "{location}: {err:?}");
598        }
599        // 0.0.0.0 reaches the local listener, so this proves the hop was not
600        // followed rather than that it merely failed to connect.
601        assert_eq!(hits.load(Ordering::SeqCst), 0);
602    }
603
604    #[test]
605    fn embedded_v4_in_v6_is_checked() {
606        for addr in [
607            "64:ff9b::a00:1",     // NAT64 of 10.0.0.1
608            "64:ff9b::7f00:1",    // NAT64 of 127.0.0.1
609            "64:ff9b::a9fe:a9fe", // NAT64 of 169.254.169.254
610            "64:ff9b:1::a00:1",   // NAT64 local-use
611            "::a00:1",            // IPv4-compatible 10.0.0.1
612            "::ac12:5",           // IPv4-compatible 172.18.0.5
613            "::7f00:1",           // IPv4-compatible 127.0.0.1
614        ] {
615            let ip: IpAddr = addr.parse().unwrap();
616            assert!(blocked_reason(ip, Policy::strict()).is_some(), "{addr} allowed");
617        }
618        for addr in ["64:ff9b::808:808", "::808:808", "2606:4700::1"] {
619            let ip: IpAddr = addr.parse().unwrap();
620            assert!(blocked_reason(ip, Policy::strict()).is_none(), "{addr} blocked");
621        }
622        let err = check_url(&url("http://[64:ff9b::a00:1]/"), Policy::strict()).unwrap_err();
623        assert!(matches!(err, SsrfError::BlockedAddress { .. }));
624    }
625
626    #[test]
627    fn this_network_range_is_blocked() {
628        for addr in ["0.0.0.0", "0.1.2.3"] {
629            let ip: IpAddr = addr.parse().unwrap();
630            assert!(blocked_reason(ip, Policy::strict()).is_some(), "{addr} allowed");
631        }
632    }
633}