1use std::net::IpAddr;
9
10use url::Url;
11
12use crate::rate_limiter::is_local_network;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct UrlSecurityConfig {
19 pub ssrf_mitigation: bool,
21 pub allow_idna: bool,
23 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#[derive(Debug, thiserror::Error)]
51pub enum UrlGuardError {
52 #[error("invalid URL: {0}")]
54 InvalidUrl(String),
55
56 #[error("SSRF: localhost tracker must use /announce path, got: {0}")]
60 LocalhostBadPath(String),
61
62 #[error("local-network web seed URL must not contain a query string")]
66 LocalNetworkQueryString,
67
68 #[error("SSRF: redirect from global URL to private/local IP {0} blocked")]
73 RedirectToPrivateIp(IpAddr),
74
75 #[error("internationalised domain name (IDNA) rejected: {0}")]
78 IdnaDomain(String),
79
80 #[error("SSRF: URL host {0} is on a private/local network")]
86 PrivateHostBlocked(String),
87}
88
89fn 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
100fn 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
110fn is_local_host_url(url: &Url) -> bool {
112 match host_ip(url) {
113 Some(ip) => is_local_network(ip),
114 None => {
115 matches!(url.host_str(), Some("localhost"))
117 }
118 }
119}
120
121fn has_idna_domain(url: &Url) -> bool {
127 match url.host_str() {
128 Some(host) => {
129 if !host.is_ascii() {
131 return true;
132 }
133 host.split('.').any(|label| label.starts_with("xn--"))
135 }
136 None => false,
137 }
138}
139
140pub(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 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 if url.scheme() == "udp" {
162 return Ok(());
163 }
164
165 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
173pub(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#[allow(dead_code)] pub(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 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
233pub 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#[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
330pub(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#[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 #[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 #[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 let url = Url::parse("http://xn--nxasmq6b.example.com/path").unwrap();
425 assert!(has_idna_domain(&url));
426
427 let url = Url::parse("http://tracker.example.com/announce").unwrap();
429 assert!(!has_idna_domain(&url));
430
431 let url = Url::parse("http://192.168.1.1/path").unwrap();
433 assert!(!has_idna_domain(&url));
434 }
435
436 #[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 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 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 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 #[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 #[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 #[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 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 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 #[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 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 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 #[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 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}