1use url::Url;
4
5pub fn validate_url_not_private(url_str: &str) -> Result<(), &'static str> {
7 let parsed = Url::parse(url_str).map_err(|_| "Invalid URL")?;
8
9 match parsed.scheme() {
10 "http" | "https" => {}
11 _ => return Err("Only HTTP(S) URLs are allowed"),
12 }
13
14 match parsed.host() {
15 Some(url::Host::Ipv4(ip)) => {
16 let o = ip.octets();
17 if ip.is_loopback() || ip.is_private() || ip.is_link_local()
18 || ip.is_broadcast() || ip.is_unspecified()
19 || (o[0] == 100 && o[1] >= 64 && o[1] <= 127)
20 {
21 return Err("Private/internal IP addresses are not allowed");
22 }
23 }
24 Some(url::Host::Ipv6(ip)) => {
25 if ip.is_loopback() || ip.is_unspecified() || is_ipv6_private(&ip) {
26 return Err("Private/internal IP addresses are not allowed");
27 }
28 }
29 Some(url::Host::Domain(domain)) => {
30 if domain == "localhost" || domain.ends_with(".local") || domain.ends_with(".internal") {
31 return Err("Local hostnames are not allowed");
32 }
33 }
34 None => return Err("URL has no host"),
35 }
36
37 Ok(())
38}
39
40fn is_ipv6_private(ip: &std::net::Ipv6Addr) -> bool {
41 if let Some(ipv4) = ip.to_ipv4_mapped() {
42 return ipv4.is_loopback() || ipv4.is_private() || ipv4.is_link_local();
43 }
44 let segments = ip.segments();
45 if segments[0] & 0xfe00 == 0xfc00 { return true; } if segments[0] & 0xffc0 == 0xfe80 { return true; } false
48}
49
50#[allow(clippy::disallowed_methods)]
63pub fn build_http_client(timeout: std::time::Duration) -> Result<reqwest::Client, String> {
64 build_http_client_with_options(timeout, None, true)
65}
66
67#[allow(clippy::disallowed_methods)]
71pub fn build_http_client_with_options(
72 timeout: std::time::Duration,
73 read_timeout: Option<std::time::Duration>,
74 follow_redirects: bool,
75) -> Result<reqwest::Client, String> {
76 let mut builder = reqwest::Client::builder()
77 .timeout(timeout)
78 .connect_timeout(std::time::Duration::from_secs(15));
81 if let Some(rt) = read_timeout {
87 builder = builder.read_timeout(rt);
88 }
89 if !follow_redirects {
90 builder = builder.redirect(reqwest::redirect::Policy::none());
91 } else {
92 builder = builder.redirect(reqwest::redirect::Policy::custom(|attempt| {
96 if attempt.previous().len() >= 10 {
97 return attempt.error("too many redirects");
98 }
99 match validate_url_not_private(attempt.url().as_str()) {
100 Ok(()) => attempt.follow(),
101 Err(e) => attempt.error(e),
102 }
103 }));
104 }
105
106 #[cfg(feature = "tor")]
107 {
108 match crate::tor::transport_state() {
109 crate::tor::TorTransportState::Active(addr) => {
110 let url = format!("socks5h://{addr}");
113 let proxy = reqwest::Proxy::all(&url)
114 .map_err(|e| format!("Tor proxy URL ({url}) invalid: {e}"))?;
115 builder = builder.proxy(proxy);
116 builder = builder.connect_timeout(std::time::Duration::from_secs(45));
118 }
119 crate::tor::TorTransportState::RequiredButInactive => {
120 let url = format!("socks5h://{}", crate::tor::blackhole_proxy_addr());
123 let proxy = reqwest::Proxy::all(&url)
124 .map_err(|e| format!("blackhole proxy invalid: {e}"))?;
125 builder = builder.proxy(proxy);
126 }
127 crate::tor::TorTransportState::Disabled => {
128 }
130 }
131 }
132
133 builder
134 .build()
135 .map_err(|e| format!("Failed to build HTTP client: {}", e))
136}
137
138use std::sync::{Arc, OnceLock, RwLock};
152
153static SHARED_HTTP_CLIENT: OnceLock<RwLock<Arc<reqwest::Client>>> = OnceLock::new();
154
155const DEFAULT_SHARED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
156
157fn shared_cell() -> &'static RwLock<Arc<reqwest::Client>> {
158 SHARED_HTTP_CLIENT.get_or_init(|| {
159 let client = build_http_client(DEFAULT_SHARED_TIMEOUT)
160 .expect("initial shared HTTP client build cannot fail");
161 RwLock::new(Arc::new(client))
162 })
163}
164
165pub fn shared_http_client() -> Arc<reqwest::Client> {
168 shared_cell().read().unwrap().clone()
169}
170
171pub fn rebuild_shared_http_client() -> Result<(), String> {
175 let new = Arc::new(build_http_client(DEFAULT_SHARED_TIMEOUT)?);
176 *shared_cell().write().unwrap() = new;
177 Ok(())
178}
179
180fn md_group_close(bytes: &[u8], start: usize, open: u8, close: u8) -> Option<usize> {
186 let mut depth = 1usize;
187 let mut escaped = false;
188 let mut j = start;
189 loop {
190 match bytes.get(j).copied() {
191 None => return None,
192 Some(b'\\') if !escaped => escaped = true,
193 Some(b) if b == open && !escaped => depth += 1,
194 Some(b) if b == close && !escaped => {
195 depth -= 1;
196 if depth == 0 {
197 return Some(j);
198 }
199 }
200 _ => escaped = false,
201 }
202 j += 1;
203 }
204}
205
206pub fn strip_md_link_claims(text: &str) -> String {
214 let bytes = text.as_bytes();
215 let mut out = String::with_capacity(text.len());
216 let mut last = 0;
217 let mut i = 0;
218 while i < bytes.len() {
219 if bytes[i] != b'[' || (i > 0 && bytes[i - 1] == b'!') {
220 i += 1;
221 continue;
222 }
223 let Some(close) = md_group_close(bytes, i + 1, b'[', b']') else { break };
224 match bytes.get(close + 1).copied() {
225 Some(b'(') => {
227 let Some(paren) = md_group_close(bytes, close + 2, b'(', b')') else {
228 i = close + 1;
229 continue;
230 };
231 out.push_str(&text[last..i]);
232 let href = text[close + 2..paren].trim();
233 if !(href.starts_with('<') && href.ends_with('>')) {
234 out.push(' ');
235 out.push_str(href);
236 out.push(' ');
237 }
238 i = paren + 1;
239 last = i;
240 }
241 Some(b'[') => {
244 let Some(ref_close) = md_group_close(bytes, close + 2, b'[', b']') else {
245 i = close + 1;
246 continue;
247 };
248 out.push_str(&text[last..i]);
249 i = ref_close + 1;
250 last = i;
251 }
252 _ => {
253 i = close + 1;
254 }
255 }
256 }
257 out.push_str(&text[last..]);
258 out
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
270 fn md_link_claim_text_dropped_href_kept() {
271 let out = strip_md_link_claims("[https://your-bank.com](https://evil.io)");
273 assert!(!out.contains("your-bank.com"), "claimed text must not reach the scan: {out}");
274 assert!(out.contains("https://evil.io"), "real destination must reach the scan: {out}");
275 }
276
277 #[test]
278 fn md_no_preview_link_dropped_entirely() {
279 let out = strip_md_link_claims("see [docs](<https://vector.app/docs>) ok");
280 assert!(!out.contains("vector.app"), "no-preview href must not reach the scan: {out}");
281 assert!(out.contains("see ") && out.contains(" ok"));
282 }
283
284 #[test]
285 fn md_image_passes_through() {
286 let text = "";
287 assert_eq!(strip_md_link_claims(text), text);
288 }
289
290 #[test]
291 fn plain_text_and_bare_urls_untouched() {
292 let text = "check https://vector.app and [also] (spaced) brackets";
293 assert_eq!(strip_md_link_claims(text), text);
294 }
295
296 #[test]
297 fn multiple_links_keep_document_order() {
298 let out = strip_md_link_claims("[a](https://one.io) mid [b](https://two.io)");
299 let one = out.find("https://one.io").expect("first href kept");
300 let two = out.find("https://two.io").expect("second href kept");
301 assert!(one < two);
302 }
303
304 #[test]
305 fn nested_bracket_label_still_drops_claim() {
306 let out = strip_md_link_claims("[[https://trusted.com]](https://evil.io)");
307 assert!(!out.contains("trusted.com"), "nested-bracket claim must not reach the scan: {out}");
308 assert!(out.contains("https://evil.io"));
309 }
310
311 #[test]
312 fn escaped_bracket_label_still_drops_claim() {
313 let out = strip_md_link_claims(r"[https://trusted.com\]](https://evil.io)");
314 assert!(!out.contains("trusted.com"), "escaped-bracket claim must not reach the scan: {out}");
315 assert!(out.contains("https://evil.io"));
316 }
317
318 #[test]
319 fn paren_path_href_survives_whole() {
320 let out = strip_md_link_claims("[wiki](https://en.wikipedia.org/wiki/Foo_(bar))");
321 assert!(out.contains("https://en.wikipedia.org/wiki/Foo_(bar)"), "balanced-paren href kept intact: {out}");
322 }
323
324 #[test]
325 fn reference_link_label_dropped_definition_scanned() {
326 let out = strip_md_link_claims("[https://trusted.com][1]\n[1]: https://evil.io");
327 assert!(!out.contains("trusted.com"), "reflink claim must not reach the scan: {out}");
328 assert!(out.contains("https://evil.io"), "definition URL stays scannable: {out}");
329 }
330
331 #[test]
332 fn multibyte_label_no_panic() {
333 let out = strip_md_link_claims("[🔒 sécurisé — café](https://evil.io) 日本語");
334 assert!(out.contains("https://evil.io"));
335 assert!(out.contains("日本語"));
336 }
337
338 #[test]
343 fn valid_public_https_url_passes() {
344 assert!(validate_url_not_private("https://example.com/path").is_ok(),
345 "https://example.com should be allowed");
346 }
347
348 #[test]
349 fn valid_public_http_url_passes() {
350 assert!(validate_url_not_private("http://example.com").is_ok(),
351 "http://example.com should be allowed");
352 }
353
354 #[test]
355 fn valid_public_ip_8888_passes() {
356 assert!(validate_url_not_private("https://8.8.8.8/dns").is_ok(),
357 "8.8.8.8 (Google DNS) is a public IP and should be allowed");
358 }
359
360 #[test]
361 fn valid_public_ip_1111_passes() {
362 assert!(validate_url_not_private("https://1.1.1.1").is_ok(),
363 "1.1.1.1 (Cloudflare DNS) is a public IP and should be allowed");
364 }
365
366 #[test]
367 fn valid_url_with_port_passes() {
368 assert!(validate_url_not_private("https://example.com:8080/api").is_ok(),
369 "URL with port on public domain should be allowed");
370 }
371
372 #[test]
377 fn localhost_rejected() {
378 let result = validate_url_not_private("http://localhost/secret");
379 assert!(result.is_err(), "localhost should be rejected");
380 }
381
382 #[test]
383 fn ip_127_0_0_1_rejected() {
384 let result = validate_url_not_private("http://127.0.0.1/admin");
385 assert!(result.is_err(), "127.0.0.1 (loopback) should be rejected");
386 }
387
388 #[test]
389 fn ip_127_255_255_255_rejected() {
390 let result = validate_url_not_private("http://127.255.255.255");
391 assert!(result.is_err(), "127.255.255.255 (loopback range) should be rejected");
392 }
393
394 #[test]
399 fn private_class_a_10_rejected() {
400 let result = validate_url_not_private("http://10.0.0.1/internal");
401 assert!(result.is_err(), "10.0.0.1 (private class A) should be rejected");
402 }
403
404 #[test]
405 fn private_class_b_172_16_rejected() {
406 let result = validate_url_not_private("http://172.16.0.1/internal");
407 assert!(result.is_err(), "172.16.0.1 (private class B) should be rejected");
408 }
409
410 #[test]
411 fn private_class_b_172_31_rejected() {
412 let result = validate_url_not_private("http://172.31.255.255");
413 assert!(result.is_err(), "172.31.255.255 (private class B upper bound) should be rejected");
414 }
415
416 #[test]
417 fn private_class_c_192_168_rejected() {
418 let result = validate_url_not_private("http://192.168.1.1/router");
419 assert!(result.is_err(), "192.168.1.1 (private class C) should be rejected");
420 }
421
422 #[test]
427 fn link_local_169_254_rejected() {
428 let result = validate_url_not_private("http://169.254.1.1");
429 assert!(result.is_err(), "169.254.1.1 (link-local) should be rejected");
430 }
431
432 #[test]
433 fn cgn_100_64_rejected() {
434 let result = validate_url_not_private("http://100.64.0.1");
435 assert!(result.is_err(), "100.64.0.1 (CGN / shared address space) should be rejected");
436 }
437
438 #[test]
439 fn cgn_100_127_rejected() {
440 let result = validate_url_not_private("http://100.127.255.255");
441 assert!(result.is_err(), "100.127.255.255 (CGN upper bound) should be rejected");
442 }
443
444 #[test]
445 fn broadcast_255_rejected() {
446 let result = validate_url_not_private("http://255.255.255.255");
447 assert!(result.is_err(), "255.255.255.255 (broadcast) should be rejected");
448 }
449
450 #[test]
451 fn unspecified_0_0_0_0_rejected() {
452 let result = validate_url_not_private("http://0.0.0.0");
453 assert!(result.is_err(), "0.0.0.0 (unspecified) should be rejected");
454 }
455
456 #[test]
461 fn ipv6_loopback_rejected() {
462 let result = validate_url_not_private("http://[::1]/secret");
463 assert!(result.is_err(), "::1 (IPv6 loopback) should be rejected");
464 }
465
466 #[test]
467 fn ipv6_unique_local_fc00_rejected() {
468 let result = validate_url_not_private("http://[fc00::1]");
469 assert!(result.is_err(), "fc00::1 (IPv6 unique-local) should be rejected");
470 }
471
472 #[test]
473 fn ipv6_unique_local_fd00_rejected() {
474 let result = validate_url_not_private("http://[fd00::1]");
475 assert!(result.is_err(), "fd00::1 (IPv6 unique-local) should be rejected");
476 }
477
478 #[test]
479 fn ipv6_link_local_fe80_rejected() {
480 let result = validate_url_not_private("http://[fe80::1]");
481 assert!(result.is_err(), "fe80::1 (IPv6 link-local) should be rejected");
482 }
483
484 #[test]
485 fn ipv4_mapped_ipv6_loopback_rejected() {
486 let result = validate_url_not_private("http://[::ffff:127.0.0.1]");
487 assert!(result.is_err(), "::ffff:127.0.0.1 (IPv4-mapped loopback) should be rejected");
488 }
489
490 #[test]
491 fn ipv4_mapped_ipv6_private_rejected() {
492 let result = validate_url_not_private("http://[::ffff:192.168.1.1]");
493 assert!(result.is_err(), "::ffff:192.168.1.1 (IPv4-mapped private) should be rejected");
494 }
495
496 #[test]
501 fn dot_local_domain_rejected() {
502 let result = validate_url_not_private("http://mydevice.local/api");
503 assert!(result.is_err(), ".local domain should be rejected");
504 }
505
506 #[test]
507 fn dot_internal_domain_rejected() {
508 let result = validate_url_not_private("http://service.internal/health");
509 assert!(result.is_err(), ".internal domain should be rejected");
510 }
511
512 #[test]
517 fn ftp_scheme_rejected() {
518 let result = validate_url_not_private("ftp://example.com/file.txt");
519 assert!(result.is_err(), "ftp:// scheme should be rejected");
520 assert_eq!(result.unwrap_err(), "Only HTTP(S) URLs are allowed");
521 }
522
523 #[test]
524 fn file_scheme_rejected() {
525 let result = validate_url_not_private("file:///etc/passwd");
526 assert!(result.is_err(), "file:// scheme should be rejected");
527 assert_eq!(result.unwrap_err(), "Only HTTP(S) URLs are allowed");
528 }
529
530 #[test]
531 fn javascript_scheme_rejected() {
532 let result = validate_url_not_private("javascript:alert(1)");
533 assert!(result.is_err(), "javascript: scheme should be rejected");
534 }
535
536 #[test]
537 fn data_scheme_rejected() {
538 let result = validate_url_not_private("data:text/html,<h1>hi</h1>");
539 assert!(result.is_err(), "data: scheme should be rejected");
540 }
541
542 #[test]
547 fn no_host_rejected() {
548 let result = validate_url_not_private("http://");
550 assert!(result.is_err(), "URL with no host should be rejected");
551 }
552
553 #[test]
554 fn invalid_url_rejected() {
555 let result = validate_url_not_private("not a url at all");
556 assert!(result.is_err(), "invalid URL string should be rejected");
557 assert_eq!(result.unwrap_err(), "Invalid URL");
558 }
559
560 #[test]
561 fn empty_string_rejected() {
562 let result = validate_url_not_private("");
563 assert!(result.is_err(), "empty string should be rejected");
564 }
565
566 #[test]
571 fn cgn_100_63_not_rejected() {
572 assert!(validate_url_not_private("http://100.63.255.255").is_ok(),
574 "100.63.255.255 is outside CGN range and should be allowed");
575 }
576
577 #[test]
578 fn cgn_100_128_not_rejected() {
579 assert!(validate_url_not_private("http://100.128.0.1").is_ok(),
581 "100.128.0.1 is outside CGN range and should be allowed");
582 }
583
584 #[test]
585 fn private_172_15_not_rejected() {
586 assert!(validate_url_not_private("http://172.15.255.255").is_ok(),
588 "172.15.255.255 is outside private class B range and should be allowed");
589 }
590
591 #[test]
592 fn private_172_32_not_rejected() {
593 assert!(validate_url_not_private("http://172.32.0.1").is_ok(),
595 "172.32.0.1 is outside private class B range and should be allowed");
596 }
597}
598
599pub async fn get_remote_file_size(url: &str) -> Option<u64> {
606 validate_url_not_private(url).ok()?;
607 let client = build_http_client(std::time::Duration::from_secs(8)).ok()?;
608
609 if let Ok(head_res) = client.head(url).send().await {
611 if let Some(length) = head_res.content_length() {
612 if length > 0 {
613 return Some(length);
614 }
615 }
616 }
617
618 if let Ok(partial_res) = client
620 .get(url)
621 .header("Range", "bytes=0-1")
622 .send()
623 .await
624 {
625 if let Some(content_range) = partial_res.headers().get("content-range") {
626 if let Ok(range_str) = content_range.to_str() {
627 if let Some(size_part) = range_str.split('/').nth(1) {
628 if let Ok(size) = size_part.parse::<u64>() {
629 return Some(size);
630 }
631 }
632 }
633 }
634 if let Some(length) = partial_res.content_length() {
635 if length > 100 {
636 return Some(length);
637 }
638 }
639 }
640
641 None
642}