Skip to main content

max / makenotwork

22.5 KB · 603 lines History Blame Raw
1 //! Link preview, server-side OpenGraph metadata fetch for post URLs.
2
3 use pulldown_cmark::{Event, Parser, Tag};
4 use reqwest::header::CONTENT_TYPE;
5
6 /// Maximum number of URLs to extract per post.
7 const MAX_URLS: usize = 3;
8
9 /// Maximum response body size to read (1 MB).
10 const MAX_BODY_SIZE: usize = 1_048_576;
11
12 /// Check if an IP address is private/reserved (not safe for SSRF).
13 fn is_private_ip(ip: std::net::IpAddr) -> bool {
14 match ip {
15 std::net::IpAddr::V4(v4) => {
16 let o = v4.octets();
17 v4.is_loopback()
18 || v4.is_private()
19 || v4.is_link_local()
20 || v4.is_broadcast()
21 || v4.is_unspecified()
22 || o[0] == 0 // 0.0.0.0/8 "this network"
23 || o[0] == 100 && (o[1] & 0xC0) == 64 // 100.64.0.0/10 (CGNAT / Tailscale)
24 || o[0] == 192 && o[1] == 0 && o[2] == 0 // 192.0.0.0/24 IETF protocol assignments
25 || o[0] == 198 && (o[1] & 0xFE) == 18 // 198.18.0.0/15 benchmarking
26 || o[0] >= 240 // 240.0.0.0/4 reserved / experimental
27 }
28 std::net::IpAddr::V6(v6) => {
29 let seg = v6.segments();
30 v6.is_loopback()
31 || v6.is_unspecified()
32 || (seg[0] & 0xfe00) == 0xfc00 // ULA fd00::/7
33 || (seg[0] & 0xffc0) == 0xfe80 // link-local
34 // NAT64 64:ff9b::/96 embeds a v4 address, check the embedded v4.
35 || (seg[0] == 0x0064 && seg[1] == 0xff9b
36 && is_private_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::new(
37 (seg[6] >> 8) as u8, seg[6] as u8, (seg[7] >> 8) as u8, seg[7] as u8,
38 ))))
39 // v4-mapped (::ffff:a.b.c.d) and v4-compatible (::a.b.c.d) both
40 // carry an embedded v4 that reqwest may connect to directly.
41 || matches!(v6.to_ipv4(), Some(v4) if is_private_ip(std::net::IpAddr::V4(v4)))
42 }
43 }
44 }
45
46 /// Cheap, synchronous pre-check that a URL is safe to fetch: http(s) scheme and
47 /// not an obvious private/literal-IP target. Hostnames are NOT resolved here,
48 /// that (and the authoritative anti-rebinding check) happens at connect time in
49 /// [`SsrfSafeResolver`], so this stays non-blocking and usable from reqwest's
50 /// synchronous redirect callback.
51 ///
52 /// The host is parsed with the `url` crate, the SAME parser reqwest uses to
53 /// decide what to connect to. An earlier hand-rolled parser split the authority
54 /// itself and fed the host to `std::net::IpAddr::parse`, which only accepts the
55 /// dotted-quad form; integer/hex/octal encodings (`http://2130706433/`,
56 /// `http://0x7f000001/`, `http://0177.0.0.1/`) failed that parse, fell through as
57 /// "a hostname (resolved safely later)", and reqwest then normalized them into
58 /// literal internal IPs the `SsrfSafeResolver` never sees (it is consulted only
59 /// for *name* hosts). Parsing here with `url::Url` closes that divergence: we
60 /// inspect exactly the `Host` reqwest will act on, so an IP literal in any
61 /// encoding is caught by `is_private_ip` and a domain defers to the resolver.
62 fn validate_url(url: &str) -> bool {
63 let Ok(parsed) = url::Url::parse(url) else {
64 return false;
65 };
66 match parsed.scheme() {
67 "http" | "https" => {}
68 _ => return false,
69 }
70 // Only allow the standard web ports. A public host that legitimately serves
71 // OG metadata does so on 80/443; an explicit non-web port (e.g. :6379) means
72 // the link is trying to use us as a request-forwarder to some other service.
73 // `port()` is `None` for the scheme default (80/443), which is fine.
74 if let Some(p) = parsed.port()
75 && p != 80
76 && p != 443
77 {
78 return false;
79 }
80 // Inspect the concrete host reqwest will connect to. Any IP literal,
81 // dotted-quad, integer, hex, octal, or IPv6, is normalized by the `url`
82 // crate into `Host::Ipv4`/`Host::Ipv6` here, so the encoding tricks that
83 // defeated `IpAddr::parse` can no longer hide behind the "it's a hostname"
84 // branch. A real domain defers to `SsrfSafeResolver` at connect time.
85 match parsed.host() {
86 Some(url::Host::Ipv4(v4)) => !is_private_ip(std::net::IpAddr::V4(v4)),
87 Some(url::Host::Ipv6(v6)) => !is_private_ip(std::net::IpAddr::V6(v6)),
88 Some(url::Host::Domain(domain)) => {
89 !domain.is_empty() && !domain.eq_ignore_ascii_case("localhost")
90 }
91 None => false,
92 }
93 }
94
95 /// Extract unique http/https URLs from markdown text via pulldown_cmark link parsing.
96 /// Returns at most `MAX_URLS` URLs.
97 pub fn extract_urls(input: &str) -> Vec<String> {
98 let parser = Parser::new(input);
99 let mut seen = std::collections::HashSet::new();
100 let mut urls = Vec::new();
101
102 for event in parser {
103 if let Event::Start(Tag::Link { dest_url, .. }) = event {
104 let url = dest_url.to_string();
105 if (url.starts_with("http://") || url.starts_with("https://"))
106 && seen.insert(url.clone())
107 {
108 urls.push(url);
109 if urls.len() >= MAX_URLS {
110 break;
111 }
112 }
113 }
114 }
115
116 urls
117 }
118
119 /// Connect-time DNS resolver that drops private/reserved addresses.
120 ///
121 /// `validate_url` resolves and checks during validation, but reqwest re-resolves
122 /// when it actually connects, a DNS-rebinding host could pass validation and
123 /// then connect internally. Filtering here, at the resolver reqwest uses to
124 /// connect, closes that TOCTOU: reqwest can only connect to an address this
125 /// resolver returned, and we never return a private one. Resolution is async
126 /// (`tokio::net::lookup_host`), so it also avoids blocking the runtime.
127 struct SsrfSafeResolver;
128
129 impl reqwest::dns::Resolve for SsrfSafeResolver {
130 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
131 Box::pin(async move {
132 let host = name.as_str().to_string();
133 let addrs = tokio::net::lookup_host((host.as_str(), 0)).await?;
134 let public: Vec<std::net::SocketAddr> =
135 addrs.filter(|a| !is_private_ip(a.ip())).collect();
136 if public.is_empty() {
137 return Err("no public address for host (SSRF guard)".into());
138 }
139 let iter: reqwest::dns::Addrs = Box::new(public.into_iter());
140 Ok(iter)
141 })
142 }
143 }
144
145 /// Build a reqwest client for link preview fetching with SSRF-safe redirect
146 /// policy and a connect-time resolver that refuses private addresses.
147 pub fn build_preview_client() -> reqwest::Client {
148 crate::tls::builder()
149 // Client-level backstop so every call site is bounded even if a
150 // per-request `.timeout()` is omitted (call sites still set a tighter
151 // 5s; this only catches a future caller that forgets).
152 .timeout(std::time::Duration::from_secs(10))
153 .connect_timeout(std::time::Duration::from_secs(5))
154 .dns_resolver(std::sync::Arc::new(SsrfSafeResolver))
155 .redirect(reqwest::redirect::Policy::custom(|attempt| {
156 if !validate_url(attempt.url().as_str()) || attempt.previous().len() >= 5 {
157 attempt.stop()
158 } else {
159 attempt.follow()
160 }
161 }))
162 .build()
163 .expect("failed to build preview HTTP client")
164 }
165
166 /// Strategy for fetching link previews. The `Noop` variant lets tests skip
167 /// real HTTP without monkey-patching `tokio::spawn`; production constructs
168 /// `Http(build_preview_client())`.
169 #[derive(Clone)]
170 pub enum LinkPreviewFetcher {
171 Http(reqwest::Client),
172 Noop,
173 }
174
175 impl LinkPreviewFetcher {
176 pub async fn fetch(&self, url: &str) -> Option<(Option<String>, Option<String>)> {
177 match self {
178 Self::Http(client) => fetch_og_metadata(client, url).await,
179 Self::Noop => None,
180 }
181 }
182 }
183
184 /// Fetch OpenGraph metadata from a URL. Returns `(og:title, og:description)`.
185 /// Best-effort: returns None on any error (timeout, too large, parse failure).
186 #[tracing::instrument(skip_all)]
187 pub async fn fetch_og_metadata(
188 http: &reqwest::Client,
189 url: &str,
190 ) -> Option<(Option<String>, Option<String>)> {
191 if !validate_url(url) {
192 tracing::warn!(%url, "link preview blocked: url failed scheme/host SSRF validation");
193 return None;
194 }
195
196 let resp = match http
197 .get(url)
198 .timeout(std::time::Duration::from_secs(5))
199 .header("User-Agent", "Multithreaded/LinkPreview")
200 .send()
201 .await
202 {
203 Ok(r) => r,
204 Err(e) => {
205 tracing::debug!(%url, error = ?e, "link preview fetch failed (transport/timeout/blocked resolver)");
206 return None;
207 }
208 };
209
210 if !resp.status().is_success() {
211 tracing::debug!(%url, status = %resp.status(), "link preview fetch: non-success status");
212 return None;
213 }
214
215 // Only parse HTML content. Default-deny: a response with no Content-Type
216 // (or an unreadable one) is not treated as HTML.
217 let is_html = resp
218 .headers()
219 .get(CONTENT_TYPE)
220 .and_then(|ct| ct.to_str().ok())
221 .is_some_and(|ct| ct.starts_with("text/html"));
222 if !is_html {
223 return None;
224 }
225
226 let mut body = Vec::new();
227 let mut stream = resp;
228 while body.len() < MAX_BODY_SIZE {
229 let Some(chunk) = stream.chunk().await.ok()? else {
230 break;
231 };
232 let remaining = MAX_BODY_SIZE - body.len();
233 body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
234 }
235
236 let html = String::from_utf8_lossy(&body);
237
238 let og_title = extract_og_meta(&html, "og:title");
239 let og_desc = extract_og_meta(&html, "og:description");
240
241 let title = og_title.or_else(|| extract_html_title(&html));
242
243 if title.is_some() || og_desc.is_some() {
244 Some((title, og_desc))
245 } else {
246 None
247 }
248 }
249
250 /// Normalize an external image response's `Content-Type` to one of the four
251 /// formats we re-serve, or `None` to reject (the proxy default-denies anything
252 /// that isn't a recognised image type, so it can't be turned into a relay for
253 /// arbitrary content).
254 fn allowed_image_content_type(ct: &str) -> Option<&'static str> {
255 match ct.split(';').next().unwrap_or("").trim() {
256 "image/png" => Some("image/png"),
257 "image/jpeg" => Some("image/jpeg"),
258 "image/gif" => Some("image/gif"),
259 "image/webp" => Some("image/webp"),
260 _ => None,
261 }
262 }
263
264 /// Fetch an external image for the same-origin image proxy.
265 ///
266 /// Same SSRF guarantees as [`fetch_og_metadata`] (scheme/port pre-check +
267 /// connect-time `SsrfSafeResolver` that refuses private addresses, even across
268 /// redirects), capped at `MAX_BODY_SIZE` and a 5s timeout. Returns the bytes and
269 /// a normalised image content-type, or `None` on any failure or a non-image
270 /// response. Best-effort by design: the caller maps `None` to a 502.
271 #[tracing::instrument(skip_all)]
272 pub async fn fetch_image(http: &reqwest::Client, url: &str) -> Option<(Vec<u8>, &'static str)> {
273 if !validate_url(url) {
274 tracing::warn!(%url, "image proxy blocked: url failed scheme/host SSRF validation");
275 return None;
276 }
277
278 let resp = match http
279 .get(url)
280 .timeout(std::time::Duration::from_secs(5))
281 .header("User-Agent", "Multithreaded/ImageProxy")
282 .send()
283 .await
284 {
285 Ok(r) => r,
286 Err(e) => {
287 tracing::debug!(%url, error = ?e, "image proxy fetch failed (transport/timeout/blocked resolver)");
288 return None;
289 }
290 };
291
292 if !resp.status().is_success() {
293 tracing::debug!(%url, status = %resp.status(), "image proxy fetch: non-success status");
294 return None;
295 }
296
297 // Default-deny: only re-serve a recognised image content-type.
298 let content_type = resp
299 .headers()
300 .get(CONTENT_TYPE)
301 .and_then(|ct| ct.to_str().ok())
302 .and_then(allowed_image_content_type)?;
303
304 // Read the body in chunks, hard-capped at MAX_BODY_SIZE (1 MB).
305 let mut body = Vec::new();
306 let mut stream = resp;
307 while body.len() < MAX_BODY_SIZE {
308 let Some(chunk) = stream.chunk().await.ok()? else {
309 break;
310 };
311 let remaining = MAX_BODY_SIZE - body.len();
312 body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
313 }
314
315 if body.is_empty() {
316 return None;
317 }
318 Some((body, content_type))
319 }
320
321 /// Extract a `<meta property="..." content="...">` value from HTML.
322 fn extract_og_meta(html: &str, property: &str) -> Option<String> {
323 static OG_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
324 regex_lite::Regex::new(
325 r#"<meta\s[^>]*?property\s*=\s*"([^"]*)"[^>]*?content\s*=\s*"([^"]*)"[^>]*?>"#,
326 )
327 .unwrap()
328 });
329 static OG_RE_REV: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
330 regex_lite::Regex::new(
331 r#"<meta\s[^>]*?content\s*=\s*"([^"]*)"[^>]*?property\s*=\s*"([^"]*)"[^>]*?>"#,
332 )
333 .unwrap()
334 });
335
336 for caps in OG_RE.captures_iter(html) {
337 if &caps[1] == property {
338 let val = caps[2].trim().to_string();
339 if !val.is_empty() {
340 return Some(val);
341 }
342 }
343 }
344 // Try content-first order (some sites put content before property)
345 for caps in OG_RE_REV.captures_iter(html) {
346 if &caps[2] == property {
347 let val = caps[1].trim().to_string();
348 if !val.is_empty() {
349 return Some(val);
350 }
351 }
352 }
353 None
354 }
355
356 /// Extract the `<title>` tag content from HTML.
357 fn extract_html_title(html: &str) -> Option<String> {
358 static TITLE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
359 regex_lite::Regex::new(r"<title[^>]*>([^<]+)</title>").unwrap()
360 });
361 TITLE_RE.captures(html).map(|c| c[1].trim().to_string())
362 }
363
364 #[cfg(test)]
365 mod tests {
366 use super::*;
367
368 #[test]
369 fn extract_urls_from_markdown() {
370 let input = "Check [this](https://example.com) and [that](https://other.com/page).";
371 let urls = extract_urls(input);
372 assert_eq!(urls, vec!["https://example.com", "https://other.com/page"]);
373 }
374
375 #[test]
376 fn image_proxy_content_type_allowlist() {
377 // Recognised image types pass (with parameters stripped); everything else
378 // is refused so the proxy can't re-serve HTML/SVG/arbitrary content.
379 assert_eq!(allowed_image_content_type("image/png"), Some("image/png"));
380 assert_eq!(
381 allowed_image_content_type("image/jpeg; charset=binary"),
382 Some("image/jpeg")
383 );
384 assert_eq!(allowed_image_content_type("image/webp"), Some("image/webp"));
385 assert_eq!(allowed_image_content_type("image/svg+xml"), None);
386 assert_eq!(allowed_image_content_type("text/html"), None);
387 assert_eq!(allowed_image_content_type(""), None);
388 }
389
390 #[test]
391 fn extract_urls_skips_non_http() {
392 let input = "[mail](mailto:a@b.com) [site](https://x.com)";
393 let urls = extract_urls(input);
394 assert_eq!(urls, vec!["https://x.com"]);
395 }
396
397 #[test]
398 fn extract_urls_caps_at_three() {
399 let input = "[a](https://1.com) [b](https://2.com) [c](https://3.com) [d](https://4.com)";
400 let urls = extract_urls(input);
401 assert_eq!(urls.len(), 3);
402 }
403
404 #[test]
405 fn extract_urls_deduplicates() {
406 let input = "[a](https://same.com) [b](https://same.com)";
407 let urls = extract_urls(input);
408 assert_eq!(urls, vec!["https://same.com"]);
409 }
410
411 #[test]
412 fn extract_urls_no_links() {
413 let urls = extract_urls("no links here");
414 assert!(urls.is_empty());
415 }
416
417 #[test]
418 fn og_meta_property_first() {
419 let html = r#"<meta property="og:title" content="My Page">"#;
420 assert_eq!(
421 extract_og_meta(html, "og:title"),
422 Some("My Page".to_string())
423 );
424 }
425
426 #[test]
427 fn og_meta_content_first() {
428 let html = r#"<meta content="Description here" property="og:description">"#;
429 assert_eq!(
430 extract_og_meta(html, "og:description"),
431 Some("Description here".to_string())
432 );
433 }
434
435 #[test]
436 fn og_meta_missing() {
437 let html = r#"<meta property="og:image" content="img.png">"#;
438 assert_eq!(extract_og_meta(html, "og:title"), None);
439 }
440
441 #[test]
442 fn html_title_fallback() {
443 let html = "<html><head><title>Page Title</title></head></html>";
444 assert_eq!(extract_html_title(html), Some("Page Title".to_string()));
445 }
446
447 #[test]
448 fn html_title_missing() {
449 let html = "<html><head></head></html>";
450 assert_eq!(extract_html_title(html), None);
451 }
452
453 // validate_url tests
454
455 #[test]
456 fn validate_url_allows_https() {
457 assert!(validate_url("https://example.com"));
458 assert!(validate_url("https://example.com/path?q=1"));
459 }
460
461 #[test]
462 fn validate_url_allows_http() {
463 assert!(validate_url("http://example.com"));
464 }
465
466 #[test]
467 fn validate_url_blocks_non_http_schemes() {
468 assert!(!validate_url("ftp://example.com"));
469 assert!(!validate_url("file:///etc/passwd"));
470 assert!(!validate_url("javascript:alert(1)"));
471 assert!(!validate_url("data:text/html,<h1>hi</h1>"));
472 }
473
474 #[test]
475 fn validate_url_blocks_localhost() {
476 assert!(!validate_url("http://localhost"));
477 assert!(!validate_url("http://localhost:8080"));
478 assert!(!validate_url("http://127.0.0.1"));
479 assert!(!validate_url("http://127.0.0.1:3000"));
480 assert!(!validate_url("http://0.0.0.0"));
481 assert!(!validate_url("http://[::1]"));
482 assert!(!validate_url("http://[::1]:8080"));
483 }
484
485 #[test]
486 fn validate_url_blocks_private_10() {
487 assert!(!validate_url("http://10.0.0.1"));
488 assert!(!validate_url("http://10.255.255.255"));
489 }
490
491 #[test]
492 fn validate_url_blocks_private_192_168() {
493 assert!(!validate_url("http://192.168.0.1"));
494 assert!(!validate_url("http://192.168.1.100:8080"));
495 }
496
497 #[test]
498 fn validate_url_blocks_private_172_16() {
499 assert!(!validate_url("http://172.16.0.1"));
500 assert!(!validate_url("http://172.31.255.255"));
501 // 172.15 and 172.32 are public
502 assert!(validate_url("http://172.15.0.1"));
503 assert!(validate_url("http://172.32.0.1"));
504 }
505
506 #[test]
507 fn validate_url_blocks_link_local() {
508 assert!(!validate_url("http://169.254.0.1"));
509 assert!(!validate_url("http://169.254.169.254")); // AWS metadata
510 }
511
512 #[test]
513 fn validate_url_blocks_ipv6_private() {
514 assert!(!validate_url("http://[fd00::1]"));
515 assert!(!validate_url("http://[fe80::1]"));
516 }
517
518 #[test]
519 fn validate_url_allows_public_ips() {
520 assert!(validate_url("http://8.8.8.8"));
521 assert!(validate_url("https://93.184.216.34"));
522 }
523
524 #[test]
525 fn validate_url_blocks_userinfo_literal_ip() {
526 // H1: a userinfo-prefixed literal IP must not slip past the literal-IP
527 // guard. reqwest connects straight to the IP (skipping SsrfSafeResolver),
528 // so validate_url is the only defense on this path.
529 assert!(!validate_url("http://@10.0.0.1/"));
530 assert!(!validate_url("http://@169.254.169.254/")); // cloud metadata
531 assert!(!validate_url("http://user:pass@127.0.0.1/"));
532 assert!(!validate_url("http://foo@192.168.1.1:80/path"));
533 assert!(!validate_url("http://a@b@10.0.0.1/")); // last '@' wins
534 assert!(!validate_url("http://@[::1]/"));
535 // A userinfo-prefixed *public* host is still allowed (host resolves and is
536 // re-checked at connect time by SsrfSafeResolver).
537 assert!(validate_url("http://user@example.com/"));
538 }
539
540 #[test]
541 fn validate_url_blocks_empty_host() {
542 // A truly empty authority is a parse error under the `url` crate → rejected.
543 assert!(!validate_url("http://@/"));
544 // `http:///path` is NOT an empty host under the `url` crate (nor under
545 // reqwest): it normalizes to host "path", a domain deferred to
546 // SsrfSafeResolver at connect time. Parsing with the same crate reqwest
547 // uses means we treat it exactly as reqwest will, which is the point.
548 assert!(validate_url("http:///path"));
549 }
550
551 #[test]
552 fn validate_url_blocks_numeric_ip_encodings() {
553 // The SSRF integer-IP bypass: these encodings all normalize to internal
554 // literals under the `url` crate (what reqwest connects through) but were
555 // rejected by the old `IpAddr::parse` pre-check, so they slipped past as
556 // "hostnames" and reqwest connected straight to the internal IP without
557 // ever consulting `SsrfSafeResolver`. validate_url must now block them.
558 assert!(!validate_url("http://2130706433/")); // decimal 127.0.0.1
559 assert!(!validate_url("http://0x7f000001/")); // hex 127.0.0.1
560 assert!(!validate_url("http://0177.0.0.1/")); // octal-leading 127.0.0.1
561 assert!(!validate_url("http://2852039166/")); // decimal 169.254.169.254 (cloud metadata)
562 assert!(!validate_url("http://0xa9fea9fe/")); // hex 169.254.169.254
563 assert!(!validate_url("http://017700000001/")); // full octal 127.0.0.1
564 // A public host in decimal form is still reachable (sanity: the guard is
565 // is_private_ip, not a blanket numeric-host reject).
566 assert!(validate_url("http://134744072/")); // 8.8.8.8
567 }
568
569 #[test]
570 fn validate_url_blocks_reserved_ranges() {
571 assert!(!validate_url("http://0.1.2.3")); // 0.0.0.0/8
572 assert!(!validate_url("http://192.0.0.1")); // 192.0.0.0/24
573 assert!(!validate_url("http://198.18.0.1")); // benchmarking 198.18/15
574 assert!(!validate_url("http://198.19.255.255"));
575 assert!(!validate_url("http://240.0.0.1")); // reserved 240/4
576 assert!(!validate_url("http://255.255.255.254"));
577 }
578
579 #[test]
580 fn is_private_ip_blocks_v6_embedded_v4() {
581 use std::net::{IpAddr, Ipv6Addr};
582 // v4-mapped private
583 assert!(is_private_ip(IpAddr::V6(
584 "::ffff:10.0.0.1".parse::<Ipv6Addr>().unwrap()
585 )));
586 // v4-mapped public
587 assert!(!is_private_ip(IpAddr::V6(
588 "::ffff:8.8.8.8".parse::<Ipv6Addr>().unwrap()
589 )));
590 // NAT64 wrapping link-local metadata
591 assert!(is_private_ip(IpAddr::V6(
592 "64:ff9b::169.254.169.254".parse::<Ipv6Addr>().unwrap()
593 )));
594 }
595
596 #[tokio::test]
597 async fn noop_fetcher_returns_none_without_network() {
598 let fetcher = LinkPreviewFetcher::Noop;
599 // Any URL, would be a public host in production but here we expect no I/O.
600 assert!(fetcher.fetch("https://example.com").await.is_none());
601 }
602 }
603