Skip to main content

leviath_core/
net.rs

1//! Outbound-request policy: which URLs an agent-driven fetch may reach.
2//!
3//! Agents fetch URLs the model chose, and the model chose them from context an
4//! attacker can influence - search results, a page fetched a moment ago, a task
5//! description pasted from an issue. Handing such a URL straight to an HTTP
6//! client turns the agent into a confused deputy sitting *inside* the user's
7//! network: `http://169.254.169.254/latest/meta-data/iam/security-credentials/`
8//! returns cloud credentials, `http://127.0.0.1:3000/api/agents` is the user's
9//! own Leviath API, and `http://192.168.1.1/` is their router.
10//!
11//! [`check_url`] is the gate. It runs before the request and again on every
12//! redirect hop, because a public URL that answers `302 Location:
13//! http://169.254.169.254/` reaches exactly the same place.
14//!
15//! ## What this does not cover
16//!
17//! A hostname is resolved here and then resolved *again* by the HTTP client when
18//! it connects. A DNS entry with a very short TTL that answers publicly the first
19//! time and privately the second slips through that window - classic DNS
20//! rebinding. Closing it needs a custom connector that dials the exact address
21//! this module approved, which the shared blocking client cannot express today.
22//! The check still stops every direct attempt (literal IPs, hostnames that
23//! resolve privately, and redirects), which is the whole of the reachable
24//! surface for a model that is picking URLs rather than running an attack.
25
26use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs};
27use std::time::Duration;
28
29/// The floor every outbound HTTP client in the workspace gets.
30///
31/// Two clients were built with a bare `reqwest::Client::new()` and therefore had
32/// **no timeouts at all**: webhook delivery (so an endpoint that accepts a
33/// connection and never answers hung that delivery forever) and the package
34/// registry. `Client::new()` is an easy default to reach for and a bad one for
35/// anything talking to a host we do not control.
36///
37/// Values are deliberately generous - this is a floor to stop a hang, not a
38/// performance budget. A caller with a real reason for different numbers builds
39/// its own client and says why, as the provider and MCP transports do.
40#[derive(Debug, Clone, Copy)]
41pub struct ClientTimeouts {
42    /// Cap on establishing the TCP+TLS connection.
43    pub connect: Duration,
44    /// Cap on the whole request/response.
45    pub total: Duration,
46}
47
48impl Default for ClientTimeouts {
49    fn default() -> Self {
50        Self {
51            connect: Duration::from_secs(10),
52            total: Duration::from_secs(60),
53        }
54    }
55}
56
57/// A `reqwest` client builder carrying the shared timeout floor and a redirect
58/// cap.
59///
60/// The redirect cap matters independently of timeouts: reqwest follows up to ten
61/// hops by default, and every hop is a fresh destination that the caller's
62/// original URL check never saw.
63pub fn client_builder(timeouts: ClientTimeouts) -> reqwest::ClientBuilder {
64    reqwest::Client::builder()
65        .connect_timeout(timeouts.connect)
66        .timeout(timeouts.total)
67        .redirect(reqwest::redirect::Policy::limited(5))
68}
69
70/// A client with the shared floor applied, built.
71///
72/// `.expect`, not a fallback to `Client::new()`: `build()` fails only on TLS
73/// backend initialization, and falling back would silently hand back exactly the
74/// timeout-less client this exists to replace. It also matches what the script
75/// host's shared client already does, and a fallback closure that never runs is
76/// a permanently uncovered region.
77pub fn client(timeouts: ClientTimeouts) -> reqwest::Client {
78    client_builder(timeouts)
79        .build()
80        .expect("building an HTTP client fails only on TLS backend init")
81}
82
83/// A client that re-runs [`check_url`] on every redirect hop.
84///
85/// A hop is a destination the caller's original check never saw: a perfectly
86/// public endpoint answering `307 Location: http://169.254.169.254/…` lands on
87/// the cloud metadata service just the same, and 307/308 preserve the method
88/// *and the body*. Capping the hop count does not help - the first hop is
89/// already somewhere else.
90///
91/// Use this wherever the URL came from outside: a model, a request body, a
92/// config file. [`client`] is for destinations Leviath itself chose.
93/// Whether a redirect hop may be followed, and why not if it may not.
94///
95/// Split out of the policy closure so it can be tested without standing up a
96/// redirecting server: the closure is then only the reqwest plumbing.
97fn redirect_decision(
98    previous_hops: usize,
99    url: &url::Url,
100    allow_local: bool,
101) -> Result<(), String> {
102    if previous_hops >= 5 {
103        return Err("too many redirects".to_string());
104    }
105    check_url(url, allow_local).map_err(|e| format!("refused to follow redirect: {e}"))
106}
107
108pub fn checked_client(timeouts: ClientTimeouts, allow_local: bool) -> reqwest::Client {
109    client_builder(timeouts)
110        .redirect(reqwest::redirect::Policy::custom(
111            move |attempt| match redirect_decision(
112                attempt.previous().len(),
113                attempt.url(),
114                allow_local,
115            ) {
116                Ok(()) => attempt.follow(),
117                Err(e) => attempt.error(e),
118            },
119        ))
120        .build()
121        .expect("building an HTTP client fails only on TLS backend init")
122}
123
124/// Why a URL was refused. Rendered into the tool result the model sees, so it
125/// says what to do differently rather than just failing.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum UrlRejection {
128    /// The scheme is not `http` or `https`.
129    Scheme(String),
130    /// The host did not resolve to any address.
131    Unresolvable(String),
132    /// The host resolved to an address outside the public internet.
133    PrivateAddress {
134        /// The host as written in the URL.
135        host: String,
136        /// The address it resolved to (or was written as).
137        addr: IpAddr,
138    },
139}
140
141impl UrlRejection {
142    /// A stable short name for this refusal.
143    ///
144    /// Exists so a test can `assert_eq!(err.kind(), "private_address")` rather
145    /// than `assert!(matches!(err, ...))` - the `matches!` non-matching arm is a
146    /// region only a *failing* assertion ever reaches, which reads as uncovered
147    /// under the workspace's 100% gate. Useful in its own right for structured
148    /// logging, where the kind is the field worth indexing on.
149    #[must_use]
150    pub fn kind(&self) -> &'static str {
151        match self {
152            Self::Scheme(_) => "scheme",
153            Self::Unresolvable(_) => "unresolvable",
154            Self::PrivateAddress { .. } => "private_address",
155        }
156    }
157}
158
159impl std::fmt::Display for UrlRejection {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        match self {
162            Self::Scheme(s) => write!(
163                f,
164                "scheme '{s}' is not fetchable - only http and https are allowed"
165            ),
166            Self::Unresolvable(h) => write!(f, "host '{h}' did not resolve"),
167            Self::PrivateAddress { host, addr } => write!(
168                f,
169                "'{host}' resolves to {addr}, which is on a loopback, private, or \
170                 link-local network. Fetching it from an agent would reach the \
171                 user's own machine or LAN - including cloud metadata services \
172                 that hand out credentials. Set `[security] allow_local_network = \
173                 true` if this agent is genuinely meant to talk to a local service."
174            ),
175        }
176    }
177}
178
179/// Whether `addr` is outside the public internet, and so off-limits to a fetch
180/// whose URL an agent chose.
181///
182/// Covers loopback, RFC 1918 private, link-local (which includes the
183/// `169.254.169.254` cloud metadata endpoint), CGNAT, unspecified, multicast,
184/// broadcast, and documentation ranges - plus the IPv6 equivalents. IPv4-mapped
185/// IPv6 addresses are unwrapped first, so `::ffff:127.0.0.1` cannot be used to
186/// smuggle a loopback address past a v6 check.
187pub fn is_restricted_addr(addr: IpAddr) -> bool {
188    match addr {
189        IpAddr::V4(v4) => is_restricted_v4(v4),
190        // `::ffff:a.b.c.d` is the same host as `a.b.c.d`; classify it as v4 so
191        // the v4 rules (which are the detailed ones) actually apply.
192        IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
193            Some(v4) => is_restricted_v4(v4),
194            None => is_restricted_v6(v6),
195        },
196    }
197}
198
199fn is_restricted_v4(a: Ipv4Addr) -> bool {
200    let [b0, b1, ..] = a.octets();
201    a.is_loopback()
202        || a.is_private()
203        || a.is_link_local()
204        || a.is_unspecified()
205        || a.is_multicast()
206        || a.is_broadcast()
207        || a.is_documentation()
208        // 100.64.0.0/10 - carrier-grade NAT. `Ipv4Addr::is_shared` is still
209        // unstable, so the range is spelled out.
210        || (b0 == 100 && (64..128).contains(&b1))
211        // 192.0.0.0/24 - IETF protocol assignments.
212        || (b0 == 192 && b1 == 0 && a.octets()[2] == 0)
213        // 240.0.0.0/4 - reserved.
214        || b0 >= 240
215}
216
217fn is_restricted_v6(a: Ipv6Addr) -> bool {
218    let seg0 = a.segments()[0];
219    a.is_loopback()
220        || a.is_unspecified()
221        || a.is_multicast()
222        // fc00::/7 - unique local. `is_unique_local` is unstable.
223        || (seg0 & 0xfe00) == 0xfc00
224        // fe80::/10 - link-local unicast. `is_unicast_link_local` is unstable.
225        || (seg0 & 0xffc0) == 0xfe80
226}
227
228/// Check one URL against the outbound policy.
229///
230/// `allow_local` comes from `[security] allow_local_network`; when set, only the
231/// scheme check applies, so an agent pointed at a self-hosted model or a service
232/// on localhost still works. It is deliberately a machine-wide switch rather
233/// than something a blueprint can assert about itself.
234///
235/// A literal IP in the URL is checked directly; a hostname is resolved and
236/// *every* address it resolves to must pass, so a name with both a public and a
237/// private A record is refused rather than raced.
238pub fn check_url(url: &url::Url, allow_local: bool) -> Result<(), UrlRejection> {
239    check_url_with(url, allow_local, resolve_host)
240}
241
242/// The real resolver: every address `host:port` maps to.
243fn resolve_host(host: &str, port: u16) -> Option<Vec<IpAddr>> {
244    (host, port)
245        .to_socket_addrs()
246        .ok()
247        .map(|addrs| addrs.map(|sa| sa.ip()).collect())
248}
249
250/// Core of [`check_url`] with name resolution injected.
251///
252/// A `fn` pointer, not `impl Fn`, so there is a single monomorphization -
253/// otherwise each caller's closure type gets its own coverage report and every
254/// arm reads as partially covered. The seam exists because the interesting cases
255/// (a name resolving to nothing, a name resolving to a public address, a name
256/// resolving to *both* public and private) cannot be produced from real DNS in a
257/// test without being slow, flaky, or dependent on someone else's zone file.
258pub(crate) fn check_url_with(
259    url: &url::Url,
260    allow_local: bool,
261    resolve: fn(&str, u16) -> Option<Vec<IpAddr>>,
262) -> Result<(), UrlRejection> {
263    match url.scheme() {
264        "http" | "https" => {}
265        other => return Err(UrlRejection::Scheme(other.to_string())),
266    }
267    if allow_local {
268        return Ok(());
269    }
270    // `url::Host`, not `host_str()`: the latter keeps the brackets on an IPv6
271    // literal (`[::1]`), which then fails to parse as an address *and* fails to
272    // resolve - refusing the URL, but as "unresolvable" rather than "loopback",
273    // and only by accident. `Host` hands back the address already parsed.
274    // `.expect`, not a `NoHost` variant: the scheme check above has already
275    // restricted us to http/https, and the URL Standard requires a host for
276    // those "special" schemes - `Url::parse("http://")` fails with "empty host"
277    // rather than producing a hostless URL. A branch for the impossible case
278    // would be a permanently-uncovered one.
279    let host = url
280        .host()
281        .expect("http/https URLs always have a host per the URL Standard");
282
283    // A literal address needs no DNS, and must not get it: going through the
284    // resolver for something already unambiguous only adds a failure mode.
285    let (host_str, literal) = match host {
286        url::Host::Ipv4(v4) => (v4.to_string(), Some(IpAddr::V4(v4))),
287        url::Host::Ipv6(v6) => (v6.to_string(), Some(IpAddr::V6(v6))),
288        url::Host::Domain(d) => (d.to_string(), None),
289    };
290    if let Some(addr) = literal {
291        return match is_restricted_addr(addr) {
292            true => Err(UrlRejection::PrivateAddress {
293                host: host_str,
294                addr,
295            }),
296            false => Ok(()),
297        };
298    }
299
300    let host = host_str.as_str();
301    let port = url.port_or_known_default().unwrap_or(80);
302    // A resolver error and an empty answer are the same thing to a caller -
303    // there is no address to check - so they collapse to one branch rather than
304    // two, one of which would be near-impossible to reach.
305    let resolved = resolve(host, port).filter(|addrs: &Vec<IpAddr>| !addrs.is_empty());
306    let Some(resolved) = resolved else {
307        return Err(UrlRejection::Unresolvable(host.to_string()));
308    };
309    // Every address must pass. Rejecting on *any* restricted answer means a
310    // hostname that resolves to both 93.184.216.34 and 127.0.0.1 is refused
311    // rather than depending on which one the client happens to dial.
312    for addr in resolved {
313        if is_restricted_addr(addr) {
314            return Err(UrlRejection::PrivateAddress {
315                host: host.to_string(),
316                addr,
317            });
318        }
319    }
320    Ok(())
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    /// Stand up a server that answers the first `hops` requests with a redirect
328    /// to `target` and then `204`s, closing once `hops + 1` connections have
329    /// been served so the task ends with the test.
330    #[cfg(test)]
331    async fn redirecting_server(target: String, hops: usize) -> std::net::SocketAddr {
332        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
333
334        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
335        let addr = listener.local_addr().unwrap();
336        tokio::spawn(async move {
337            for i in 0..=hops {
338                // `.expect`, not a fallible arm: an accept on a listener this
339                // test owns does not fail, and the arm would be a region no
340                // test can drive.
341                let (mut sock, _) = listener.accept().await.expect("accept");
342                // Read the request first: writing before the client has finished
343                // sending confuses the connection and surfaces as a transport
344                // error rather than the redirect under test.
345                let mut buf = [0u8; 1024];
346                let _ = sock.read(&mut buf).await;
347                let body = match i < hops {
348                    true => format!(
349                        "HTTP/1.1 307 Temporary Redirect\r\nLocation: {target}\r\n\
350                         Content-Length: 0\r\nConnection: close\r\n\r\n"
351                    ),
352                    false => "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\
353                              Connection: close\r\n\r\n"
354                        .to_string(),
355                };
356                let _ = sock.write_all(body.as_bytes()).await;
357                let _ = sock.flush().await;
358            }
359        });
360        addr
361    }
362
363    /// The client itself, following a real redirect: a public-looking endpoint
364    /// answering `307 Location: http://169.254.169.254/…` reaches the metadata
365    /// service unless every hop is re-checked, and 307 preserves the method and
366    /// the body.
367    #[tokio::test(flavor = "multi_thread")]
368    async fn checked_client_refuses_a_redirect_to_a_restricted_address() {
369        let addr = redirecting_server("http://169.254.169.254/latest/".to_string(), 1).await;
370
371        // The policy only sees *redirects*, never the URL the caller passed, so
372        // the initial connect to loopback proceeds regardless of this flag -
373        // what it governs is whether the hop onward to link-local is followed.
374        let client = checked_client(ClientTimeouts::default(), false);
375        let err = client
376            .get(format!("http://{addr}/hook"))
377            .send()
378            .await
379            .expect_err("the hop to 169.254.169.254 must be refused");
380
381        // reqwest wraps the policy's message in its source chain, so the reason
382        // lives below the top-level "error sending request".
383        let mut chain = err.to_string();
384        let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&err);
385        while let Some(e) = src {
386            chain.push_str(&format!(": {e}"));
387            src = e.source();
388        }
389        let refused = chain.contains("refused to follow redirect");
390        assert!(refused, "{chain}");
391    }
392
393    /// And a permitted hop is actually followed - so the policy is deciding per
394    /// address rather than refusing every redirect.
395    #[tokio::test(flavor = "multi_thread")]
396    async fn checked_client_follows_a_permitted_redirect() {
397        // Somewhere real to land, then a server that points at it once.
398        let destination = redirecting_server(String::new(), 0).await;
399        let addr = redirecting_server(format!("http://{destination}/done"), 1).await;
400
401        let client = checked_client(ClientTimeouts::default(), true);
402        let resp = client
403            .get(format!("http://{addr}/hook"))
404            .send()
405            .await
406            .expect("a permitted hop is followed to its destination");
407        assert_eq!(resp.status().as_u16(), 204);
408    }
409
410    /// A hop is a destination the caller's original check never saw. 307/308
411    /// preserve the method *and* the body, so a public endpoint answering with a
412    /// redirect to a private address turns a webhook into a request primitive
413    /// against the internal network - capping the hop count does not help,
414    /// because the first hop is already somewhere else.
415    #[test]
416    fn a_redirect_hop_is_checked_like_any_other_url() {
417        let private = "http://169.254.169.254/latest/".parse().unwrap();
418        assert!(redirect_decision(0, &private, false).is_err());
419        assert!(redirect_decision(0, &"http://127.0.0.1/x".parse().unwrap(), false).is_err());
420
421        // The deliberate opt-out still works, so the check reads the address
422        // rather than refusing every redirect.
423        assert!(redirect_decision(0, &private, true).is_ok());
424    }
425
426    /// And a loop is bounded even when every hop is permitted, so a server
427    /// cannot hold a delivery open by bouncing it forever.
428    #[test]
429    fn a_redirect_loop_is_bounded_regardless_of_destination() {
430        let ok = "http://127.0.0.1/x".parse().unwrap();
431        assert!(redirect_decision(4, &ok, true).is_ok());
432        let err = redirect_decision(5, &ok, true).expect_err("the sixth hop is refused");
433        assert!(err.contains("too many redirects"), "{err}");
434    }
435
436    fn u(s: &str) -> url::Url {
437        url::Url::parse(s).expect("test URL parses")
438    }
439
440    /// The floor exists because two clients were built with a bare
441    /// `Client::new()` and had no timeouts at all.
442    #[test]
443    fn the_default_timeouts_are_a_real_floor() {
444        let t = ClientTimeouts::default();
445        assert!(t.connect > Duration::ZERO, "a connect timeout is the point");
446        assert!(
447            t.total > t.connect,
448            "the total budget must exceed the connect"
449        );
450    }
451
452    /// The builder has to actually produce a client - a factory returning a
453    /// builder nothing can `build()` would be a silent no-op.
454    #[test]
455    fn the_client_builder_produces_a_usable_client() {
456        assert!(client_builder(ClientTimeouts::default()).build().is_ok());
457        // And a caller may override the floor with its own numbers.
458        let custom = ClientTimeouts {
459            connect: Duration::from_secs(1),
460            total: Duration::from_secs(2),
461        };
462        assert!(client_builder(custom).build().is_ok());
463        // The convenience wrapper callers actually use.
464        let _ = client(ClientTimeouts::default());
465    }
466
467    #[test]
468    fn rejects_non_http_schemes() {
469        for s in ["file:///etc/passwd", "ftp://example.com/x", "gopher://x/"] {
470            let err = check_url(&u(s), false).unwrap_err();
471            assert_eq!(err.kind(), "scheme", "{s} → {err:?}");
472        }
473    }
474
475    /// The headline case: an agent talked into fetching the cloud metadata
476    /// endpoint gets credentials for the whole instance.
477    #[test]
478    fn rejects_cloud_metadata_endpoint() {
479        let err = check_url(&u("http://169.254.169.254/latest/meta-data/"), false).unwrap_err();
480        assert_eq!(err.kind(), "private_address");
481    }
482
483    /// Asserting on `PrivateAddress` specifically, not just `is_err()`: an IPv6
484    /// literal refused as *unresolvable* (the brackets from `host_str()`
485    /// parsing as neither an address nor a name) would pass an `is_err()` check
486    /// while the address rules never ran at all.
487    #[test]
488    fn rejects_loopback_and_private_literals() {
489        for s in [
490            "http://127.0.0.1:3000/api/agents",
491            "http://0.0.0.0/",
492            "http://10.0.0.5/",
493            "http://192.168.1.1/",
494            "http://172.16.0.1/",
495            "http://100.64.0.1/",
496            "http://[::1]/",
497            "http://[fd00::1]/",
498            "http://[fe80::1]/",
499        ] {
500            let err = check_url(&u(s), false).unwrap_err();
501            assert_eq!(err.kind(), "private_address", "{s} → {err:?}");
502        }
503    }
504
505    /// `::ffff:127.0.0.1` is loopback wearing a v6 costume.
506    #[test]
507    fn rejects_ipv4_mapped_loopback() {
508        let err = check_url(&u("http://[::ffff:127.0.0.1]/"), false).unwrap_err();
509        assert_eq!(err.kind(), "private_address", "{err:?}");
510        assert!(is_restricted_addr("::ffff:10.0.0.1".parse().unwrap()));
511    }
512
513    #[test]
514    fn allows_public_literals() {
515        for s in ["http://93.184.216.34/", "https://[2606:2800:220:1::]/"] {
516            assert!(check_url(&u(s), false).is_ok(), "{s} should be allowed");
517        }
518    }
519
520    /// The opt-out exists for people running a local model or a local service.
521    #[test]
522    fn allow_local_waives_the_address_check_but_not_the_scheme() {
523        assert!(check_url(&u("http://127.0.0.1:11434/api/tags"), true).is_ok());
524        assert!(check_url(&u("file:///etc/passwd"), true).is_err());
525    }
526
527    #[test]
528    fn unresolvable_host_is_reported_as_such() {
529        let err = check_url(&u("http://this-host-does-not-exist.invalid/"), false).unwrap_err();
530        assert_eq!(err.kind(), "unresolvable", "{err:?}");
531    }
532
533    /// `localhost` is a *name*, not a literal, so it exercises the resolver path
534    /// rather than the parse-as-IP shortcut.
535    #[test]
536    fn rejects_localhost_by_name() {
537        let err = check_url(&u("http://localhost:8080/"), false).unwrap_err();
538        assert_eq!(err.kind(), "private_address", "{err:?}");
539    }
540
541    // ─── the resolver seam ────────────────────────────────────────────────
542
543    fn resolves_to_nothing(_: &str, _: u16) -> Option<Vec<IpAddr>> {
544        Some(Vec::new())
545    }
546    fn resolver_fails(_: &str, _: u16) -> Option<Vec<IpAddr>> {
547        None
548    }
549    fn resolves_public(_: &str, _: u16) -> Option<Vec<IpAddr>> {
550        Some(vec!["93.184.216.34".parse().expect("valid")])
551    }
552    fn resolves_to_both(_: &str, _: u16) -> Option<Vec<IpAddr>> {
553        Some(vec![
554            "93.184.216.34".parse().expect("valid"),
555            "127.0.0.1".parse().expect("valid"),
556        ])
557    }
558
559    /// A name that resolves to a public address passes. Injected rather than
560    /// using real DNS: a test that depends on someone else's zone file is slow
561    /// when it works and confusing when it doesn't.
562    #[test]
563    fn a_hostname_resolving_publicly_is_allowed() {
564        assert!(check_url_with(&u("https://example.com/x"), false, resolves_public).is_ok());
565    }
566
567    /// A name with **both** a public and a private answer is refused rather than
568    /// raced. Which address the client would actually dial is not ours to
569    /// predict, so any restricted answer settles it.
570    #[test]
571    fn a_hostname_resolving_to_both_public_and_private_is_refused() {
572        let err = check_url_with(&u("https://split.example/x"), false, resolves_to_both)
573            .expect_err("a private answer must settle it");
574        assert_eq!(err.kind(), "private_address");
575    }
576
577    /// A resolver error and an empty answer are the same thing to a caller:
578    /// there is no address to check.
579    #[test]
580    fn no_addresses_reads_as_unresolvable() {
581        for resolver in [resolves_to_nothing, resolver_fails] {
582            let err = check_url_with(&u("https://nowhere.example/x"), false, resolver)
583                .expect_err("no address means no fetch");
584            assert_eq!(err.kind(), "unresolvable");
585        }
586    }
587
588    /// How many times [`counting_resolver`] has been called. A counter rather
589    /// than a panicking "must not run" resolver: the body of a resolver that is
590    /// never called is itself an uncovered region, so the assertion would come
591    /// at the cost of the thing it is asserting about.
592    static RESOLVER_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
593
594    fn counting_resolver(_: &str, _: u16) -> Option<Vec<IpAddr>> {
595        RESOLVER_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
596        Some(vec!["93.184.216.34".parse().expect("valid")])
597    }
598
599    /// The scheme check runs before resolution, so a refused scheme never
600    /// reaches the resolver - no DNS lookup for a URL we were never going to
601    /// fetch.
602    #[test]
603    fn the_scheme_is_checked_before_anything_is_resolved() {
604        use std::sync::atomic::Ordering::SeqCst;
605
606        // Establish that this resolver does get called for a URL that reaches
607        // resolution, so the count below means "not reached" and not "broken".
608        let before = RESOLVER_CALLS.load(SeqCst);
609        assert!(check_url_with(&u("https://example.com/x"), false, counting_resolver).is_ok());
610        assert_eq!(RESOLVER_CALLS.load(SeqCst), before + 1);
611
612        let err = check_url_with(&u("ftp://example.com/x"), false, counting_resolver)
613            .expect_err("ftp is refused");
614        assert_eq!(err.kind(), "scheme");
615        assert_eq!(
616            RESOLVER_CALLS.load(SeqCst),
617            before + 1,
618            "a refused scheme must not trigger a DNS lookup"
619        );
620
621        // A literal address short-circuits resolution too.
622        assert!(check_url_with(&u("https://93.184.216.34/x"), false, counting_resolver).is_ok());
623        assert_eq!(
624            RESOLVER_CALLS.load(SeqCst),
625            before + 1,
626            "an IP literal must not trigger a DNS lookup"
627        );
628    }
629
630    /// Every rejection reports a distinct kind, so the accessor is a real
631    /// discriminator rather than a constant.
632    #[test]
633    fn each_rejection_has_its_own_kind() {
634        let kinds = [
635            UrlRejection::Scheme("ftp".into()).kind(),
636            UrlRejection::Unresolvable("h".into()).kind(),
637            UrlRejection::PrivateAddress {
638                host: "h".into(),
639                addr: "127.0.0.1".parse().expect("valid"),
640            }
641            .kind(),
642        ];
643        let unique: std::collections::HashSet<_> = kinds.iter().collect();
644        assert_eq!(
645            unique.len(),
646            kinds.len(),
647            "kinds must be distinct: {kinds:?}"
648        );
649    }
650
651    #[test]
652    fn rejection_messages_name_the_fix() {
653        let err = check_url(&u("http://127.0.0.1/"), false).unwrap_err();
654        let msg = err.to_string();
655        assert!(msg.contains("allow_local_network"), "{msg}");
656        assert!(msg.contains("127.0.0.1"), "{msg}");
657
658        assert!(
659            UrlRejection::Unresolvable("h".into())
660                .to_string()
661                .contains("did not resolve")
662        );
663        assert!(
664            UrlRejection::Scheme("ftp".into())
665                .to_string()
666                .contains("only http and https")
667        );
668    }
669
670    #[test]
671    fn restricted_classification_covers_reserved_ranges() {
672        for s in [
673            "192.0.0.1", // IETF protocol assignments
674            "240.0.0.1", // reserved
675            "255.255.255.255",
676            "224.0.0.1", // multicast
677            "192.0.2.1", // documentation
678        ] {
679            assert!(is_restricted_addr(s.parse().unwrap()), "{s}");
680        }
681        assert!(!is_restricted_addr("8.8.8.8".parse().unwrap()));
682        assert!(is_restricted_addr("ff02::1".parse().unwrap()));
683        assert!(!is_restricted_addr("2001:4860:4860::8888".parse().unwrap()));
684    }
685}