Skip to main content

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