1use std::net::IpAddr;
9
10use url::Url;
11
12use irontide_core::is_local_network;
13
14pub use irontide_session_types::UrlSecurityConfig;
19
20#[derive(Debug, thiserror::Error)]
24pub enum UrlGuardError {
25 #[error("invalid URL: {0}")]
27 InvalidUrl(String),
28
29 #[error("SSRF: localhost tracker must use /announce path, got: {0}")]
33 LocalhostBadPath(String),
34
35 #[error("local-network web seed URL must not contain a query string")]
39 LocalNetworkQueryString,
40
41 #[error("SSRF: redirect from global URL to private/local IP {0} blocked")]
46 RedirectToPrivateIp(IpAddr),
47
48 #[error("internationalised domain name (IDNA) rejected: {0}")]
51 IdnaDomain(String),
52
53 #[error("SSRF: URL host {0} is on a private/local network")]
59 PrivateHostBlocked(String),
60}
61
62fn 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
73fn 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
83fn is_local_host_url(url: &Url) -> bool {
85 match host_ip(url) {
86 Some(ip) => is_local_network(ip),
87 None => {
88 matches!(url.host_str(), Some("localhost"))
90 }
91 }
92}
93
94fn has_idna_domain(url: &Url) -> bool {
100 match url.host_str() {
101 Some(host) => {
102 if !host.is_ascii() {
104 return true;
105 }
106 host.split('.').any(|label| label.starts_with("xn--"))
108 }
109 None => false,
110 }
111}
112
113pub(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 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 if url.scheme() == "udp" {
135 return Ok(());
136 }
137
138 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
146pub(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#[allow(dead_code)] pub(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 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
206pub 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#[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
300pub(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#[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 #[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 #[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 let url = Url::parse("http://xn--nxasmq6b.example.com/path").unwrap();
410 assert!(has_idna_domain(&url));
411
412 let url = Url::parse("http://tracker.example.com/announce").unwrap();
414 assert!(!has_idna_domain(&url));
415
416 let url = Url::parse("http://192.168.1.1/path").unwrap();
418 assert!(!has_idna_domain(&url));
419 }
420
421 #[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 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 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 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 #[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 #[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 #[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 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 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 #[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 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 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 #[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 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}