Skip to main content

irontide_session/
url_guard.rs

1//! URL security validation — SSRF mitigation, IDNA rejection, HTTPS enforcement.
2//!
3//! Provides centralized URL checking for tracker announces and web seed requests.
4//! Guards against server-side request forgery by rejecting redirects from public
5//! to private IP ranges, restricting localhost tracker paths, and optionally
6//! rejecting internationalised domain names (IDNA).
7
8use std::net::IpAddr;
9
10use url::Url;
11
12use crate::rate_limiter::is_local_network;
13
14// ── Configuration ─────────────────────────────────────────────────────
15
16/// URL security configuration extracted from [`Settings`](crate::Settings).
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct UrlSecurityConfig {
19    /// Block requests to private/loopback IPs and restrict localhost tracker paths.
20    pub ssrf_mitigation: bool,
21    /// Allow internationalised (non-ASCII) domain names in URLs.
22    pub allow_idna: bool,
23    /// Require HTTPS for HTTP tracker announces (UDP trackers are unaffected).
24    pub validate_https_trackers: bool,
25}
26
27impl Default for UrlSecurityConfig {
28    fn default() -> Self {
29        Self {
30            ssrf_mitigation: true,
31            allow_idna: false,
32            validate_https_trackers: true,
33        }
34    }
35}
36
37impl From<&crate::settings::Settings> for UrlSecurityConfig {
38    fn from(s: &crate::settings::Settings) -> Self {
39        Self {
40            ssrf_mitigation: s.ssrf_mitigation,
41            allow_idna: s.allow_idna,
42            validate_https_trackers: s.validate_https_trackers,
43        }
44    }
45}
46
47// ── Errors ────────────────────────────────────────────────────────────
48
49/// Errors returned by URL validation functions.
50#[derive(Debug, thiserror::Error)]
51pub enum UrlGuardError {
52    /// URL failed `url::Url::parse` or used an unsupported scheme.
53    #[error("invalid URL: {0}")]
54    InvalidUrl(String),
55
56    /// A localhost tracker URL was missing the required `/announce` path —
57    /// BEP 3 trackers must terminate at that path, and accepting bare-host
58    /// URLs widens the SSRF surface unnecessarily.
59    #[error("SSRF: localhost tracker must use /announce path, got: {0}")]
60    LocalhostBadPath(String),
61
62    /// A local-network web seed URL carried a query string. BEP 17/19 web
63    /// seeds shouldn't need one, and allowing them opens an SSRF vector
64    /// (smuggling commands via `?` to local services).
65    #[error("local-network web seed URL must not contain a query string")]
66    LocalNetworkQueryString,
67
68    /// In-flight redirect from a public URL landed on a private IP — fired
69    /// by the custom [`reqwest::redirect::Policy`] returned by
70    /// [`build_redirect_policy`]. Distinct from [`Self::PrivateHostBlocked`],
71    /// which is a *pre-flight* check on user-supplied URLs.
72    #[error("SSRF: redirect from global URL to private/local IP {0} blocked")]
73    RedirectToPrivateIp(IpAddr),
74
75    /// An internationalised domain name was present and IDNA was disallowed by
76    /// the active [`UrlSecurityConfig`].
77    #[error("internationalised domain name (IDNA) rejected: {0}")]
78    IdnaDomain(String),
79
80    /// M218: a user-typed URL pointed at a private/loopback host. Stricter than
81    /// [`Self::RedirectToPrivateIp`] (which only fires in-flight); this is the
82    /// pre-flight check that rejects `http://localhost/`, `http://192.168.1.1/`,
83    /// `http://0.0.0.0/`, `http://[::ffff:127.0.0.1]/`, etc. before the request
84    /// leaves the process.
85    #[error("SSRF: URL host {0} is on a private/local network")]
86    PrivateHostBlocked(String),
87}
88
89// ── Private helpers ───────────────────────────────────────────────────
90
91/// Extract an IP address from a URL whose host is an IP literal.
92fn host_ip(url: &Url) -> Option<IpAddr> {
93    match url.host()? {
94        url::Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
95        url::Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
96        url::Host::Domain(_) => None,
97    }
98}
99
100/// Returns `true` if the URL points to localhost (127.0.0.0/8, `::1`, or "localhost").
101fn is_localhost(url: &Url) -> bool {
102    match url.host() {
103        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
104        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
105        Some(url::Host::Domain(d)) => d == "localhost",
106        None => false,
107    }
108}
109
110/// Returns `true` if the URL's host is a local/private network address.
111fn is_local_host_url(url: &Url) -> bool {
112    match host_ip(url) {
113        Some(ip) => is_local_network(ip),
114        None => {
115            // Domain name — only "localhost" is considered local.
116            matches!(url.host_str(), Some("localhost"))
117        }
118    }
119}
120
121/// Returns `true` if the URL contains a non-ASCII (internationalised) domain name.
122///
123/// The `url` crate may punycode-encode non-ASCII hostnames, so we check the
124/// original host string for either non-ASCII characters or the punycode `xn--`
125/// prefix in any label.
126fn has_idna_domain(url: &Url) -> bool {
127    match url.host_str() {
128        Some(host) => {
129            // Check for non-ASCII characters (direct Unicode representation).
130            if !host.is_ascii() {
131                return true;
132            }
133            // Check for punycode-encoded labels (url crate may convert to ASCII).
134            host.split('.').any(|label| label.starts_with("xn--"))
135        }
136        None => false,
137    }
138}
139
140// ── Public validation functions ───────────────────────────────────────
141
142/// Validate a tracker announce URL.
143///
144/// - UDP trackers skip SSRF checks (they don't follow HTTP redirects) but
145///   still undergo IDNA validation.
146/// - HTTP/HTTPS trackers check IDNA + localhost path restrictions.
147pub(crate) fn validate_tracker_url(
148    url_str: &str,
149    config: UrlSecurityConfig,
150) -> Result<(), UrlGuardError> {
151    let url = Url::parse(url_str).map_err(|e| UrlGuardError::InvalidUrl(e.to_string()))?;
152
153    // IDNA check applies to all URL schemes.
154    if !config.allow_idna && has_idna_domain(&url) {
155        return Err(UrlGuardError::IdnaDomain(
156            url.host_str().unwrap_or_default().to_string(),
157        ));
158    }
159
160    // UDP trackers don't need SSRF checks — they can't follow redirects.
161    if url.scheme() == "udp" {
162        return Ok(());
163    }
164
165    // SSRF: localhost tracker URLs must have path ending in /announce.
166    if config.ssrf_mitigation && is_localhost(&url) && !url.path().ends_with("/announce") {
167        return Err(UrlGuardError::LocalhostBadPath(url.path().to_string()));
168    }
169
170    Ok(())
171}
172
173/// Validate a web seed (BEP 19 / BEP 17) URL.
174///
175/// - IDNA check.
176/// - Local-network URLs must not have a query string (prevents info leakage).
177pub(crate) fn validate_web_seed_url(
178    url_str: &str,
179    config: UrlSecurityConfig,
180) -> Result<(), UrlGuardError> {
181    let url = Url::parse(url_str).map_err(|e| UrlGuardError::InvalidUrl(e.to_string()))?;
182
183    if !config.allow_idna && has_idna_domain(&url) {
184        return Err(UrlGuardError::IdnaDomain(
185            url.host_str().unwrap_or_default().to_string(),
186        ));
187    }
188
189    if config.ssrf_mitigation && is_local_host_url(&url) && url.query().is_some() {
190        return Err(UrlGuardError::LocalNetworkQueryString);
191    }
192
193    Ok(())
194}
195
196/// Validate an HTTP redirect target against SSRF policy.
197///
198/// Blocks redirects from a public (non-local) origin to a private/local IP.
199#[allow(dead_code)] // Public API for callers to pre-check redirects; also tested in scenario tests.
200pub(crate) fn validate_redirect(
201    original_url: &Url,
202    redirect_url: &Url,
203    config: UrlSecurityConfig,
204) -> Result<(), UrlGuardError> {
205    if !config.ssrf_mitigation {
206        return Ok(());
207    }
208
209    let orig_local = match host_ip(original_url) {
210        Some(ip) => is_local_network(ip),
211        None => is_localhost(original_url),
212    };
213
214    // Only block public -> private redirects; private -> private is fine.
215    if orig_local {
216        return Ok(());
217    }
218
219    let redirect_ip = host_ip(redirect_url);
220    let redirect_local = match redirect_ip {
221        Some(ip) => is_local_network(ip),
222        None => is_localhost(redirect_url),
223    };
224
225    if redirect_local {
226        let ip = redirect_ip.unwrap_or_else(|| "127.0.0.1".parse().unwrap());
227        return Err(UrlGuardError::RedirectToPrivateIp(ip));
228    }
229
230    Ok(())
231}
232
233/// M218: validate a user-typed URL before issuing an HTTP fetch.
234///
235/// Stricter than [`validate_web_seed_url`] (which only blocks query strings on
236/// local URLs): for arbitrary user-supplied URLs there is no swarm context to
237/// bound the risk, so *any* private/loopback host is rejected outright when
238/// `ssrf_mitigation` is enabled. Also restricts the scheme to `http` / `https`
239/// — `file://` is local file disclosure and other schemes have no useful
240/// semantics for a `.torrent` fetch.
241///
242/// Returns `Ok(())` if the URL is safe to fetch under `config`. The check is
243/// pre-flight; the in-flight redirect check is enforced separately by the
244/// `reqwest` policy built by [`build_redirect_policy`].
245///
246/// # Errors
247///
248/// Returns [`UrlGuardError::InvalidUrl`] if `url_str` fails to parse or uses a
249/// scheme other than `http` / `https`; [`UrlGuardError::IdnaDomain`] if IDNA is
250/// disallowed and the host contains punycode; [`UrlGuardError::PrivateHostBlocked`]
251/// if SSRF mitigation is on and the host resolves to a private/loopback
252/// address (including `0.0.0.0`, `::`, and IPv4-mapped IPv6 loopback).
253pub fn validate_user_url(
254    url_str: &str,
255    config: UrlSecurityConfig,
256) -> Result<(), UrlGuardError> {
257    let url = Url::parse(url_str).map_err(|e| UrlGuardError::InvalidUrl(e.to_string()))?;
258
259    if !matches!(url.scheme(), "http" | "https") {
260        return Err(UrlGuardError::InvalidUrl(format!(
261            "unsupported scheme '{}'",
262            url.scheme()
263        )));
264    }
265
266    if !config.allow_idna && has_idna_domain(&url) {
267        return Err(UrlGuardError::IdnaDomain(
268            url.host_str().unwrap_or_default().to_string(),
269        ));
270    }
271
272    if config.ssrf_mitigation && (is_localhost(&url) || is_local_host_url(&url)) {
273        return Err(UrlGuardError::PrivateHostBlocked(
274            url.host_str().unwrap_or_default().to_string(),
275        ));
276    }
277
278    Ok(())
279}
280
281// ── HTTP helpers ──────────────────────────────────────────────────────
282
283/// Build a reqwest redirect policy that blocks SSRF redirect attacks.
284///
285/// If SSRF mitigation is enabled, redirects from public to private IPs are
286/// rejected. Otherwise a standard 10-hop redirect policy is used.
287///
288/// Returns a policy compatible with both `reqwest::Client` and
289/// `reqwest::blocking::Client` (they share the same `redirect::Policy` type).
290#[must_use]
291pub fn build_redirect_policy(config: UrlSecurityConfig) -> reqwest::redirect::Policy {
292    if !config.ssrf_mitigation {
293        return reqwest::redirect::Policy::limited(10);
294    }
295
296    reqwest::redirect::Policy::custom(move |attempt| {
297        if attempt.previous().len() >= 10 {
298            return attempt.error(std::io::Error::other("too many redirects"));
299        }
300
301        let original = &attempt.previous()[0];
302        let redirect = attempt.url();
303
304        let orig_local = match original.host() {
305            Some(url::Host::Ipv4(ip)) => is_local_network(IpAddr::V4(ip)),
306            Some(url::Host::Ipv6(ip)) => is_local_network(IpAddr::V6(ip)),
307            Some(url::Host::Domain(d)) => d == "localhost",
308            None => false,
309        };
310
311        if !orig_local {
312            let redirect_local = match redirect.host() {
313                Some(url::Host::Ipv4(ip)) => is_local_network(IpAddr::V4(ip)),
314                Some(url::Host::Ipv6(ip)) => is_local_network(IpAddr::V6(ip)),
315                Some(url::Host::Domain(d)) => d == "localhost",
316                None => false,
317            };
318
319            if redirect_local {
320                return attempt.error(std::io::Error::other(
321                    "redirect from public to private IP blocked (SSRF)",
322                ));
323            }
324        }
325
326        attempt.follow()
327    })
328}
329
330/// Build a configured reqwest HTTP client with SSRF-safe redirect policy.
331pub(crate) fn build_http_client(
332    config: UrlSecurityConfig,
333    proxy_url: Option<&str>,
334    user_agent: &str,
335) -> reqwest::Client {
336    let mut builder = reqwest::Client::builder()
337        .user_agent(user_agent)
338        .redirect(build_redirect_policy(config))
339        .timeout(std::time::Duration::from_secs(30))
340        .connect_timeout(std::time::Duration::from_secs(10));
341
342    if !config.validate_https_trackers {
343        builder = builder.danger_accept_invalid_certs(true);
344    }
345
346    if let Some(proxy) = proxy_url
347        && let Ok(p) = reqwest::Proxy::all(proxy)
348    {
349        builder = builder.proxy(p);
350    }
351
352    builder.build().expect("failed to build HTTP client")
353}
354
355// ── Tests ─────────────────────────────────────────────────────────────
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    fn ssrf_config() -> UrlSecurityConfig {
362        UrlSecurityConfig {
363            ssrf_mitigation: true,
364            allow_idna: false,
365            validate_https_trackers: true,
366        }
367    }
368
369    fn permissive_config() -> UrlSecurityConfig {
370        UrlSecurityConfig {
371            ssrf_mitigation: false,
372            allow_idna: true,
373            validate_https_trackers: false,
374        }
375    }
376
377    // ── Config defaults ──
378
379    #[test]
380    fn url_security_config_defaults() {
381        let cfg = UrlSecurityConfig::default();
382        assert!(cfg.ssrf_mitigation);
383        assert!(!cfg.allow_idna);
384        assert!(cfg.validate_https_trackers);
385    }
386
387    // ── Helper functions ──
388
389    #[test]
390    fn host_ip_extraction() {
391        let url = Url::parse("http://192.168.1.1:8080/path").unwrap();
392        assert_eq!(host_ip(&url), Some("192.168.1.1".parse().unwrap()));
393
394        let url = Url::parse("http://[::1]:8080/path").unwrap();
395        assert_eq!(host_ip(&url), Some("::1".parse().unwrap()));
396
397        let url = Url::parse("http://example.com/path").unwrap();
398        assert_eq!(host_ip(&url), None);
399    }
400
401    #[test]
402    fn localhost_detection() {
403        assert!(is_localhost(
404            &Url::parse("http://127.0.0.1/announce").unwrap()
405        ));
406        assert!(is_localhost(
407            &Url::parse("http://127.0.0.5:8080/announce").unwrap()
408        ));
409        assert!(is_localhost(&Url::parse("http://[::1]/announce").unwrap()));
410        assert!(is_localhost(
411            &Url::parse("http://localhost/announce").unwrap()
412        ));
413        assert!(!is_localhost(
414            &Url::parse("http://10.0.0.1/announce").unwrap()
415        ));
416        assert!(!is_localhost(
417            &Url::parse("http://example.com/announce").unwrap()
418        ));
419    }
420
421    #[test]
422    fn idna_domain_detection() {
423        // The url crate punycode-encodes non-ASCII domains, so we check for xn-- labels.
424        let url = Url::parse("http://xn--nxasmq6b.example.com/path").unwrap();
425        assert!(has_idna_domain(&url));
426
427        // Plain ASCII domain should not be flagged.
428        let url = Url::parse("http://tracker.example.com/announce").unwrap();
429        assert!(!has_idna_domain(&url));
430
431        // IP-literal host: no domain, no IDNA.
432        let url = Url::parse("http://192.168.1.1/path").unwrap();
433        assert!(!has_idna_domain(&url));
434    }
435
436    // ── Tracker URL validation ──
437
438    #[test]
439    fn tracker_url_valid_public_http() {
440        let cfg = ssrf_config();
441        assert!(validate_tracker_url("http://tracker.example.com/announce", cfg).is_ok());
442        assert!(validate_tracker_url("https://tracker.example.com/announce", cfg).is_ok());
443    }
444
445    #[test]
446    fn tracker_url_valid_udp() {
447        let cfg = ssrf_config();
448        assert!(validate_tracker_url("udp://tracker.example.com:6969/announce", cfg).is_ok());
449    }
450
451    #[test]
452    fn tracker_url_udp_localhost_allowed() {
453        // UDP trackers skip SSRF checks entirely.
454        let cfg = ssrf_config();
455        assert!(validate_tracker_url("udp://127.0.0.1:6969/announce", cfg).is_ok());
456        assert!(validate_tracker_url("udp://127.0.0.1:6969/bad/path", cfg).is_ok());
457    }
458
459    #[test]
460    fn tracker_url_localhost_good_path() {
461        let cfg = ssrf_config();
462        assert!(validate_tracker_url("http://127.0.0.1:8080/announce", cfg).is_ok());
463        assert!(validate_tracker_url("http://localhost/announce", cfg).is_ok());
464        assert!(validate_tracker_url("http://127.0.0.1/custom/announce", cfg).is_ok());
465    }
466
467    #[test]
468    fn tracker_url_localhost_bad_path() {
469        let cfg = ssrf_config();
470        assert!(matches!(
471            validate_tracker_url("http://127.0.0.1:8080/api/admin", cfg),
472            Err(UrlGuardError::LocalhostBadPath(_))
473        ));
474        assert!(matches!(
475            validate_tracker_url("http://localhost/", cfg),
476            Err(UrlGuardError::LocalhostBadPath(_))
477        ));
478    }
479
480    #[test]
481    fn tracker_url_localhost_ssrf_disabled() {
482        let mut cfg = ssrf_config();
483        cfg.ssrf_mitigation = false;
484        // With SSRF disabled, bad paths on localhost are allowed.
485        assert!(validate_tracker_url("http://127.0.0.1:8080/api/admin", cfg).is_ok());
486    }
487
488    #[test]
489    fn tracker_url_invalid() {
490        let cfg = ssrf_config();
491        assert!(matches!(
492            validate_tracker_url("not a url", cfg),
493            Err(UrlGuardError::InvalidUrl(_))
494        ));
495    }
496
497    #[test]
498    fn tracker_url_idna_rejected() {
499        let cfg = ssrf_config();
500        // Use a punycode-encoded domain since url crate normalises.
501        assert!(matches!(
502            validate_tracker_url("http://xn--nxasmq6b.example.com/announce", cfg),
503            Err(UrlGuardError::IdnaDomain(_))
504        ));
505    }
506
507    #[test]
508    fn tracker_url_idna_allowed() {
509        let cfg = permissive_config();
510        assert!(validate_tracker_url("http://xn--nxasmq6b.example.com/announce", cfg).is_ok());
511    }
512
513    // ── Web seed URL validation ──
514
515    #[test]
516    fn web_seed_url_valid_public() {
517        let cfg = ssrf_config();
518        assert!(validate_web_seed_url("http://cdn.example.com/files/", cfg).is_ok());
519        assert!(validate_web_seed_url("https://cdn.example.com/files/?token=abc", cfg).is_ok());
520    }
521
522    #[test]
523    fn web_seed_url_local_no_query() {
524        let cfg = ssrf_config();
525        assert!(validate_web_seed_url("http://192.168.1.100/files/", cfg).is_ok());
526        assert!(validate_web_seed_url("http://10.0.0.1/data/", cfg).is_ok());
527    }
528
529    #[test]
530    fn web_seed_url_local_with_query() {
531        let cfg = ssrf_config();
532        assert!(matches!(
533            validate_web_seed_url("http://192.168.1.100/files/?secret=abc", cfg),
534            Err(UrlGuardError::LocalNetworkQueryString)
535        ));
536        assert!(matches!(
537            validate_web_seed_url("http://localhost/files/?key=val", cfg),
538            Err(UrlGuardError::LocalNetworkQueryString)
539        ));
540    }
541
542    #[test]
543    fn web_seed_url_local_query_ssrf_disabled() {
544        let mut cfg = ssrf_config();
545        cfg.ssrf_mitigation = false;
546        assert!(validate_web_seed_url("http://192.168.1.100/files/?secret=abc", cfg).is_ok());
547    }
548
549    #[test]
550    fn web_seed_url_idna_rejected() {
551        let cfg = ssrf_config();
552        assert!(matches!(
553            validate_web_seed_url("http://xn--nxasmq6b.example.com/files/", cfg),
554            Err(UrlGuardError::IdnaDomain(_))
555        ));
556    }
557
558    // ── Redirect validation ──
559
560    #[test]
561    fn redirect_public_to_public() {
562        let cfg = ssrf_config();
563        let orig = Url::parse("http://tracker.example.com/announce").unwrap();
564        let redir = Url::parse("http://other.example.com/announce").unwrap();
565        assert!(validate_redirect(&orig, &redir, cfg).is_ok());
566    }
567
568    #[test]
569    fn redirect_public_to_private_blocked() {
570        let cfg = ssrf_config();
571        let orig = Url::parse("http://tracker.example.com/announce").unwrap();
572        let redir = Url::parse("http://192.168.1.1/announce").unwrap();
573        assert!(matches!(
574            validate_redirect(&orig, &redir, cfg),
575            Err(UrlGuardError::RedirectToPrivateIp(_))
576        ));
577    }
578
579    #[test]
580    fn redirect_public_to_localhost_blocked() {
581        let cfg = ssrf_config();
582        let orig = Url::parse("http://tracker.example.com/announce").unwrap();
583        let redir = Url::parse("http://127.0.0.1/announce").unwrap();
584        assert!(matches!(
585            validate_redirect(&orig, &redir, cfg),
586            Err(UrlGuardError::RedirectToPrivateIp(_))
587        ));
588
589        let redir_v6 = Url::parse("http://[::1]/announce").unwrap();
590        assert!(matches!(
591            validate_redirect(&orig, &redir_v6, cfg),
592            Err(UrlGuardError::RedirectToPrivateIp(_))
593        ));
594    }
595
596    #[test]
597    fn redirect_public_to_localhost_domain_blocked() {
598        let cfg = ssrf_config();
599        let orig = Url::parse("http://tracker.example.com/announce").unwrap();
600        let redir = Url::parse("http://localhost/announce").unwrap();
601        assert!(matches!(
602            validate_redirect(&orig, &redir, cfg),
603            Err(UrlGuardError::RedirectToPrivateIp(_))
604        ));
605    }
606
607    #[test]
608    fn redirect_private_to_private_allowed() {
609        let cfg = ssrf_config();
610        let orig = Url::parse("http://192.168.1.1/announce").unwrap();
611        let redir = Url::parse("http://10.0.0.1/announce").unwrap();
612        assert!(validate_redirect(&orig, &redir, cfg).is_ok());
613    }
614
615    #[test]
616    fn redirect_private_to_public_allowed() {
617        let cfg = ssrf_config();
618        let orig = Url::parse("http://192.168.1.1/announce").unwrap();
619        let redir = Url::parse("http://tracker.example.com/announce").unwrap();
620        assert!(validate_redirect(&orig, &redir, cfg).is_ok());
621    }
622
623    #[test]
624    fn redirect_ssrf_disabled() {
625        let mut cfg = ssrf_config();
626        cfg.ssrf_mitigation = false;
627        let orig = Url::parse("http://tracker.example.com/announce").unwrap();
628        let redir = Url::parse("http://192.168.1.1/announce").unwrap();
629        assert!(validate_redirect(&orig, &redir, cfg).is_ok());
630    }
631
632    // ── HTTP client builder ──
633
634    #[test]
635    fn build_client_default_config() {
636        let cfg = ssrf_config();
637        let client = build_http_client(cfg, None, "Torrent/0.60.0");
638        // Just verify it builds without panicking.
639        drop(client);
640    }
641
642    #[test]
643    fn build_client_with_proxy() {
644        let cfg = ssrf_config();
645        let client =
646            build_http_client(cfg, Some("http://proxy.example.com:8080"), "Torrent/0.60.0");
647        drop(client);
648    }
649
650    #[test]
651    fn build_client_invalid_proxy_fallback() {
652        let cfg = ssrf_config();
653        // Invalid proxy URL — should still build a client (proxy is silently skipped).
654        let client = build_http_client(cfg, Some("not a url"), "Torrent/0.60.0");
655        drop(client);
656    }
657
658    #[test]
659    fn build_client_permissive_config() {
660        let cfg = permissive_config();
661        let client = build_http_client(cfg, None, "Torrent/0.60.0");
662        drop(client);
663    }
664
665    #[test]
666    fn build_redirect_policy_ssrf_enabled() {
667        let cfg = ssrf_config();
668        let _policy = build_redirect_policy(cfg);
669    }
670
671    #[test]
672    fn build_redirect_policy_ssrf_disabled() {
673        let mut cfg = ssrf_config();
674        cfg.ssrf_mitigation = false;
675        let _policy = build_redirect_policy(cfg);
676    }
677
678    // ── M218: validate_user_url tests ──
679
680    #[test]
681    fn validate_user_url_rejects_localhost() {
682        let cfg = ssrf_config();
683        assert!(matches!(
684            validate_user_url("http://localhost/file.torrent", cfg),
685            Err(UrlGuardError::PrivateHostBlocked(_))
686        ));
687    }
688
689    #[test]
690    fn validate_user_url_rejects_loopback_ip() {
691        let cfg = ssrf_config();
692        assert!(matches!(
693            validate_user_url("http://127.0.0.1/x", cfg),
694            Err(UrlGuardError::PrivateHostBlocked(_))
695        ));
696        assert!(matches!(
697            validate_user_url("http://[::1]/x", cfg),
698            Err(UrlGuardError::PrivateHostBlocked(_))
699        ));
700    }
701
702    #[test]
703    fn validate_user_url_rejects_rfc1918() {
704        let cfg = ssrf_config();
705        for host in ["http://192.168.1.1/x", "http://10.0.0.5/x", "http://172.16.0.1/x"] {
706            assert!(
707                matches!(validate_user_url(host, cfg), Err(UrlGuardError::PrivateHostBlocked(_))),
708                "expected PrivateHostBlocked for {host}",
709            );
710        }
711    }
712
713    #[test]
714    fn validate_user_url_rejects_unspecified_and_mapped() {
715        // M218 OV: must also catch 0.0.0.0 and ::ffff:127.0.0.1 (the
716        // is_local_network gaps fixed in this milestone).
717        let cfg = ssrf_config();
718        for host in [
719            "http://0.0.0.0/x",
720            "http://[::]/x",
721            "http://[::ffff:127.0.0.1]/x",
722            "http://[::ffff:192.168.1.1]/x",
723        ] {
724            assert!(
725                matches!(validate_user_url(host, cfg), Err(UrlGuardError::PrivateHostBlocked(_))),
726                "expected PrivateHostBlocked for {host}",
727            );
728        }
729    }
730
731    #[test]
732    fn validate_user_url_allows_public_https() {
733        let cfg = ssrf_config();
734        assert!(validate_user_url("https://example.com/foo.torrent", cfg).is_ok());
735        assert!(validate_user_url("http://8.8.8.8/x.torrent", cfg).is_ok());
736    }
737
738    #[test]
739    fn validate_user_url_rejects_unsupported_scheme() {
740        let cfg = ssrf_config();
741        for url in [
742            "file:///etc/passwd",
743            "ftp://example.com/x",
744            "gopher://example.com/0",
745            "data:application/octet-stream;base64,ZA==",
746        ] {
747            assert!(
748                matches!(validate_user_url(url, cfg), Err(UrlGuardError::InvalidUrl(_))),
749                "expected InvalidUrl for {url}",
750            );
751        }
752    }
753
754    #[test]
755    fn validate_user_url_rejects_idna_when_disallowed() {
756        let cfg = ssrf_config();
757        assert!(matches!(
758            validate_user_url("http://example.中国/x", cfg),
759            Err(UrlGuardError::IdnaDomain(_))
760        ));
761        let permissive = permissive_config();
762        // With permissive config, IDNA passes but private check is also off,
763        // so an IDNA + public host should succeed.
764        assert!(validate_user_url("http://example.中国/x", permissive).is_ok());
765    }
766
767    #[test]
768    fn validate_user_url_rejects_malformed_url() {
769        let cfg = ssrf_config();
770        assert!(matches!(
771            validate_user_url("not a url", cfg),
772            Err(UrlGuardError::InvalidUrl(_))
773        ));
774    }
775
776    // ── Integration / scenario tests ──
777
778    #[test]
779    fn scenario_malicious_torrent_ssrf_via_tracker() {
780        let cfg = ssrf_config();
781        let err =
782            validate_tracker_url("http://127.0.0.1:9090/api/admin/delete-all", cfg).unwrap_err();
783        assert!(matches!(err, UrlGuardError::LocalhostBadPath(_)));
784        assert!(validate_tracker_url("http://127.0.0.1:9090/announce", cfg).is_ok());
785    }
786
787    #[test]
788    fn scenario_malicious_torrent_ssrf_via_web_seed() {
789        let cfg = ssrf_config();
790        let err = validate_web_seed_url("http://192.168.1.1/api?action=reboot", cfg).unwrap_err();
791        assert!(matches!(err, UrlGuardError::LocalNetworkQueryString));
792    }
793
794    #[test]
795    fn scenario_redirect_ssrf() {
796        let cfg = ssrf_config();
797        let orig = Url::parse("http://evil-tracker.example.com/announce").unwrap();
798        let redir = Url::parse("http://169.254.169.254/metadata/v1/").unwrap();
799        let err = validate_redirect(&orig, &redir, cfg).unwrap_err();
800        assert!(matches!(err, UrlGuardError::RedirectToPrivateIp(_)));
801    }
802
803    #[test]
804    fn scenario_legitimate_local_tracker() {
805        let cfg = ssrf_config();
806        assert!(validate_tracker_url("http://192.168.1.100:6969/announce", cfg).is_ok());
807        assert!(validate_tracker_url("http://[fe80::1]:6969/announce", cfg).is_ok());
808    }
809
810    #[test]
811    fn scenario_homograph_attack() {
812        let cfg = ssrf_config();
813        // Cyrillic 'а' (U+0430) looks identical to Latin 'a'.
814        // The url crate punycode-encodes non-ASCII hostnames, so test with the
815        // pre-encoded form. With allow_idna: false the xn-- label is rejected,
816        // preventing homograph-based tracker substitution attacks.
817        assert!(matches!(
818            validate_tracker_url("http://xn--nxasmq6b.evil.com/announce", cfg),
819            Err(UrlGuardError::IdnaDomain(_))
820        ));
821    }
822
823    #[test]
824    fn scenario_all_protections_disabled() {
825        let cfg = UrlSecurityConfig {
826            ssrf_mitigation: false,
827            allow_idna: true,
828            validate_https_trackers: true,
829        };
830        assert!(validate_tracker_url("http://127.0.0.1:9090/admin", cfg).is_ok());
831        assert!(validate_web_seed_url("http://10.0.0.1/data?cmd=exec", cfg).is_ok());
832        let orig = Url::parse("http://tracker.example.com/announce").unwrap();
833        let redir = Url::parse("http://127.0.0.1/admin").unwrap();
834        assert!(validate_redirect(&orig, &redir, cfg).is_ok());
835    }
836}