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, true)
65}
66
67#[allow(clippy::disallowed_methods)]
71pub fn build_http_client_with_options(
72 timeout: std::time::Duration,
73 follow_redirects: bool,
74) -> Result<reqwest::Client, String> {
75 let mut builder = reqwest::Client::builder().timeout(timeout);
76 if !follow_redirects {
77 builder = builder.redirect(reqwest::redirect::Policy::none());
78 } else {
79 builder = builder.redirect(reqwest::redirect::Policy::custom(|attempt| {
83 if attempt.previous().len() >= 10 {
84 return attempt.error("too many redirects");
85 }
86 match validate_url_not_private(attempt.url().as_str()) {
87 Ok(()) => attempt.follow(),
88 Err(e) => attempt.error(e),
89 }
90 }));
91 }
92
93 #[cfg(feature = "tor")]
94 {
95 match crate::tor::transport_state() {
96 crate::tor::TorTransportState::Active(addr) => {
97 let url = format!("socks5h://{addr}");
100 let proxy = reqwest::Proxy::all(&url)
101 .map_err(|e| format!("Tor proxy URL ({url}) invalid: {e}"))?;
102 builder = builder.proxy(proxy);
103 }
104 crate::tor::TorTransportState::RequiredButInactive => {
105 let url = format!("socks5h://{}", crate::tor::blackhole_proxy_addr());
108 let proxy = reqwest::Proxy::all(&url)
109 .map_err(|e| format!("blackhole proxy invalid: {e}"))?;
110 builder = builder.proxy(proxy);
111 }
112 crate::tor::TorTransportState::Disabled => {
113 }
115 }
116 }
117
118 builder
119 .build()
120 .map_err(|e| format!("Failed to build HTTP client: {}", e))
121}
122
123use std::sync::{Arc, OnceLock, RwLock};
137
138static SHARED_HTTP_CLIENT: OnceLock<RwLock<Arc<reqwest::Client>>> = OnceLock::new();
139
140const DEFAULT_SHARED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
141
142fn shared_cell() -> &'static RwLock<Arc<reqwest::Client>> {
143 SHARED_HTTP_CLIENT.get_or_init(|| {
144 let client = build_http_client(DEFAULT_SHARED_TIMEOUT)
145 .expect("initial shared HTTP client build cannot fail");
146 RwLock::new(Arc::new(client))
147 })
148}
149
150pub fn shared_http_client() -> Arc<reqwest::Client> {
153 shared_cell().read().unwrap().clone()
154}
155
156pub fn rebuild_shared_http_client() -> Result<(), String> {
160 let new = Arc::new(build_http_client(DEFAULT_SHARED_TIMEOUT)?);
161 *shared_cell().write().unwrap() = new;
162 Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
174 fn valid_public_https_url_passes() {
175 assert!(validate_url_not_private("https://example.com/path").is_ok(),
176 "https://example.com should be allowed");
177 }
178
179 #[test]
180 fn valid_public_http_url_passes() {
181 assert!(validate_url_not_private("http://example.com").is_ok(),
182 "http://example.com should be allowed");
183 }
184
185 #[test]
186 fn valid_public_ip_8888_passes() {
187 assert!(validate_url_not_private("https://8.8.8.8/dns").is_ok(),
188 "8.8.8.8 (Google DNS) is a public IP and should be allowed");
189 }
190
191 #[test]
192 fn valid_public_ip_1111_passes() {
193 assert!(validate_url_not_private("https://1.1.1.1").is_ok(),
194 "1.1.1.1 (Cloudflare DNS) is a public IP and should be allowed");
195 }
196
197 #[test]
198 fn valid_url_with_port_passes() {
199 assert!(validate_url_not_private("https://example.com:8080/api").is_ok(),
200 "URL with port on public domain should be allowed");
201 }
202
203 #[test]
208 fn localhost_rejected() {
209 let result = validate_url_not_private("http://localhost/secret");
210 assert!(result.is_err(), "localhost should be rejected");
211 }
212
213 #[test]
214 fn ip_127_0_0_1_rejected() {
215 let result = validate_url_not_private("http://127.0.0.1/admin");
216 assert!(result.is_err(), "127.0.0.1 (loopback) should be rejected");
217 }
218
219 #[test]
220 fn ip_127_255_255_255_rejected() {
221 let result = validate_url_not_private("http://127.255.255.255");
222 assert!(result.is_err(), "127.255.255.255 (loopback range) should be rejected");
223 }
224
225 #[test]
230 fn private_class_a_10_rejected() {
231 let result = validate_url_not_private("http://10.0.0.1/internal");
232 assert!(result.is_err(), "10.0.0.1 (private class A) should be rejected");
233 }
234
235 #[test]
236 fn private_class_b_172_16_rejected() {
237 let result = validate_url_not_private("http://172.16.0.1/internal");
238 assert!(result.is_err(), "172.16.0.1 (private class B) should be rejected");
239 }
240
241 #[test]
242 fn private_class_b_172_31_rejected() {
243 let result = validate_url_not_private("http://172.31.255.255");
244 assert!(result.is_err(), "172.31.255.255 (private class B upper bound) should be rejected");
245 }
246
247 #[test]
248 fn private_class_c_192_168_rejected() {
249 let result = validate_url_not_private("http://192.168.1.1/router");
250 assert!(result.is_err(), "192.168.1.1 (private class C) should be rejected");
251 }
252
253 #[test]
258 fn link_local_169_254_rejected() {
259 let result = validate_url_not_private("http://169.254.1.1");
260 assert!(result.is_err(), "169.254.1.1 (link-local) should be rejected");
261 }
262
263 #[test]
264 fn cgn_100_64_rejected() {
265 let result = validate_url_not_private("http://100.64.0.1");
266 assert!(result.is_err(), "100.64.0.1 (CGN / shared address space) should be rejected");
267 }
268
269 #[test]
270 fn cgn_100_127_rejected() {
271 let result = validate_url_not_private("http://100.127.255.255");
272 assert!(result.is_err(), "100.127.255.255 (CGN upper bound) should be rejected");
273 }
274
275 #[test]
276 fn broadcast_255_rejected() {
277 let result = validate_url_not_private("http://255.255.255.255");
278 assert!(result.is_err(), "255.255.255.255 (broadcast) should be rejected");
279 }
280
281 #[test]
282 fn unspecified_0_0_0_0_rejected() {
283 let result = validate_url_not_private("http://0.0.0.0");
284 assert!(result.is_err(), "0.0.0.0 (unspecified) should be rejected");
285 }
286
287 #[test]
292 fn ipv6_loopback_rejected() {
293 let result = validate_url_not_private("http://[::1]/secret");
294 assert!(result.is_err(), "::1 (IPv6 loopback) should be rejected");
295 }
296
297 #[test]
298 fn ipv6_unique_local_fc00_rejected() {
299 let result = validate_url_not_private("http://[fc00::1]");
300 assert!(result.is_err(), "fc00::1 (IPv6 unique-local) should be rejected");
301 }
302
303 #[test]
304 fn ipv6_unique_local_fd00_rejected() {
305 let result = validate_url_not_private("http://[fd00::1]");
306 assert!(result.is_err(), "fd00::1 (IPv6 unique-local) should be rejected");
307 }
308
309 #[test]
310 fn ipv6_link_local_fe80_rejected() {
311 let result = validate_url_not_private("http://[fe80::1]");
312 assert!(result.is_err(), "fe80::1 (IPv6 link-local) should be rejected");
313 }
314
315 #[test]
316 fn ipv4_mapped_ipv6_loopback_rejected() {
317 let result = validate_url_not_private("http://[::ffff:127.0.0.1]");
318 assert!(result.is_err(), "::ffff:127.0.0.1 (IPv4-mapped loopback) should be rejected");
319 }
320
321 #[test]
322 fn ipv4_mapped_ipv6_private_rejected() {
323 let result = validate_url_not_private("http://[::ffff:192.168.1.1]");
324 assert!(result.is_err(), "::ffff:192.168.1.1 (IPv4-mapped private) should be rejected");
325 }
326
327 #[test]
332 fn dot_local_domain_rejected() {
333 let result = validate_url_not_private("http://mydevice.local/api");
334 assert!(result.is_err(), ".local domain should be rejected");
335 }
336
337 #[test]
338 fn dot_internal_domain_rejected() {
339 let result = validate_url_not_private("http://service.internal/health");
340 assert!(result.is_err(), ".internal domain should be rejected");
341 }
342
343 #[test]
348 fn ftp_scheme_rejected() {
349 let result = validate_url_not_private("ftp://example.com/file.txt");
350 assert!(result.is_err(), "ftp:// scheme should be rejected");
351 assert_eq!(result.unwrap_err(), "Only HTTP(S) URLs are allowed");
352 }
353
354 #[test]
355 fn file_scheme_rejected() {
356 let result = validate_url_not_private("file:///etc/passwd");
357 assert!(result.is_err(), "file:// scheme should be rejected");
358 assert_eq!(result.unwrap_err(), "Only HTTP(S) URLs are allowed");
359 }
360
361 #[test]
362 fn javascript_scheme_rejected() {
363 let result = validate_url_not_private("javascript:alert(1)");
364 assert!(result.is_err(), "javascript: scheme should be rejected");
365 }
366
367 #[test]
368 fn data_scheme_rejected() {
369 let result = validate_url_not_private("data:text/html,<h1>hi</h1>");
370 assert!(result.is_err(), "data: scheme should be rejected");
371 }
372
373 #[test]
378 fn no_host_rejected() {
379 let result = validate_url_not_private("http://");
381 assert!(result.is_err(), "URL with no host should be rejected");
382 }
383
384 #[test]
385 fn invalid_url_rejected() {
386 let result = validate_url_not_private("not a url at all");
387 assert!(result.is_err(), "invalid URL string should be rejected");
388 assert_eq!(result.unwrap_err(), "Invalid URL");
389 }
390
391 #[test]
392 fn empty_string_rejected() {
393 let result = validate_url_not_private("");
394 assert!(result.is_err(), "empty string should be rejected");
395 }
396
397 #[test]
402 fn cgn_100_63_not_rejected() {
403 assert!(validate_url_not_private("http://100.63.255.255").is_ok(),
405 "100.63.255.255 is outside CGN range and should be allowed");
406 }
407
408 #[test]
409 fn cgn_100_128_not_rejected() {
410 assert!(validate_url_not_private("http://100.128.0.1").is_ok(),
412 "100.128.0.1 is outside CGN range and should be allowed");
413 }
414
415 #[test]
416 fn private_172_15_not_rejected() {
417 assert!(validate_url_not_private("http://172.15.255.255").is_ok(),
419 "172.15.255.255 is outside private class B range and should be allowed");
420 }
421
422 #[test]
423 fn private_172_32_not_rejected() {
424 assert!(validate_url_not_private("http://172.32.0.1").is_ok(),
426 "172.32.0.1 is outside private class B range and should be allowed");
427 }
428}
429
430pub async fn get_remote_file_size(url: &str) -> Option<u64> {
437 validate_url_not_private(url).ok()?;
438 let client = build_http_client(std::time::Duration::from_secs(8)).ok()?;
439
440 if let Ok(head_res) = client.head(url).send().await {
442 if let Some(length) = head_res.content_length() {
443 if length > 0 {
444 return Some(length);
445 }
446 }
447 }
448
449 if let Ok(partial_res) = client
451 .get(url)
452 .header("Range", "bytes=0-1")
453 .send()
454 .await
455 {
456 if let Some(content_range) = partial_res.headers().get("content-range") {
457 if let Ok(range_str) = content_range.to_str() {
458 if let Some(size_part) = range_str.split('/').nth(1) {
459 if let Ok(size) = size_part.parse::<u64>() {
460 return Some(size);
461 }
462 }
463 }
464 }
465 if let Some(length) = partial_res.content_length() {
466 if length > 100 {
467 return Some(length);
468 }
469 }
470 }
471
472 None
473}