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/// The exact `reqwest` these clients are built from.
51///
52/// Re-exported because [`build_http_client`] hands back a `reqwest::Client`: a
53/// consumer that names its own `reqwest` version gets a different type with the
54/// same name, and the error says nothing useful about why.
55pub use reqwest;
56
57/// How long a transfer may go without moving a byte before it is given up on.
58///
59/// This is the only time limit a large transfer has. A total deadline caps the
60/// file size a slow link can ever move (300 s at 1 Mbit/s is 36 MB), so
61/// uploads and downloads are bounded by progress instead: any rate at all
62/// keeps them alive, and only a dead connection is abandoned.
63///
64/// Two minutes rather than one because TCP's own retransmit backoff on a
65/// lossy long-haul path reaches that between attempts: a shorter window
66/// abandons a connection the kernel is still recovering. Servers we run keep
67/// their gap timers above this, so the client is the one that gives up and
68/// can say why, instead of meeting a socket the proxy already closed.
69pub const TRANSFER_STALL: std::time::Duration = std::time::Duration::from_secs(120);
70
71/// Build an HTTP client with the given total timeout.
72///
73/// Honors the Tor failsafe: when the user has Tor enabled, every connection
74/// goes through Tor — period. If Tor is enabled but not currently running
75/// (bootstrap in flight, mid-restart, service crashed), the returned client
76/// is wired to a blackhole SOCKS proxy so requests fail at the TCP layer
77/// without any chance of leaking clearnet traffic. Direct connections are
78/// only ever issued when the user has explicitly disabled Tor.
79///
80/// Callers should use this rather than `reqwest::Client::builder()` directly
81/// so the failsafe automatically covers their traffic. The `disallowed_methods`
82/// clippy lint enforces this everywhere except this one canonical call site.
83#[allow(clippy::disallowed_methods)]
84pub fn build_http_client(timeout: std::time::Duration) -> Result<reqwest::Client, String> {
85    build_http_client_with_options(Some(timeout), None, true)
86}
87
88/// Like `build_http_client`, with the total timeout optional and
89/// redirect-following switchable.
90///
91/// `timeout` is a deadline on the whole request; `None` for large transfers,
92/// which are bounded by progress instead (see [`TRANSFER_STALL`]).
93///
94/// `read_timeout` resets on every byte of the response *body*. Before the
95/// headers arrive it is a single deadline from the start of the request that
96/// nothing resets — including bytes of a request body going out — so it must
97/// stay `None` on an upload whose body may take longer than it.
98///
99/// Blossom PUT uses `follow_redirects = false`: a 3xx mid-upload would
100/// re-issue as GET and drop the body, so the 3xx surfaces as the real status.
101/// What every request calls itself. Set once by the app with its own
102/// version; until then, this crate's. Magnitude serves link previews to
103/// Vector only, and judges that by this header, so it has to be present on
104/// every request and has to start with `Vector/`. One string for every
105/// user is also the least identifying choice: over Tor, a client that looks
106/// like every other Vector looks like nothing in particular.
107static USER_AGENT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
108
109/// Name the app's version in the user agent. Call once at startup; a second
110/// call is ignored.
111pub fn set_app_version(version: &str) {
112    let _ = USER_AGENT.set(format!("Vector/{version}"));
113}
114
115pub fn user_agent() -> String {
116    USER_AGENT
117        .get()
118        .cloned()
119        .unwrap_or_else(|| format!("Vector/{}", env!("CARGO_PKG_VERSION")))
120}
121
122/// Clients by their options, so a fetch reuses the connections of every
123/// fetch before it with the same options instead of opening its own.
124///
125/// A `reqwest::Client` is a handle on a connection pool; one built per
126/// request has an empty pool and pays a TCP and a TLS handshake for every
127/// file, which through an edge 33 ms away cost more than the file itself
128/// (measured: ~260 ms per already-cached clip, against ~70 ms on a kept
129/// connection). Twenty call sites built their own; now they share by
130/// options. With HTTP/2 negotiated to the edges, hundreds of small fetches
131/// share one connection. Emptied by [`rebuild_shared_http_client`] when Tor
132/// flips, since a client carries the proxy it was built with.
133static CLIENTS_BY_OPTIONS: OnceLock<std::sync::Mutex<std::collections::HashMap<ClientOptions, reqwest::Client>>> =
134    OnceLock::new();
135
136/// `(timeout, read_timeout, follow_redirects)`.
137type ClientOptions = (Option<std::time::Duration>, Option<std::time::Duration>, bool);
138
139pub fn build_http_client_with_options(
140    timeout: Option<std::time::Duration>,
141    read_timeout: Option<std::time::Duration>,
142    follow_redirects: bool,
143) -> Result<reqwest::Client, String> {
144    let key = (timeout, read_timeout, follow_redirects);
145    let cell = CLIENTS_BY_OPTIONS.get_or_init(Default::default);
146    if let Ok(map) = cell.lock() {
147        if let Some(c) = map.get(&key) {
148            return Ok(c.clone());
149        }
150    }
151    let client = build_http_client_uncached(timeout, read_timeout, follow_redirects)?;
152    if let Ok(mut map) = cell.lock() {
153        map.entry(key).or_insert_with(|| client.clone());
154    }
155    Ok(client)
156}
157
158/// Forget every pooled client: the next request builds one against the
159/// current Tor state.
160fn forget_pooled_clients() {
161    if let Some(cell) = CLIENTS_BY_OPTIONS.get() {
162        if let Ok(mut map) = cell.lock() {
163            map.clear();
164        }
165    }
166}
167
168/// The browser's fetch owns pooling, TLS, redirects and timeouts; a page cannot
169/// pick its user agent or route through a proxy.
170#[cfg(target_arch = "wasm32")]
171#[allow(clippy::disallowed_methods)]
172fn build_http_client_uncached(
173    _timeout: Option<std::time::Duration>,
174    _read_timeout: Option<std::time::Duration>,
175    _follow_redirects: bool,
176) -> Result<reqwest::Client, String> {
177    reqwest::Client::builder().build().map_err(|e| format!("HTTP client build failed: {e}"))
178}
179
180#[cfg(not(target_arch = "wasm32"))]
181#[allow(clippy::disallowed_methods)]
182fn build_http_client_uncached(
183    timeout: Option<std::time::Duration>,
184    read_timeout: Option<std::time::Duration>,
185    follow_redirects: bool,
186) -> Result<reqwest::Client, String> {
187    let mut builder = reqwest::Client::builder()
188        // Kept connections: idle ones stay a while so a burst of small
189        // fetches to one host (an edge, a Blossom server) rides one or a
190        // few connections, and HTTP/2 multiplexes on them where offered.
191        .pool_max_idle_per_host(8)
192        .pool_idle_timeout(std::time::Duration::from_secs(90))
193        .tcp_keepalive(std::time::Duration::from_secs(30))
194        .http2_keep_alive_interval(std::time::Duration::from_secs(30))
195        .http2_keep_alive_while_idle(true)
196        // Every request names the client, on every platform, because this
197        // is the one place a client is built. reqwest sends no agent at all
198        // otherwise, and Magnitude's preview endpoint refuses a caller that
199        // does not say it is Vector.
200        .user_agent(user_agent())
201        // Bounded connect: a black-holed host (SYN swallowed, never refused) must
202        // fail in seconds instead of silently consuming the whole request budget.
203        .connect_timeout(std::time::Duration::from_secs(15));
204    if let Some(t) = timeout {
205        builder = builder.timeout(t);
206    }
207    if let Some(rt) = read_timeout {
208        builder = builder.read_timeout(rt);
209    }
210    if !follow_redirects {
211        builder = builder.redirect(reqwest::redirect::Policy::none());
212    } else {
213        // Validate EVERY redirect hop, not just the initial URL: a public
214        // host answering `302 Location: http://169.254.169.254/…` would
215        // otherwise walk the request straight past the SSRF check.
216        builder = builder.redirect(reqwest::redirect::Policy::custom(|attempt| {
217            if attempt.previous().len() >= 10 {
218                return attempt.error("too many redirects");
219            }
220            match validate_url_not_private(attempt.url().as_str()) {
221                Ok(()) => attempt.follow(),
222                Err(e) => attempt.error(e),
223            }
224        }));
225    }
226
227    #[cfg(feature = "tor")]
228    {
229        match crate::tor::transport_state() {
230            crate::tor::TorTransportState::Active(addr) => {
231                // Use the addr from the variant directly — re-querying via
232                // proxy_url() races against TorService::stop() and can panic.
233                let url = format!("socks5h://{addr}");
234                let proxy = reqwest::Proxy::all(&url)
235                    .map_err(|e| format!("Tor proxy URL ({url}) invalid: {e}"))?;
236                builder = builder.proxy(proxy);
237                // Circuit builds to a fresh host legitimately take tens of seconds.
238                builder = builder.connect_timeout(std::time::Duration::from_secs(45));
239            }
240            crate::tor::TorTransportState::RequiredButInactive => {
241                // Tor failsafe: route to a blackhole so connections fail safe
242                // instead of leaking direct.
243                let url = format!("socks5h://{}", crate::tor::blackhole_proxy_addr());
244                let proxy = reqwest::Proxy::all(&url)
245                    .map_err(|e| format!("blackhole proxy invalid: {e}"))?;
246                builder = builder.proxy(proxy);
247            }
248            crate::tor::TorTransportState::Disabled => {
249                // No proxy — user has Tor off.
250            }
251        }
252    }
253
254    builder
255        .build()
256        .map_err(|e| format!("Failed to build HTTP client: {}", e))
257}
258
259// ============================================================================
260// Shared HTTP client — proxy-aware + rebuildable on Tor toggle
261// ============================================================================
262//
263// Some call sites (image-cache fetches, PIVX wallet polling) make frequent
264// requests and benefit from a shared `reqwest::Client` to reuse connection
265// pools / TLS sessions. A bare `LazyLock<Client>` doesn't work for us because
266// a Tor toggle should affect future requests immediately — but the static is
267// frozen at first init. Instead, we hold an `Arc<Client>` behind a `RwLock`
268// and rebuild it via `rebuild_shared_http_client()` whenever the Tor state
269// changes. In-flight requests finish on the old Arc; new requests pick up
270// the new one.
271
272use std::sync::{Arc, OnceLock, RwLock};
273
274static SHARED_HTTP_CLIENT: OnceLock<RwLock<Arc<reqwest::Client>>> = OnceLock::new();
275
276const DEFAULT_SHARED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
277
278fn shared_cell() -> &'static RwLock<Arc<reqwest::Client>> {
279    SHARED_HTTP_CLIENT.get_or_init(|| {
280        let client = build_http_client(DEFAULT_SHARED_TIMEOUT)
281            .expect("initial shared HTTP client build cannot fail");
282        RwLock::new(Arc::new(client))
283    })
284}
285
286/// Get a shared HTTP client. Cheap clone (Arc), proxy-aware, picks up Tor
287/// toggles on the next call after `rebuild_shared_http_client()` runs.
288pub fn shared_http_client() -> Arc<reqwest::Client> {
289    shared_cell().read().unwrap().clone()
290}
291
292/// Rebuild the shared client. Call this when Tor state flips so the next
293/// request goes through the freshly-configured proxy. In-flight requests on
294/// the old client continue to completion on the previous Arc.
295pub fn rebuild_shared_http_client() -> Result<(), String> {
296    forget_pooled_clients();
297    let new = Arc::new(build_http_client(DEFAULT_SHARED_TIMEOUT)?);
298    *shared_cell().write().unwrap() = new;
299    Ok(())
300}
301
302/// Find the byte index where a bracket/paren group opened at `start` closes,
303/// tracking nesting depth and honoring backslash escapes — markdown balances
304/// both, so a naive first-closer scan desyncs on `[[claim]](evil)` or
305/// `[claim\]](evil)` and lets the claim reach the URL scan. All compared
306/// bytes are ASCII, so the returned index is char-boundary-safe.
307fn md_group_close(bytes: &[u8], start: usize, open: u8, close: u8) -> Option<usize> {
308    let mut depth = 1usize;
309    let mut escaped = false;
310    let mut j = start;
311    loop {
312        match bytes.get(j).copied() {
313            None => return None,
314            Some(b'\\') if !escaped => escaped = true,
315            Some(b) if b == open && !escaped => depth += 1,
316            Some(b) if b == close && !escaped => {
317                depth -= 1;
318                if depth == 0 {
319                    return Some(j);
320                }
321            }
322            _ => escaped = false,
323        }
324        j += 1;
325    }
326}
327
328/// Rewrite markdown links so a preview-URL scan sees only real DESTINATIONS:
329/// `[text](href)` keeps the href and drops the display text — a URL claimed in
330/// the text must never win the OG preview over where the link actually goes —
331/// `[text](<href>)` drops entirely (angle brackets are the no-preview syntax),
332/// and `[text][ref]` drops the label (its destination is a definition scanned
333/// on its own elsewhere in the text). Images (`![alt](url)`) render as literal
334/// text in chat, so they pass through untouched.
335pub fn strip_md_link_claims(text: &str) -> String {
336    let bytes = text.as_bytes();
337    let mut out = String::with_capacity(text.len());
338    let mut last = 0;
339    let mut i = 0;
340    while i < bytes.len() {
341        if bytes[i] != b'[' || (i > 0 && bytes[i - 1] == b'!') {
342            i += 1;
343            continue;
344        }
345        let Some(close) = md_group_close(bytes, i + 1, b'[', b']') else { break };
346        match bytes.get(close + 1).copied() {
347            // Inline link: drop the label, contribute the destination.
348            Some(b'(') => {
349                let Some(paren) = md_group_close(bytes, close + 2, b'(', b')') else {
350                    i = close + 1;
351                    continue;
352                };
353                out.push_str(&text[last..i]);
354                let href = text[close + 2..paren].trim();
355                if !(href.starts_with('<') && href.ends_with('>')) {
356                    out.push(' ');
357                    out.push_str(href);
358                    out.push(' ');
359                }
360                i = paren + 1;
361                last = i;
362            }
363            // Reference link: drop the label; the `[ref]: url` definition line
364            // carries the real destination and gets scanned as plain text.
365            Some(b'[') => {
366                let Some(ref_close) = md_group_close(bytes, close + 2, b'[', b']') else {
367                    i = close + 1;
368                    continue;
369                };
370                out.push_str(&text[last..i]);
371                i = ref_close + 1;
372                last = i;
373            }
374            _ => {
375                i = close + 1;
376            }
377        }
378    }
379    out.push_str(&text[last..]);
380    out
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    // ========================================================================
388    // strip_md_link_claims — preview scan must see destinations, not claims
389    // ========================================================================
390
391    #[test]
392    fn md_link_claim_text_dropped_href_kept() {
393        // The spoof shape: claimed URL first in raw text, real destination second.
394        let out = strip_md_link_claims("[https://your-bank.com](https://evil.io)");
395        assert!(!out.contains("your-bank.com"), "claimed text must not reach the scan: {out}");
396        assert!(out.contains("https://evil.io"), "real destination must reach the scan: {out}");
397    }
398
399    #[test]
400    fn md_no_preview_link_dropped_entirely() {
401        let out = strip_md_link_claims("see [docs](<https://vector.app/docs>) ok");
402        assert!(!out.contains("vector.app"), "no-preview href must not reach the scan: {out}");
403        assert!(out.contains("see ") && out.contains(" ok"));
404    }
405
406    #[test]
407    fn md_image_passes_through() {
408        let text = "![shot](https://host.io/img.png)";
409        assert_eq!(strip_md_link_claims(text), text);
410    }
411
412    #[test]
413    fn plain_text_and_bare_urls_untouched() {
414        let text = "check https://vector.app and [also] (spaced) brackets";
415        assert_eq!(strip_md_link_claims(text), text);
416    }
417
418    #[test]
419    fn multiple_links_keep_document_order() {
420        let out = strip_md_link_claims("[a](https://one.io) mid [b](https://two.io)");
421        let one = out.find("https://one.io").expect("first href kept");
422        let two = out.find("https://two.io").expect("second href kept");
423        assert!(one < two);
424    }
425
426    #[test]
427    fn nested_bracket_label_still_drops_claim() {
428        let out = strip_md_link_claims("[[https://trusted.com]](https://evil.io)");
429        assert!(!out.contains("trusted.com"), "nested-bracket claim must not reach the scan: {out}");
430        assert!(out.contains("https://evil.io"));
431    }
432
433    #[test]
434    fn escaped_bracket_label_still_drops_claim() {
435        let out = strip_md_link_claims(r"[https://trusted.com\]](https://evil.io)");
436        assert!(!out.contains("trusted.com"), "escaped-bracket claim must not reach the scan: {out}");
437        assert!(out.contains("https://evil.io"));
438    }
439
440    #[test]
441    fn paren_path_href_survives_whole() {
442        let out = strip_md_link_claims("[wiki](https://en.wikipedia.org/wiki/Foo_(bar))");
443        assert!(out.contains("https://en.wikipedia.org/wiki/Foo_(bar)"), "balanced-paren href kept intact: {out}");
444    }
445
446    #[test]
447    fn reference_link_label_dropped_definition_scanned() {
448        let out = strip_md_link_claims("[https://trusted.com][1]\n[1]: https://evil.io");
449        assert!(!out.contains("trusted.com"), "reflink claim must not reach the scan: {out}");
450        assert!(out.contains("https://evil.io"), "definition URL stays scannable: {out}");
451    }
452
453    #[test]
454    fn multibyte_label_no_panic() {
455        let out = strip_md_link_claims("[🔒 sécurisé — café](https://evil.io) 日本語");
456        assert!(out.contains("https://evil.io"));
457        assert!(out.contains("日本語"));
458    }
459
460    // ========================================================================
461    // Valid public URLs — should pass
462    // ========================================================================
463
464    #[test]
465    fn valid_public_https_url_passes() {
466        assert!(validate_url_not_private("https://example.com/path").is_ok(),
467            "https://example.com should be allowed");
468    }
469
470    #[test]
471    fn valid_public_http_url_passes() {
472        assert!(validate_url_not_private("http://example.com").is_ok(),
473            "http://example.com should be allowed");
474    }
475
476    #[test]
477    fn valid_public_ip_8888_passes() {
478        assert!(validate_url_not_private("https://8.8.8.8/dns").is_ok(),
479            "8.8.8.8 (Google DNS) is a public IP and should be allowed");
480    }
481
482    #[test]
483    fn valid_public_ip_1111_passes() {
484        assert!(validate_url_not_private("https://1.1.1.1").is_ok(),
485            "1.1.1.1 (Cloudflare DNS) is a public IP and should be allowed");
486    }
487
488    #[test]
489    fn valid_url_with_port_passes() {
490        assert!(validate_url_not_private("https://example.com:8080/api").is_ok(),
491            "URL with port on public domain should be allowed");
492    }
493
494    // ========================================================================
495    // Loopback addresses — should be rejected
496    // ========================================================================
497
498    #[test]
499    fn localhost_rejected() {
500        let result = validate_url_not_private("http://localhost/secret");
501        assert!(result.is_err(), "localhost should be rejected");
502    }
503
504    #[test]
505    fn ip_127_0_0_1_rejected() {
506        let result = validate_url_not_private("http://127.0.0.1/admin");
507        assert!(result.is_err(), "127.0.0.1 (loopback) should be rejected");
508    }
509
510    #[test]
511    fn ip_127_255_255_255_rejected() {
512        let result = validate_url_not_private("http://127.255.255.255");
513        assert!(result.is_err(), "127.255.255.255 (loopback range) should be rejected");
514    }
515
516    // ========================================================================
517    // Private IP ranges — should be rejected
518    // ========================================================================
519
520    #[test]
521    fn private_class_a_10_rejected() {
522        let result = validate_url_not_private("http://10.0.0.1/internal");
523        assert!(result.is_err(), "10.0.0.1 (private class A) should be rejected");
524    }
525
526    #[test]
527    fn private_class_b_172_16_rejected() {
528        let result = validate_url_not_private("http://172.16.0.1/internal");
529        assert!(result.is_err(), "172.16.0.1 (private class B) should be rejected");
530    }
531
532    #[test]
533    fn private_class_b_172_31_rejected() {
534        let result = validate_url_not_private("http://172.31.255.255");
535        assert!(result.is_err(), "172.31.255.255 (private class B upper bound) should be rejected");
536    }
537
538    #[test]
539    fn private_class_c_192_168_rejected() {
540        let result = validate_url_not_private("http://192.168.1.1/router");
541        assert!(result.is_err(), "192.168.1.1 (private class C) should be rejected");
542    }
543
544    // ========================================================================
545    // Special addresses — should be rejected
546    // ========================================================================
547
548    #[test]
549    fn link_local_169_254_rejected() {
550        let result = validate_url_not_private("http://169.254.1.1");
551        assert!(result.is_err(), "169.254.1.1 (link-local) should be rejected");
552    }
553
554    #[test]
555    fn cgn_100_64_rejected() {
556        let result = validate_url_not_private("http://100.64.0.1");
557        assert!(result.is_err(), "100.64.0.1 (CGN / shared address space) should be rejected");
558    }
559
560    #[test]
561    fn cgn_100_127_rejected() {
562        let result = validate_url_not_private("http://100.127.255.255");
563        assert!(result.is_err(), "100.127.255.255 (CGN upper bound) should be rejected");
564    }
565
566    #[test]
567    fn broadcast_255_rejected() {
568        let result = validate_url_not_private("http://255.255.255.255");
569        assert!(result.is_err(), "255.255.255.255 (broadcast) should be rejected");
570    }
571
572    #[test]
573    fn unspecified_0_0_0_0_rejected() {
574        let result = validate_url_not_private("http://0.0.0.0");
575        assert!(result.is_err(), "0.0.0.0 (unspecified) should be rejected");
576    }
577
578    // ========================================================================
579    // IPv6 addresses — should be rejected
580    // ========================================================================
581
582    #[test]
583    fn ipv6_loopback_rejected() {
584        let result = validate_url_not_private("http://[::1]/secret");
585        assert!(result.is_err(), "::1 (IPv6 loopback) should be rejected");
586    }
587
588    #[test]
589    fn ipv6_unique_local_fc00_rejected() {
590        let result = validate_url_not_private("http://[fc00::1]");
591        assert!(result.is_err(), "fc00::1 (IPv6 unique-local) should be rejected");
592    }
593
594    #[test]
595    fn ipv6_unique_local_fd00_rejected() {
596        let result = validate_url_not_private("http://[fd00::1]");
597        assert!(result.is_err(), "fd00::1 (IPv6 unique-local) should be rejected");
598    }
599
600    #[test]
601    fn ipv6_link_local_fe80_rejected() {
602        let result = validate_url_not_private("http://[fe80::1]");
603        assert!(result.is_err(), "fe80::1 (IPv6 link-local) should be rejected");
604    }
605
606    #[test]
607    fn ipv4_mapped_ipv6_loopback_rejected() {
608        let result = validate_url_not_private("http://[::ffff:127.0.0.1]");
609        assert!(result.is_err(), "::ffff:127.0.0.1 (IPv4-mapped loopback) should be rejected");
610    }
611
612    #[test]
613    fn ipv4_mapped_ipv6_private_rejected() {
614        let result = validate_url_not_private("http://[::ffff:192.168.1.1]");
615        assert!(result.is_err(), "::ffff:192.168.1.1 (IPv4-mapped private) should be rejected");
616    }
617
618    // ========================================================================
619    // Domain name restrictions
620    // ========================================================================
621
622    #[test]
623    fn dot_local_domain_rejected() {
624        let result = validate_url_not_private("http://mydevice.local/api");
625        assert!(result.is_err(), ".local domain should be rejected");
626    }
627
628    #[test]
629    fn dot_internal_domain_rejected() {
630        let result = validate_url_not_private("http://service.internal/health");
631        assert!(result.is_err(), ".internal domain should be rejected");
632    }
633
634    // ========================================================================
635    // Scheme restrictions
636    // ========================================================================
637
638    #[test]
639    fn ftp_scheme_rejected() {
640        let result = validate_url_not_private("ftp://example.com/file.txt");
641        assert!(result.is_err(), "ftp:// scheme should be rejected");
642        assert_eq!(result.unwrap_err(), "Only HTTP(S) URLs are allowed");
643    }
644
645    #[test]
646    fn file_scheme_rejected() {
647        let result = validate_url_not_private("file:///etc/passwd");
648        assert!(result.is_err(), "file:// scheme should be rejected");
649        assert_eq!(result.unwrap_err(), "Only HTTP(S) URLs are allowed");
650    }
651
652    #[test]
653    fn javascript_scheme_rejected() {
654        let result = validate_url_not_private("javascript:alert(1)");
655        assert!(result.is_err(), "javascript: scheme should be rejected");
656    }
657
658    #[test]
659    fn data_scheme_rejected() {
660        let result = validate_url_not_private("data:text/html,<h1>hi</h1>");
661        assert!(result.is_err(), "data: scheme should be rejected");
662    }
663
664    // ========================================================================
665    // Missing / invalid URL
666    // ========================================================================
667
668    #[test]
669    fn no_host_rejected() {
670        // http:// with no host is actually an invalid URL for the url crate
671        let result = validate_url_not_private("http://");
672        assert!(result.is_err(), "URL with no host should be rejected");
673    }
674
675    #[test]
676    fn invalid_url_rejected() {
677        let result = validate_url_not_private("not a url at all");
678        assert!(result.is_err(), "invalid URL string should be rejected");
679        assert_eq!(result.unwrap_err(), "Invalid URL");
680    }
681
682    #[test]
683    fn empty_string_rejected() {
684        let result = validate_url_not_private("");
685        assert!(result.is_err(), "empty string should be rejected");
686    }
687
688    // ========================================================================
689    // Edge cases
690    // ========================================================================
691
692    #[test]
693    fn cgn_100_63_not_rejected() {
694        // 100.63.x.x is NOT in the CGN range (100.64-100.127)
695        assert!(validate_url_not_private("http://100.63.255.255").is_ok(),
696            "100.63.255.255 is outside CGN range and should be allowed");
697    }
698
699    #[test]
700    fn cgn_100_128_not_rejected() {
701        // 100.128.x.x is NOT in the CGN range
702        assert!(validate_url_not_private("http://100.128.0.1").is_ok(),
703            "100.128.0.1 is outside CGN range and should be allowed");
704    }
705
706    #[test]
707    fn private_172_15_not_rejected() {
708        // 172.15.x.x is NOT private (private is 172.16-172.31)
709        assert!(validate_url_not_private("http://172.15.255.255").is_ok(),
710            "172.15.255.255 is outside private class B range and should be allowed");
711    }
712
713    #[test]
714    fn private_172_32_not_rejected() {
715        // 172.32.x.x is NOT private
716        assert!(validate_url_not_private("http://172.32.0.1").is_ok(),
717            "172.32.0.1 is outside private class B range and should be allowed");
718    }
719}
720
721// ============================================================================
722// Egress: where a request actually goes
723// ============================================================================
724
725/// Where a request for `url` really goes, and what it carries: the proxy's
726/// address with a signed authorization when the privacy setting routes it
727/// through Magnitude, or the URL itself.
728pub struct Egress {
729    pub url: String,
730    pub auth: Option<reqwest::header::HeaderValue>,
731}
732
733impl Egress {
734    /// Whether the request leaves through a proxy rather than to the host.
735    pub fn proxied(&self) -> bool {
736        self.auth.is_some() || crate::proxy::proxy_server_of(&self.url).is_some()
737    }
738}
739
740/// Resolve the destination for `url`. Every outbound fetch of somebody else's
741/// content asks here first, so the setting has exactly one place to act.
742pub async fn egress(url: &str) -> Egress {
743    match crate::proxy::proxied(url).await {
744        Some(via) => {
745            let auth = match crate::proxy::proxy_server_of(&via) {
746                Some(server) => crate::proxy::proxy_authorization(&server).await,
747                None => None,
748            };
749            Egress { url: via, auth }
750        }
751        None => Egress { url: url.to_string(), auth: None },
752    }
753}
754
755/// A request for `url` on `client`, already pointed at the right place and
756/// carrying the proxy authorization when there is one.
757pub async fn proxied_request(client: &reqwest::Client, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
758    let e = egress(url).await;
759    let req = client.request(method, &e.url);
760    match e.auth {
761        Some(v) => req.header(reqwest::header::AUTHORIZATION, v),
762        None => req,
763    }
764}
765
766/// The HTTP status the SOURCE gives for `url`: 2xx means it serves, 404/410
767/// means it is gone. `None` means nothing definitive could be learned (the
768/// host, or the proxy, was unreachable). Through the proxy a HEAD carries no
769/// body, so one byte is asked for instead and the source's status read from
770/// the proxy's answer; direct, a HEAD is tried first and a one-byte GET when
771/// the host refuses HEAD.
772pub async fn remote_status(url: &str, timeout: std::time::Duration) -> Option<u16> {
773    let e = egress(url).await;
774    let client = build_http_client(timeout).ok()?;
775    let with = |req: reqwest::RequestBuilder| match &e.auth {
776        Some(v) => req.header(reqwest::header::AUTHORIZATION, v.clone()),
777        None => req,
778    };
779    if e.proxied() {
780        let resp = with(client.get(&e.url)).header(reqwest::header::RANGE, "bytes=0-0").send().await.ok()?;
781        if resp.status().is_success() {
782            return Some(200);
783        }
784        let body: serde_json::Value = resp.json().await.ok()?;
785        return body.get("source_status").and_then(|v| v.as_u64()).map(|v| v as u16);
786    }
787    let head = with(client.head(&e.url)).send().await.ok()?;
788    let status = head.status();
789    if status == reqwest::StatusCode::METHOD_NOT_ALLOWED || status == reqwest::StatusCode::NOT_IMPLEMENTED {
790        let r = with(client.get(&e.url)).header(reqwest::header::RANGE, "bytes=0-0").send().await.ok()?;
791        return Some(r.status().as_u16());
792    }
793    Some(status.as_u16())
794}
795
796// ============================================================================
797// Remote File Size
798// ============================================================================
799
800/// Get the size of a remote file via HEAD request or Range fallback.
801/// Returns None if the URL is private, unreachable, or size can't be determined.
802pub async fn get_remote_file_size(url: &str) -> Option<u64> {
803    validate_url_not_private(url).ok()?;
804    let client = build_http_client(std::time::Duration::from_secs(8)).ok()?;
805
806    // Method 1: HEAD request
807    if let Ok(head_res) = proxied_request(&client, reqwest::Method::HEAD, url).await.send().await {
808        if let Some(length) = head_res.content_length() {
809            if length > 0 {
810                return Some(length);
811            }
812        }
813    }
814
815    // Method 2: Range request fallback
816    if let Ok(partial_res) = proxied_request(&client, reqwest::Method::GET, url)
817        .await
818        .header("Range", "bytes=0-1")
819        .send()
820        .await
821    {
822        if let Some(content_range) = partial_res.headers().get("content-range") {
823            if let Ok(range_str) = content_range.to_str() {
824                if let Some(size_part) = range_str.split('/').nth(1) {
825                    if let Ok(size) = size_part.parse::<u64>() {
826                        return Some(size);
827                    }
828                }
829            }
830        }
831        if let Some(length) = partial_res.content_length() {
832            if length > 100 {
833                return Some(length);
834            }
835        }
836    }
837
838    None
839}
840
841/// Lift the open-file ceiling to what the OS allows.
842///
843/// A relay pool authenticates every plane on its own socket and the SQLite pool
844/// holds dozens of handles, so a busy session idles near 200 descriptors. macOS
845/// launches GUI apps with a soft limit of 256; past it every socket, file and
846/// child process fails, and the first system UI to lazily load a resource
847/// bundle traps the process. Returns the new soft limit, `None` if unchanged.
848#[allow(clippy::unnecessary_cast)] // rlim_t is u32 on 32-bit Android
849pub fn raise_fd_limit() -> Option<u64> {
850    #[cfg(not(unix))]
851    {
852        None
853    }
854    #[cfg(unix)]
855    {
856        // macOS refuses a soft limit above OPEN_MAX (sys/syslimits.h) even under
857        // an unlimited hard limit; Linux refuses one above nr_open.
858        #[cfg(target_os = "macos")]
859        const CEILING: libc::rlim_t = 10240;
860        #[cfg(not(target_os = "macos"))]
861        const CEILING: libc::rlim_t = 1 << 20;
862
863        let mut lim = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
864        // SAFETY: plain libc calls on a stack struct we own.
865        if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) } != 0 {
866            return None;
867        }
868        let target = lim.rlim_max.min(CEILING);
869        if target <= lim.rlim_cur {
870            return None;
871        }
872        lim.rlim_cur = target;
873        if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &lim) } != 0 {
874            return None;
875        }
876        Some(target as u64)
877    }
878}
879
880#[cfg(all(test, unix))]
881mod fd_limit_tests {
882    #[test]
883    fn soft_limit_reaches_the_ceiling_and_is_never_lowered() {
884        let mut before = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
885        assert_eq!(unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut before) }, 0);
886
887        super::raise_fd_limit();
888
889        let mut after = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
890        assert_eq!(unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut after) }, 0);
891        #[cfg(target_os = "macos")]
892        let ceiling: libc::rlim_t = 10240;
893        #[cfg(not(target_os = "macos"))]
894        let ceiling: libc::rlim_t = 1 << 20;
895        assert!(after.rlim_cur >= before.rlim_cur);
896        assert!(after.rlim_cur >= after.rlim_max.min(ceiling));
897    }
898}
899
900#[cfg(test)]
901mod user_agent_tests {
902    use super::*;
903
904    /// Magnitude refuses a preview request whose agent does not start with
905    /// `Vector/`; this is the string every request will carry.
906    #[test]
907    fn the_client_names_itself_as_vector() {
908        assert!(user_agent().starts_with("Vector/"), "{}", user_agent());
909        set_app_version("9.9.9-test");
910        // Either the app's version or, if another test set it first, that
911        // one: in both cases still Vector's.
912        assert!(user_agent().starts_with("Vector/"), "{}", user_agent());
913    }
914}