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}