Skip to main content

vector_core/
net.rs

1//! Network utilities — SSRF protection, HTTP client helpers.
2
3use url::Url;
4
5/// Reject URLs that resolve to private/loopback/link-local addresses (SSRF protection).
6pub 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; } // Unique local
46    if segments[0] & 0xffc0 == 0xfe80 { return true; } // Link-local
47    false
48}
49
50/// Build an HTTP client with the given timeout.
51///
52/// Honors the Tor failsafe: when the user has Tor enabled, every connection
53/// goes through Tor — period. If Tor is enabled but not currently running
54/// (bootstrap in flight, mid-restart, service crashed), the returned client
55/// is wired to a blackhole SOCKS proxy so requests fail at the TCP layer
56/// without any chance of leaking clearnet traffic. Direct connections are
57/// only ever issued when the user has explicitly disabled Tor.
58///
59/// Callers should use this rather than `reqwest::Client::builder()` directly
60/// so the failsafe automatically covers their traffic. The `disallowed_methods`
61/// clippy lint enforces this everywhere except this one canonical call site.
62#[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/// Like `build_http_client`, optionally without redirect-following.
68/// Blossom PUT uses `false`: a 3xx mid-upload would re-issue as GET and
69/// drop the body, so we surface the 3xx as the real failure status.
70#[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        // Validate EVERY redirect hop, not just the initial URL: a public
80        // host answering `302 Location: http://169.254.169.254/…` would
81        // otherwise walk the request straight past the SSRF check.
82        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                // Use the addr from the variant directly — re-querying via
98                // proxy_url() races against TorService::stop() and can panic.
99                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                // Tor failsafe: route to a blackhole so connections fail safe
106                // instead of leaking direct.
107                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                // No proxy — user has Tor off.
114            }
115        }
116    }
117
118    builder
119        .build()
120        .map_err(|e| format!("Failed to build HTTP client: {}", e))
121}
122
123// ============================================================================
124// Shared HTTP client — proxy-aware + rebuildable on Tor toggle
125// ============================================================================
126//
127// Some call sites (image-cache fetches, PIVX wallet polling) make frequent
128// requests and benefit from a shared `reqwest::Client` to reuse connection
129// pools / TLS sessions. A bare `LazyLock<Client>` doesn't work for us because
130// a Tor toggle should affect future requests immediately — but the static is
131// frozen at first init. Instead, we hold an `Arc<Client>` behind a `RwLock`
132// and rebuild it via `rebuild_shared_http_client()` whenever the Tor state
133// changes. In-flight requests finish on the old Arc; new requests pick up
134// the new one.
135
136use 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
150/// Get a shared HTTP client. Cheap clone (Arc), proxy-aware, picks up Tor
151/// toggles on the next call after `rebuild_shared_http_client()` runs.
152pub fn shared_http_client() -> Arc<reqwest::Client> {
153    shared_cell().read().unwrap().clone()
154}
155
156/// Rebuild the shared client. Call this when Tor state flips so the next
157/// request goes through the freshly-configured proxy. In-flight requests on
158/// the old client continue to completion on the previous Arc.
159pub 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    // ========================================================================
170    // Valid public URLs — should pass
171    // ========================================================================
172
173    #[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    // ========================================================================
204    // Loopback addresses — should be rejected
205    // ========================================================================
206
207    #[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    // ========================================================================
226    // Private IP ranges — should be rejected
227    // ========================================================================
228
229    #[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    // ========================================================================
254    // Special addresses — should be rejected
255    // ========================================================================
256
257    #[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    // ========================================================================
288    // IPv6 addresses — should be rejected
289    // ========================================================================
290
291    #[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    // ========================================================================
328    // Domain name restrictions
329    // ========================================================================
330
331    #[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    // ========================================================================
344    // Scheme restrictions
345    // ========================================================================
346
347    #[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    // ========================================================================
374    // Missing / invalid URL
375    // ========================================================================
376
377    #[test]
378    fn no_host_rejected() {
379        // http:// with no host is actually an invalid URL for the url crate
380        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    // ========================================================================
398    // Edge cases
399    // ========================================================================
400
401    #[test]
402    fn cgn_100_63_not_rejected() {
403        // 100.63.x.x is NOT in the CGN range (100.64-100.127)
404        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        // 100.128.x.x is NOT in the CGN range
411        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        // 172.15.x.x is NOT private (private is 172.16-172.31)
418        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        // 172.32.x.x is NOT private
425        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
430// ============================================================================
431// Remote File Size
432// ============================================================================
433
434/// Get the size of a remote file via HEAD request or Range fallback.
435/// Returns None if the URL is private, unreachable, or size can't be determined.
436pub 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    // Method 1: HEAD request
441    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    // Method 2: Range request fallback
450    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}