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
50pub use reqwest;
56
57#[allow(clippy::disallowed_methods)]
70pub fn build_http_client(timeout: std::time::Duration) -> Result<reqwest::Client, String> {
71 build_http_client_with_options(timeout, None, true)
72}
73
74#[allow(clippy::disallowed_methods)]
78pub fn build_http_client_with_options(
79 timeout: std::time::Duration,
80 read_timeout: Option<std::time::Duration>,
81 follow_redirects: bool,
82) -> Result<reqwest::Client, String> {
83 let mut builder = reqwest::Client::builder()
84 .timeout(timeout)
85 .connect_timeout(std::time::Duration::from_secs(15));
88 if let Some(rt) = read_timeout {
94 builder = builder.read_timeout(rt);
95 }
96 if !follow_redirects {
97 builder = builder.redirect(reqwest::redirect::Policy::none());
98 } else {
99 builder = builder.redirect(reqwest::redirect::Policy::custom(|attempt| {
103 if attempt.previous().len() >= 10 {
104 return attempt.error("too many redirects");
105 }
106 match validate_url_not_private(attempt.url().as_str()) {
107 Ok(()) => attempt.follow(),
108 Err(e) => attempt.error(e),
109 }
110 }));
111 }
112
113 #[cfg(feature = "tor")]
114 {
115 match crate::tor::transport_state() {
116 crate::tor::TorTransportState::Active(addr) => {
117 let url = format!("socks5h://{addr}");
120 let proxy = reqwest::Proxy::all(&url)
121 .map_err(|e| format!("Tor proxy URL ({url}) invalid: {e}"))?;
122 builder = builder.proxy(proxy);
123 builder = builder.connect_timeout(std::time::Duration::from_secs(45));
125 }
126 crate::tor::TorTransportState::RequiredButInactive => {
127 let url = format!("socks5h://{}", crate::tor::blackhole_proxy_addr());
130 let proxy = reqwest::Proxy::all(&url)
131 .map_err(|e| format!("blackhole proxy invalid: {e}"))?;
132 builder = builder.proxy(proxy);
133 }
134 crate::tor::TorTransportState::Disabled => {
135 }
137 }
138 }
139
140 builder
141 .build()
142 .map_err(|e| format!("Failed to build HTTP client: {}", e))
143}
144
145use std::sync::{Arc, OnceLock, RwLock};
159
160static SHARED_HTTP_CLIENT: OnceLock<RwLock<Arc<reqwest::Client>>> = OnceLock::new();
161
162const DEFAULT_SHARED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
163
164fn shared_cell() -> &'static RwLock<Arc<reqwest::Client>> {
165 SHARED_HTTP_CLIENT.get_or_init(|| {
166 let client = build_http_client(DEFAULT_SHARED_TIMEOUT)
167 .expect("initial shared HTTP client build cannot fail");
168 RwLock::new(Arc::new(client))
169 })
170}
171
172pub fn shared_http_client() -> Arc<reqwest::Client> {
175 shared_cell().read().unwrap().clone()
176}
177
178pub fn rebuild_shared_http_client() -> Result<(), String> {
182 let new = Arc::new(build_http_client(DEFAULT_SHARED_TIMEOUT)?);
183 *shared_cell().write().unwrap() = new;
184 Ok(())
185}
186
187fn md_group_close(bytes: &[u8], start: usize, open: u8, close: u8) -> Option<usize> {
193 let mut depth = 1usize;
194 let mut escaped = false;
195 let mut j = start;
196 loop {
197 match bytes.get(j).copied() {
198 None => return None,
199 Some(b'\\') if !escaped => escaped = true,
200 Some(b) if b == open && !escaped => depth += 1,
201 Some(b) if b == close && !escaped => {
202 depth -= 1;
203 if depth == 0 {
204 return Some(j);
205 }
206 }
207 _ => escaped = false,
208 }
209 j += 1;
210 }
211}
212
213pub fn strip_md_link_claims(text: &str) -> String {
221 let bytes = text.as_bytes();
222 let mut out = String::with_capacity(text.len());
223 let mut last = 0;
224 let mut i = 0;
225 while i < bytes.len() {
226 if bytes[i] != b'[' || (i > 0 && bytes[i - 1] == b'!') {
227 i += 1;
228 continue;
229 }
230 let Some(close) = md_group_close(bytes, i + 1, b'[', b']') else { break };
231 match bytes.get(close + 1).copied() {
232 Some(b'(') => {
234 let Some(paren) = md_group_close(bytes, close + 2, b'(', b')') else {
235 i = close + 1;
236 continue;
237 };
238 out.push_str(&text[last..i]);
239 let href = text[close + 2..paren].trim();
240 if !(href.starts_with('<') && href.ends_with('>')) {
241 out.push(' ');
242 out.push_str(href);
243 out.push(' ');
244 }
245 i = paren + 1;
246 last = i;
247 }
248 Some(b'[') => {
251 let Some(ref_close) = md_group_close(bytes, close + 2, b'[', b']') else {
252 i = close + 1;
253 continue;
254 };
255 out.push_str(&text[last..i]);
256 i = ref_close + 1;
257 last = i;
258 }
259 _ => {
260 i = close + 1;
261 }
262 }
263 }
264 out.push_str(&text[last..]);
265 out
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
277 fn md_link_claim_text_dropped_href_kept() {
278 let out = strip_md_link_claims("[https://your-bank.com](https://evil.io)");
280 assert!(!out.contains("your-bank.com"), "claimed text must not reach the scan: {out}");
281 assert!(out.contains("https://evil.io"), "real destination must reach the scan: {out}");
282 }
283
284 #[test]
285 fn md_no_preview_link_dropped_entirely() {
286 let out = strip_md_link_claims("see [docs](<https://vector.app/docs>) ok");
287 assert!(!out.contains("vector.app"), "no-preview href must not reach the scan: {out}");
288 assert!(out.contains("see ") && out.contains(" ok"));
289 }
290
291 #[test]
292 fn md_image_passes_through() {
293 let text = "";
294 assert_eq!(strip_md_link_claims(text), text);
295 }
296
297 #[test]
298 fn plain_text_and_bare_urls_untouched() {
299 let text = "check https://vector.app and [also] (spaced) brackets";
300 assert_eq!(strip_md_link_claims(text), text);
301 }
302
303 #[test]
304 fn multiple_links_keep_document_order() {
305 let out = strip_md_link_claims("[a](https://one.io) mid [b](https://two.io)");
306 let one = out.find("https://one.io").expect("first href kept");
307 let two = out.find("https://two.io").expect("second href kept");
308 assert!(one < two);
309 }
310
311 #[test]
312 fn nested_bracket_label_still_drops_claim() {
313 let out = strip_md_link_claims("[[https://trusted.com]](https://evil.io)");
314 assert!(!out.contains("trusted.com"), "nested-bracket claim must not reach the scan: {out}");
315 assert!(out.contains("https://evil.io"));
316 }
317
318 #[test]
319 fn escaped_bracket_label_still_drops_claim() {
320 let out = strip_md_link_claims(r"[https://trusted.com\]](https://evil.io)");
321 assert!(!out.contains("trusted.com"), "escaped-bracket claim must not reach the scan: {out}");
322 assert!(out.contains("https://evil.io"));
323 }
324
325 #[test]
326 fn paren_path_href_survives_whole() {
327 let out = strip_md_link_claims("[wiki](https://en.wikipedia.org/wiki/Foo_(bar))");
328 assert!(out.contains("https://en.wikipedia.org/wiki/Foo_(bar)"), "balanced-paren href kept intact: {out}");
329 }
330
331 #[test]
332 fn reference_link_label_dropped_definition_scanned() {
333 let out = strip_md_link_claims("[https://trusted.com][1]\n[1]: https://evil.io");
334 assert!(!out.contains("trusted.com"), "reflink claim must not reach the scan: {out}");
335 assert!(out.contains("https://evil.io"), "definition URL stays scannable: {out}");
336 }
337
338 #[test]
339 fn multibyte_label_no_panic() {
340 let out = strip_md_link_claims("[🔒 sécurisé — café](https://evil.io) 日本語");
341 assert!(out.contains("https://evil.io"));
342 assert!(out.contains("日本語"));
343 }
344
345 #[test]
350 fn valid_public_https_url_passes() {
351 assert!(validate_url_not_private("https://example.com/path").is_ok(),
352 "https://example.com should be allowed");
353 }
354
355 #[test]
356 fn valid_public_http_url_passes() {
357 assert!(validate_url_not_private("http://example.com").is_ok(),
358 "http://example.com should be allowed");
359 }
360
361 #[test]
362 fn valid_public_ip_8888_passes() {
363 assert!(validate_url_not_private("https://8.8.8.8/dns").is_ok(),
364 "8.8.8.8 (Google DNS) is a public IP and should be allowed");
365 }
366
367 #[test]
368 fn valid_public_ip_1111_passes() {
369 assert!(validate_url_not_private("https://1.1.1.1").is_ok(),
370 "1.1.1.1 (Cloudflare DNS) is a public IP and should be allowed");
371 }
372
373 #[test]
374 fn valid_url_with_port_passes() {
375 assert!(validate_url_not_private("https://example.com:8080/api").is_ok(),
376 "URL with port on public domain should be allowed");
377 }
378
379 #[test]
384 fn localhost_rejected() {
385 let result = validate_url_not_private("http://localhost/secret");
386 assert!(result.is_err(), "localhost should be rejected");
387 }
388
389 #[test]
390 fn ip_127_0_0_1_rejected() {
391 let result = validate_url_not_private("http://127.0.0.1/admin");
392 assert!(result.is_err(), "127.0.0.1 (loopback) should be rejected");
393 }
394
395 #[test]
396 fn ip_127_255_255_255_rejected() {
397 let result = validate_url_not_private("http://127.255.255.255");
398 assert!(result.is_err(), "127.255.255.255 (loopback range) should be rejected");
399 }
400
401 #[test]
406 fn private_class_a_10_rejected() {
407 let result = validate_url_not_private("http://10.0.0.1/internal");
408 assert!(result.is_err(), "10.0.0.1 (private class A) should be rejected");
409 }
410
411 #[test]
412 fn private_class_b_172_16_rejected() {
413 let result = validate_url_not_private("http://172.16.0.1/internal");
414 assert!(result.is_err(), "172.16.0.1 (private class B) should be rejected");
415 }
416
417 #[test]
418 fn private_class_b_172_31_rejected() {
419 let result = validate_url_not_private("http://172.31.255.255");
420 assert!(result.is_err(), "172.31.255.255 (private class B upper bound) should be rejected");
421 }
422
423 #[test]
424 fn private_class_c_192_168_rejected() {
425 let result = validate_url_not_private("http://192.168.1.1/router");
426 assert!(result.is_err(), "192.168.1.1 (private class C) should be rejected");
427 }
428
429 #[test]
434 fn link_local_169_254_rejected() {
435 let result = validate_url_not_private("http://169.254.1.1");
436 assert!(result.is_err(), "169.254.1.1 (link-local) should be rejected");
437 }
438
439 #[test]
440 fn cgn_100_64_rejected() {
441 let result = validate_url_not_private("http://100.64.0.1");
442 assert!(result.is_err(), "100.64.0.1 (CGN / shared address space) should be rejected");
443 }
444
445 #[test]
446 fn cgn_100_127_rejected() {
447 let result = validate_url_not_private("http://100.127.255.255");
448 assert!(result.is_err(), "100.127.255.255 (CGN upper bound) should be rejected");
449 }
450
451 #[test]
452 fn broadcast_255_rejected() {
453 let result = validate_url_not_private("http://255.255.255.255");
454 assert!(result.is_err(), "255.255.255.255 (broadcast) should be rejected");
455 }
456
457 #[test]
458 fn unspecified_0_0_0_0_rejected() {
459 let result = validate_url_not_private("http://0.0.0.0");
460 assert!(result.is_err(), "0.0.0.0 (unspecified) should be rejected");
461 }
462
463 #[test]
468 fn ipv6_loopback_rejected() {
469 let result = validate_url_not_private("http://[::1]/secret");
470 assert!(result.is_err(), "::1 (IPv6 loopback) should be rejected");
471 }
472
473 #[test]
474 fn ipv6_unique_local_fc00_rejected() {
475 let result = validate_url_not_private("http://[fc00::1]");
476 assert!(result.is_err(), "fc00::1 (IPv6 unique-local) should be rejected");
477 }
478
479 #[test]
480 fn ipv6_unique_local_fd00_rejected() {
481 let result = validate_url_not_private("http://[fd00::1]");
482 assert!(result.is_err(), "fd00::1 (IPv6 unique-local) should be rejected");
483 }
484
485 #[test]
486 fn ipv6_link_local_fe80_rejected() {
487 let result = validate_url_not_private("http://[fe80::1]");
488 assert!(result.is_err(), "fe80::1 (IPv6 link-local) should be rejected");
489 }
490
491 #[test]
492 fn ipv4_mapped_ipv6_loopback_rejected() {
493 let result = validate_url_not_private("http://[::ffff:127.0.0.1]");
494 assert!(result.is_err(), "::ffff:127.0.0.1 (IPv4-mapped loopback) should be rejected");
495 }
496
497 #[test]
498 fn ipv4_mapped_ipv6_private_rejected() {
499 let result = validate_url_not_private("http://[::ffff:192.168.1.1]");
500 assert!(result.is_err(), "::ffff:192.168.1.1 (IPv4-mapped private) should be rejected");
501 }
502
503 #[test]
508 fn dot_local_domain_rejected() {
509 let result = validate_url_not_private("http://mydevice.local/api");
510 assert!(result.is_err(), ".local domain should be rejected");
511 }
512
513 #[test]
514 fn dot_internal_domain_rejected() {
515 let result = validate_url_not_private("http://service.internal/health");
516 assert!(result.is_err(), ".internal domain should be rejected");
517 }
518
519 #[test]
524 fn ftp_scheme_rejected() {
525 let result = validate_url_not_private("ftp://example.com/file.txt");
526 assert!(result.is_err(), "ftp:// scheme should be rejected");
527 assert_eq!(result.unwrap_err(), "Only HTTP(S) URLs are allowed");
528 }
529
530 #[test]
531 fn file_scheme_rejected() {
532 let result = validate_url_not_private("file:///etc/passwd");
533 assert!(result.is_err(), "file:// scheme should be rejected");
534 assert_eq!(result.unwrap_err(), "Only HTTP(S) URLs are allowed");
535 }
536
537 #[test]
538 fn javascript_scheme_rejected() {
539 let result = validate_url_not_private("javascript:alert(1)");
540 assert!(result.is_err(), "javascript: scheme should be rejected");
541 }
542
543 #[test]
544 fn data_scheme_rejected() {
545 let result = validate_url_not_private("data:text/html,<h1>hi</h1>");
546 assert!(result.is_err(), "data: scheme should be rejected");
547 }
548
549 #[test]
554 fn no_host_rejected() {
555 let result = validate_url_not_private("http://");
557 assert!(result.is_err(), "URL with no host should be rejected");
558 }
559
560 #[test]
561 fn invalid_url_rejected() {
562 let result = validate_url_not_private("not a url at all");
563 assert!(result.is_err(), "invalid URL string should be rejected");
564 assert_eq!(result.unwrap_err(), "Invalid URL");
565 }
566
567 #[test]
568 fn empty_string_rejected() {
569 let result = validate_url_not_private("");
570 assert!(result.is_err(), "empty string should be rejected");
571 }
572
573 #[test]
578 fn cgn_100_63_not_rejected() {
579 assert!(validate_url_not_private("http://100.63.255.255").is_ok(),
581 "100.63.255.255 is outside CGN range and should be allowed");
582 }
583
584 #[test]
585 fn cgn_100_128_not_rejected() {
586 assert!(validate_url_not_private("http://100.128.0.1").is_ok(),
588 "100.128.0.1 is outside CGN range and should be allowed");
589 }
590
591 #[test]
592 fn private_172_15_not_rejected() {
593 assert!(validate_url_not_private("http://172.15.255.255").is_ok(),
595 "172.15.255.255 is outside private class B range and should be allowed");
596 }
597
598 #[test]
599 fn private_172_32_not_rejected() {
600 assert!(validate_url_not_private("http://172.32.0.1").is_ok(),
602 "172.32.0.1 is outside private class B range and should be allowed");
603 }
604}
605
606pub async fn get_remote_file_size(url: &str) -> Option<u64> {
613 validate_url_not_private(url).ok()?;
614 let client = build_http_client(std::time::Duration::from_secs(8)).ok()?;
615
616 if let Ok(head_res) = client.head(url).send().await {
618 if let Some(length) = head_res.content_length() {
619 if length > 0 {
620 return Some(length);
621 }
622 }
623 }
624
625 if let Ok(partial_res) = client
627 .get(url)
628 .header("Range", "bytes=0-1")
629 .send()
630 .await
631 {
632 if let Some(content_range) = partial_res.headers().get("content-range") {
633 if let Ok(range_str) = content_range.to_str() {
634 if let Some(size_part) = range_str.split('/').nth(1) {
635 if let Ok(size) = size_part.parse::<u64>() {
636 return Some(size);
637 }
638 }
639 }
640 }
641 if let Some(length) = partial_res.content_length() {
642 if length > 100 {
643 return Some(length);
644 }
645 }
646 }
647
648 None
649}