1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
8
9use reqwest::redirect::{Attempt, Policy};
10use tracing::{error, warn};
11
12pub fn validate_safe_url(
27 url: &str,
28 allowed_hosts: &[String],
29 block_private: bool,
30) -> Result<(), String> {
31 let parsed_url = reqwest::Url::parse(url).map_err(|e| format!("Invalid URL format: {e}"))?;
33
34 let scheme = parsed_url.scheme();
36 if scheme != "http" && scheme != "https" {
37 error!(
38 url = sanitize_url(url),
39 scheme = scheme,
40 "RPC URL rejected: invalid scheme"
41 );
42 return Err(format!(
43 "Invalid URL scheme '{scheme}': only http and https are allowed"
44 ));
45 }
46
47 let host = parsed_url
49 .host_str()
50 .ok_or_else(|| "URL must contain a host".to_string())?;
51
52 if !allowed_hosts.is_empty()
54 && !allowed_hosts
55 .iter()
56 .any(|allowed| allowed.eq_ignore_ascii_case(host))
57 {
58 error!(
59 url = sanitize_url(url),
60 host = host,
61 "RPC URL rejected: host not in allow-list"
62 );
63 return Err(format!("Host '{host}' is not in the allowed hosts list"));
64 }
65
66 if is_metadata_hostname(host) {
68 error!(
69 url = sanitize_url(url),
70 host = host,
71 "RPC URL rejected: cloud metadata hostname"
72 );
73 return Err(
74 "Cloud metadata hostnames (metadata.google.internal) are not allowed".to_string(),
75 );
76 }
77
78 if block_private && is_dangerous_hostname(host) {
80 error!(
81 url = sanitize_url(url),
82 host = host,
83 "RPC URL rejected: dangerous hostname"
84 );
85 return Err(format!("Hostname '{host}' is not allowed"));
86 }
87
88 if let Ok(ip) = host.parse::<IpAddr>() {
90 return validate_ip_address(&ip, block_private, url);
91 }
92
93 Ok(())
102}
103
104fn is_metadata_hostname(host: &str) -> bool {
109 let host_lower = host.to_lowercase();
110
111 host_lower == "metadata.google.internal"
114}
115
116fn is_dangerous_hostname(host: &str) -> bool {
121 let host_lower = host.to_lowercase();
122
123 if host_lower == "localhost" || host_lower.ends_with(".localhost") {
125 return true;
126 }
127
128 if host_lower.ends_with(".internal") {
132 return true;
133 }
134
135 false
136}
137
138fn validate_ip_address(ip: &IpAddr, block_private: bool, url: &str) -> Result<(), String> {
140 if let IpAddr::V6(ipv6) = ip {
143 if let Some(mapped_v4) = ipv6.to_ipv4_mapped() {
144 return validate_ip_address(&IpAddr::V4(mapped_v4), block_private, url);
145 }
146 }
147
148 if is_unspecified(ip) {
150 error!(
151 url = sanitize_url(url),
152 ip = %ip,
153 "RPC URL rejected: unspecified IP address"
154 );
155 return Err("Unspecified IP addresses (0.0.0.0, ::) are not allowed".to_string());
156 }
157
158 if is_metadata_endpoint(ip) {
160 error!(
161 url = sanitize_url(url),
162 ip = %ip,
163 "RPC URL rejected: cloud metadata endpoint"
164 );
165 return Err(
166 "Cloud metadata endpoints (169.254.169.254, fd00:ec2::254) are not allowed".to_string(),
167 );
168 }
169
170 if block_private {
172 if is_loopback(ip) {
173 error!(
174 url = sanitize_url(url),
175 ip = %ip,
176 "RPC URL rejected: loopback address"
177 );
178 return Err("Loopback addresses (127.0.0.0/8, ::1) are not allowed".to_string());
179 }
180
181 if is_private_ip_range(ip) {
182 error!(
183 url = sanitize_url(url),
184 ip = %ip,
185 "RPC URL rejected: private IP address"
186 );
187 return Err(
188 "Private IP addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7) are not allowed"
189 .to_string(),
190 );
191 }
192
193 if is_link_local(ip) {
194 error!(
195 url = sanitize_url(url),
196 ip = %ip,
197 "RPC URL rejected: link-local address"
198 );
199 return Err(
200 "Link-local addresses (169.254.0.0/16, fe80::/10) are not allowed".to_string(),
201 );
202 }
203 }
204
205 Ok(())
206}
207
208fn is_private_ip_range(ip: &IpAddr) -> bool {
210 match ip {
211 IpAddr::V4(ipv4) => ipv4.is_private(),
212 IpAddr::V6(ipv6) => ipv6.is_unique_local(),
213 }
214}
215
216fn is_loopback(ip: &IpAddr) -> bool {
218 ip.is_loopback()
219}
220
221fn is_link_local(ip: &IpAddr) -> bool {
223 match ip {
224 IpAddr::V4(ipv4) => ipv4.is_link_local(),
225 IpAddr::V6(ipv6) => ipv6.is_unicast_link_local(),
226 }
227}
228
229fn is_unspecified(ip: &IpAddr) -> bool {
231 ip.is_unspecified()
232}
233
234fn is_metadata_endpoint(ip: &IpAddr) -> bool {
236 match ip {
237 IpAddr::V4(ipv4) => {
238 *ipv4 == Ipv4Addr::new(169, 254, 169, 254)
240 }
241 IpAddr::V6(ipv6) => {
242 *ipv6 == Ipv6Addr::new(0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254)
244 }
245 }
246}
247
248fn sanitize_url(url: &str) -> String {
250 if let Ok(parsed) = reqwest::Url::parse(url) {
251 let mut sanitized = parsed.clone();
252 sanitized.set_query(None);
253 sanitized.set_fragment(None);
254 sanitized.to_string()
255 } else {
256 "[invalid URL]".to_string()
257 }
258}
259
260pub fn sanitize_url_for_error(url: &str) -> String {
286 if let Ok(parsed) = reqwest::Url::parse(url) {
287 let scheme = parsed.scheme();
288 let host = parsed.host_str().unwrap_or("[no host]");
289 let port_suffix = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
290 format!("{scheme}://{host}{port_suffix}/[path redacted]")
291 } else {
292 "[invalid URL]".to_string()
293 }
294}
295
296pub fn create_secure_redirect_policy() -> Policy {
316 Policy::custom(|attempt: Attempt| {
317 let target_url = attempt.url();
319
320 let previous_urls = attempt.previous();
322
323 if previous_urls.len() > 1 {
325 warn!(
326 redirect_count = previous_urls.len(),
327 "Blocking redirect: too many redirects in chain"
328 );
329 return attempt.stop();
330 }
331
332 let Some(original_url) = previous_urls.first() else {
334 warn!("Blocking redirect: no previous URL found");
336 return attempt.stop();
337 };
338
339 let original_host = original_url.host_str().unwrap_or("");
341 let target_host = target_url.host_str().unwrap_or("");
342 if !original_host.eq_ignore_ascii_case(target_host) {
343 warn!(
344 original_host = original_host,
345 target_host = target_host,
346 "Blocking redirect: host mismatch"
347 );
348 return attempt.stop();
349 }
350
351 let original_port = original_url.port_or_known_default();
353 let target_port = target_url.port_or_known_default();
354 if original_port != target_port {
355 warn!(
356 original_port = ?original_port,
357 target_port = ?target_port,
358 "Blocking redirect: port mismatch"
359 );
360 return attempt.stop();
361 }
362
363 let original_scheme = original_url.scheme();
365 let target_scheme = target_url.scheme();
366 if original_scheme == "http" && target_scheme == "https" {
367 tracing::debug!(
368 original = %original_url,
369 target = %target_url,
370 "Allowing HTTP to HTTPS redirect"
371 );
372 attempt.follow()
373 } else {
374 warn!(
375 original_scheme = original_scheme,
376 target_scheme = target_scheme,
377 "Blocking redirect: only HTTP to HTTPS upgrades are allowed"
378 );
379 attempt.stop()
380 }
381 })
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_private_ipv4_detection() {
390 assert!(is_private_ip_range(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
391 assert!(is_private_ip_range(&IpAddr::V4(Ipv4Addr::new(
392 172, 16, 0, 1
393 ))));
394 assert!(is_private_ip_range(&IpAddr::V4(Ipv4Addr::new(
395 192, 168, 1, 1
396 ))));
397 assert!(!is_private_ip_range(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
398 }
399
400 #[test]
401 fn test_loopback_detection() {
402 assert!(is_loopback(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
403 assert!(is_loopback(&IpAddr::V6(Ipv6Addr::new(
404 0, 0, 0, 0, 0, 0, 0, 1
405 ))));
406 assert!(!is_loopback(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
407 }
408
409 #[test]
410 fn test_link_local_detection() {
411 assert!(is_link_local(&IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1))));
412 assert!(is_link_local(&IpAddr::V6(Ipv6Addr::new(
413 0xfe80, 0, 0, 0, 0, 0, 0, 1
414 ))));
415 assert!(!is_link_local(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
416 }
417
418 #[test]
419 fn test_metadata_endpoint_detection() {
420 assert!(is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
421 169, 254, 169, 254
422 ))));
423 assert!(is_metadata_endpoint(&IpAddr::V6(Ipv6Addr::new(
424 0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254
425 ))));
426 assert!(!is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
427 8, 8, 8, 8
428 ))));
429 }
430
431 #[test]
432 fn test_unspecified_detection() {
433 assert!(is_unspecified(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))));
434 assert!(is_unspecified(&IpAddr::V6(Ipv6Addr::new(
435 0, 0, 0, 0, 0, 0, 0, 0
436 ))));
437 assert!(!is_unspecified(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
438 }
439
440 #[test]
441 fn test_validate_public_ip() {
442 let result = validate_safe_url("http://8.8.8.8:8545", &[], false);
443 assert!(result.is_ok());
444 }
445
446 #[test]
447 fn test_block_private_ip() {
448 let result = validate_safe_url("http://192.168.1.1:8545", &[], true);
449 assert!(result.is_err());
450 assert!(result.unwrap_err().contains("Private IP"));
451 }
452
453 #[test]
454 fn test_allow_private_ip_when_disabled() {
455 let result = validate_safe_url("http://192.168.1.1:8545", &[], false);
456 assert!(result.is_ok());
457 }
458
459 #[test]
460 fn test_block_loopback() {
461 let result = validate_safe_url("http://127.0.0.1:8545", &[], true);
462 assert!(result.is_err());
463 assert!(result.unwrap_err().contains("Loopback"));
464 }
465
466 #[test]
467 fn test_block_metadata_endpoint_always() {
468 let result = validate_safe_url("http://169.254.169.254/latest/meta-data", &[], false);
470 assert!(result.is_err());
471 assert!(result.unwrap_err().contains("metadata"));
472 }
473
474 #[test]
475 fn test_allow_list_enforced_when_provided() {
476 let result = validate_safe_url(
478 "https://eth-mainnet.g.alchemy.com/v2/demo",
479 &["eth-mainnet.g.alchemy.com".to_string()],
480 false,
481 );
482 assert!(result.is_ok());
483 }
484
485 #[test]
486 fn test_allow_list_case_insensitive() {
487 let result = validate_safe_url(
490 "https://eth-mainnet.g.alchemy.com/v2/demo",
491 &["ETH-MAINNET.G.ALCHEMY.COM".to_string()],
492 false,
493 );
494 assert!(result.is_ok());
495
496 let result = validate_safe_url(
498 "https://ETH-MAINNET.G.ALCHEMY.COM/v2/demo",
499 &["eth-mainnet.g.alchemy.com".to_string()],
500 false,
501 );
502 assert!(result.is_ok());
503
504 let result = validate_safe_url(
506 "https://Eth-Mainnet.G.Alchemy.COM/v2/demo",
507 &["ETH-mainnet.g.ALCHEMY.com".to_string()],
508 false,
509 );
510 assert!(result.is_ok());
511 }
512
513 #[test]
514 fn test_allow_list_rejects_unlisted_host() {
515 let result = validate_safe_url(
517 "https://mainnet.infura.io/v3/demo",
518 &["eth-mainnet.g.alchemy.com".to_string()],
519 false,
520 );
521 assert!(result.is_err());
522 assert!(result
523 .unwrap_err()
524 .contains("not in the allowed hosts list"));
525 }
526
527 #[test]
528 fn test_empty_allow_list_permits_all() {
529 let result = validate_safe_url("https://mainnet.infura.io/v3/demo", &[], false);
531 assert!(result.is_ok());
532 }
533
534 #[test]
535 fn test_invalid_url() {
536 let result = validate_safe_url("not-a-url", &[], false);
537 assert!(result.is_err());
538 assert!(result.unwrap_err().contains("Invalid URL format"));
539 }
540
541 #[test]
542 fn test_url_without_host() {
543 let result = validate_safe_url("file:///path/to/file", &[], false);
545 assert!(result.is_err());
546 assert!(result.unwrap_err().contains("Invalid URL scheme"));
547 }
548
549 #[test]
550 fn test_unspecified_always_blocked() {
551 let result = validate_safe_url("http://0.0.0.0:8545", &[], false);
552 assert!(result.is_err());
553 assert!(result.unwrap_err().contains("Unspecified"));
554 }
555
556 #[test]
557 fn test_sanitize_url() {
558 assert_eq!(
559 sanitize_url("https://example.com/path?key=secret#fragment"),
560 "https://example.com/path"
561 );
562 assert_eq!(sanitize_url("invalid"), "[invalid URL]");
563 }
564
565 #[test]
566 fn test_sanitize_url_for_error_redacts_path() {
567 assert_eq!(
569 sanitize_url_for_error("https://mainnet.infura.io/v3/SECRET_API_KEY"),
570 "https://mainnet.infura.io/[path redacted]"
571 );
572 assert_eq!(
573 sanitize_url_for_error("https://eth-mainnet.g.alchemy.com/v2/MY_API_KEY"),
574 "https://eth-mainnet.g.alchemy.com/[path redacted]"
575 );
576 }
577
578 #[test]
579 fn test_sanitize_url_for_error_redacts_query() {
580 assert_eq!(
582 sanitize_url_for_error("https://api.example.com/rpc?apikey=secret"),
583 "https://api.example.com/[path redacted]"
584 );
585 }
586
587 #[test]
588 fn test_sanitize_url_for_error_preserves_port() {
589 assert_eq!(
591 sanitize_url_for_error("https://rpc.example.com:8545/path"),
592 "https://rpc.example.com:8545/[path redacted]"
593 );
594 assert_eq!(
595 sanitize_url_for_error("http://localhost:8545/secret"),
596 "http://localhost:8545/[path redacted]"
597 );
598 }
599
600 #[test]
601 fn test_sanitize_url_for_error_handles_invalid() {
602 assert_eq!(sanitize_url_for_error("not-a-url"), "[invalid URL]");
604 assert_eq!(sanitize_url_for_error(""), "[invalid URL]");
605 }
606
607 #[test]
608 fn test_sanitize_url_for_error_preserves_scheme() {
609 assert_eq!(
610 sanitize_url_for_error("http://example.com/path"),
611 "http://example.com/[path redacted]"
612 );
613 assert_eq!(
614 sanitize_url_for_error("https://example.com/path"),
615 "https://example.com/[path redacted]"
616 );
617 }
618
619 #[test]
622 fn test_block_invalid_scheme() {
623 let result = validate_safe_url("ftp://example.com:8545", &[], false);
625 assert!(result.is_err());
626 assert!(result.unwrap_err().contains("Invalid URL scheme"));
627
628 let result = validate_safe_url("gopher://example.com:8545", &[], false);
630 assert!(result.is_err());
631 assert!(result.unwrap_err().contains("Invalid URL scheme"));
632 }
633
634 #[test]
635 fn test_allow_valid_schemes() {
636 let result = validate_safe_url("http://example.com:8545", &[], false);
638 assert!(result.is_ok());
639
640 let result = validate_safe_url("https://example.com:8545", &[], false);
642 assert!(result.is_ok());
643 }
644
645 #[test]
646 fn test_block_localhost_hostname() {
647 let result = validate_safe_url("http://localhost:8545", &[], true);
649 assert!(result.is_err());
650 assert!(result.unwrap_err().contains("not allowed"));
651 }
652
653 #[test]
654 fn test_allow_localhost_when_disabled() {
655 let result = validate_safe_url("http://localhost:8545", &[], false);
657 assert!(result.is_ok());
658 }
659
660 #[test]
661 fn test_block_localhost_subdomain() {
662 let result = validate_safe_url("http://subdomain.localhost:8545", &[], true);
664 assert!(result.is_err());
665 assert!(result.unwrap_err().contains("not allowed"));
666 }
667
668 #[test]
669 fn test_block_metadata_google_internal_always() {
670 let result = validate_safe_url(
673 "http://metadata.google.internal/computeMetadata/v1",
674 &[],
675 true,
676 );
677 assert!(result.is_err());
678 assert!(result.unwrap_err().contains("metadata"));
679
680 let result = validate_safe_url(
682 "http://metadata.google.internal/computeMetadata/v1",
683 &[],
684 false,
685 );
686 assert!(result.is_err());
687 assert!(result.unwrap_err().contains("metadata"));
688 }
689
690 #[test]
691 fn test_block_internal_domain() {
692 let result = validate_safe_url("http://some-service.internal:8545", &[], true);
694 assert!(result.is_err());
695 assert!(result.unwrap_err().contains("not allowed"));
696 }
697
698 #[test]
699 fn test_allow_list_with_ip_address() {
700 let result = validate_safe_url("http://8.8.8.8:8545", &["8.8.8.8".to_string()], false);
702 assert!(result.is_ok());
703 }
704
705 #[test]
706 fn test_dangerous_hostname_detection() {
707 assert!(is_dangerous_hostname("localhost"));
709 assert!(is_dangerous_hostname("LOCALHOST")); assert!(is_dangerous_hostname("sub.localhost"));
711 assert!(is_dangerous_hostname("service.internal"));
713 assert!(is_dangerous_hostname("some-app.internal"));
714 assert!(!is_dangerous_hostname("example.com"));
716 assert!(!is_dangerous_hostname("eth-mainnet.g.alchemy.com"));
717 assert!(is_dangerous_hostname("metadata.google.internal"));
720 }
721
722 #[test]
723 fn test_metadata_hostname_detection() {
724 assert!(is_metadata_hostname("metadata.google.internal"));
726 assert!(is_metadata_hostname("METADATA.GOOGLE.INTERNAL")); assert!(!is_metadata_hostname("localhost"));
729 assert!(!is_metadata_hostname("example.com"));
730 assert!(!is_metadata_hostname("service.internal"));
731 }
732
733 #[test]
734 fn test_ipv4_mapped_detection() {
735 let mapped_loopback: Ipv6Addr = "::ffff:127.0.0.1".parse().unwrap();
737 assert!(mapped_loopback.to_ipv4_mapped().is_some());
738 assert!(mapped_loopback.to_ipv4_mapped().unwrap().is_loopback());
739
740 let mapped_private: Ipv6Addr = "::ffff:192.168.1.1".parse().unwrap();
741 assert!(mapped_private.to_ipv4_mapped().is_some());
742 assert!(mapped_private.to_ipv4_mapped().unwrap().is_private());
743 }
744
745 #[test]
748 fn test_private_ipv6_detection() {
749 assert!(is_private_ip_range(&IpAddr::V6(Ipv6Addr::new(
751 0xfc00, 0, 0, 0, 0, 0, 0, 1
752 ))));
753 assert!(is_private_ip_range(&IpAddr::V6(Ipv6Addr::new(
754 0xfd00, 0, 0, 0, 0, 0, 0, 1
755 ))));
756 assert!(is_private_ip_range(&IpAddr::V6(Ipv6Addr::new(
757 0xfdff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff
758 ))));
759 assert!(!is_private_ip_range(&IpAddr::V6(Ipv6Addr::new(
761 0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888
762 ))));
763 }
764
765 #[test]
766 fn test_block_link_local_ipv4() {
767 let result = validate_safe_url("http://169.254.1.1:8545", &[], true);
770 assert!(result.is_err());
771 assert!(result.unwrap_err().contains("Link-local"));
772 }
773
774 #[test]
775 fn test_allow_link_local_ipv4_when_disabled() {
776 let result = validate_safe_url("http://169.254.1.1:8545", &[], false);
778 assert!(result.is_ok());
779 }
780
781 #[test]
782 fn test_loopback_range_boundary() {
783 let result = validate_safe_url("http://127.0.0.1:8545", &[], true);
785 assert!(result.is_err());
786 assert!(result.unwrap_err().contains("Loopback"));
787
788 let result = validate_safe_url("http://127.255.255.1:8545", &[], true);
789 assert!(result.is_err());
790 assert!(result.unwrap_err().contains("Loopback"));
791
792 let result = validate_safe_url("http://127.0.0.255:8545", &[], true);
793 assert!(result.is_err());
794 assert!(result.unwrap_err().contains("Loopback"));
795 }
796
797 #[test]
798 fn test_private_ip_range_boundaries() {
799 let result = validate_safe_url("http://10.0.0.0:8545", &[], true);
801 assert!(result.is_err());
802 assert!(result.unwrap_err().contains("Private IP"));
803
804 let result = validate_safe_url("http://10.255.255.255:8545", &[], true);
805 assert!(result.is_err());
806 assert!(result.unwrap_err().contains("Private IP"));
807
808 let result = validate_safe_url("http://172.16.0.0:8545", &[], true);
810 assert!(result.is_err());
811 assert!(result.unwrap_err().contains("Private IP"));
812
813 let result = validate_safe_url("http://172.31.255.255:8545", &[], true);
814 assert!(result.is_err());
815 assert!(result.unwrap_err().contains("Private IP"));
816
817 let result = validate_safe_url("http://172.15.255.255:8545", &[], true);
819 assert!(result.is_ok());
820
821 let result = validate_safe_url("http://172.32.0.1:8545", &[], true);
823 assert!(result.is_ok());
824
825 let result = validate_safe_url("http://192.168.0.0:8545", &[], true);
827 assert!(result.is_err());
828 assert!(result.unwrap_err().contains("Private IP"));
829
830 let result = validate_safe_url("http://192.168.255.255:8545", &[], true);
831 assert!(result.is_err());
832 assert!(result.unwrap_err().contains("Private IP"));
833 }
834
835 #[test]
836 fn test_multiple_allowed_hosts() {
837 let allowed = vec![
839 "eth-mainnet.g.alchemy.com".to_string(),
840 "mainnet.infura.io".to_string(),
841 "rpc.ankr.com".to_string(),
842 ];
843
844 let result = validate_safe_url("https://eth-mainnet.g.alchemy.com/v2/key", &allowed, false);
845 assert!(result.is_ok());
846
847 let result = validate_safe_url("https://mainnet.infura.io/v3/key", &allowed, false);
848 assert!(result.is_ok());
849
850 let result = validate_safe_url("https://rpc.ankr.com/eth", &allowed, false);
851 assert!(result.is_ok());
852
853 let result = validate_safe_url("https://other-provider.com/rpc", &allowed, false);
855 assert!(result.is_err());
856 assert!(result
857 .unwrap_err()
858 .contains("not in the allowed hosts list"));
859 }
860
861 #[test]
862 fn test_url_with_credentials() {
863 let result = validate_safe_url("http://user:pass@8.8.8.8:8545", &[], false);
865 assert!(result.is_ok());
866
867 let result = validate_safe_url("http://user:pass@192.168.1.1:8545", &[], true);
869 assert!(result.is_err());
870 assert!(result.unwrap_err().contains("Private IP"));
871 }
872
873 #[test]
874 fn test_url_without_port() {
875 let result = validate_safe_url("http://8.8.8.8", &[], false);
877 assert!(result.is_ok());
878
879 let result = validate_safe_url("https://example.com", &[], false);
880 assert!(result.is_ok());
881
882 let result = validate_safe_url("http://192.168.1.1", &[], true);
883 assert!(result.is_err());
884 assert!(result.unwrap_err().contains("Private IP"));
885 }
886
887 #[test]
888 fn test_url_with_path_and_query() {
889 let result = validate_safe_url("https://example.com/path/to/rpc?key=value", &[], false);
891 assert!(result.is_ok());
892
893 let result = validate_safe_url(
894 "https://192.168.1.1/path/to/rpc?key=value#fragment",
895 &[],
896 true,
897 );
898 assert!(result.is_err());
899 assert!(result.unwrap_err().contains("Private IP"));
900 }
901
902 #[test]
903 fn test_sanitize_url_no_path() {
904 assert_eq!(sanitize_url("https://example.com"), "https://example.com/");
906
907 assert_eq!(
908 sanitize_url("https://example.com:8545"),
909 "https://example.com:8545/"
910 );
911 }
912
913 #[test]
914 fn test_sanitize_url_preserves_path() {
915 assert_eq!(
917 sanitize_url("https://example.com/api/v1/rpc"),
918 "https://example.com/api/v1/rpc"
919 );
920 }
921
922 #[test]
923 fn test_sanitize_url_for_error_no_path() {
924 assert_eq!(
926 sanitize_url_for_error("https://example.com"),
927 "https://example.com/[path redacted]"
928 );
929 }
930
931 #[test]
932 fn test_sanitize_url_for_error_with_credentials() {
933 let result = sanitize_url_for_error("https://user:pass@example.com/path");
935 assert!(result.contains("example.com"));
936 assert!(result.contains("[path redacted]"));
937 }
938
939 #[test]
940 fn test_is_link_local_ipv6_variations() {
941 assert!(is_link_local(&IpAddr::V6(Ipv6Addr::new(
943 0xfe80, 0, 0, 0, 0, 0, 0, 1
944 ))));
945 assert!(is_link_local(&IpAddr::V6(Ipv6Addr::new(
946 0xfe80, 0, 0, 0, 0x1234, 0x5678, 0x9abc, 0xdef0
947 ))));
948 assert!(is_link_local(&IpAddr::V6(Ipv6Addr::new(
949 0xfebf, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff
950 ))));
951 assert!(!is_link_local(&IpAddr::V6(Ipv6Addr::new(
953 0xfec0, 0, 0, 0, 0, 0, 0, 1
954 ))));
955 }
956
957 #[test]
958 fn test_validate_ip_address_directly() {
959 let mapped_loopback = IpAddr::V6("::ffff:127.0.0.1".parse().unwrap());
964 let result = validate_ip_address(&mapped_loopback, true, "http://test");
965 assert!(result.is_err());
966 assert!(result.unwrap_err().contains("Loopback"));
967
968 let mapped_private = IpAddr::V6("::ffff:192.168.1.1".parse().unwrap());
970 let result = validate_ip_address(&mapped_private, true, "http://test");
971 assert!(result.is_err());
972 assert!(result.unwrap_err().contains("Private IP"));
973
974 let mapped_metadata = IpAddr::V6("::ffff:169.254.169.254".parse().unwrap());
976 let result = validate_ip_address(&mapped_metadata, false, "http://test");
977 assert!(result.is_err());
978 assert!(result.unwrap_err().contains("metadata"));
979
980 let mapped_public = IpAddr::V6("::ffff:8.8.8.8".parse().unwrap());
982 let result = validate_ip_address(&mapped_public, true, "http://test");
983 assert!(result.is_ok());
984 }
985
986 #[test]
987 fn test_allow_internal_domain_when_disabled() {
988 let result = validate_safe_url("http://some-service.internal:8545", &[], false);
991 assert!(result.is_ok());
992 }
993
994 #[test]
995 fn test_localhost_uppercase() {
996 let result = validate_safe_url("http://LOCALHOST:8545", &[], true);
998 assert!(result.is_err());
999 assert!(result.unwrap_err().contains("not allowed"));
1000
1001 let result = validate_safe_url("http://LocalHost:8545", &[], true);
1002 assert!(result.is_err());
1003 assert!(result.unwrap_err().contains("not allowed"));
1004 }
1005
1006 #[test]
1007 fn test_data_uri_scheme_rejected() {
1008 let result = validate_safe_url("data:text/html,<h1>test</h1>", &[], false);
1010 assert!(result.is_err());
1011 }
1013
1014 #[test]
1015 fn test_javascript_uri_scheme_rejected() {
1016 let result = validate_safe_url("javascript:alert(1)", &[], false);
1018 assert!(result.is_err());
1019 }
1020
1021 #[test]
1026 fn test_metadata_endpoint_ipv4_variations() {
1027 assert!(is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
1029 169, 254, 169, 254
1030 ))));
1031 assert!(!is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
1033 169, 254, 169, 253
1034 ))));
1035 assert!(!is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
1036 169, 254, 1, 1
1037 ))));
1038 }
1039
1040 #[test]
1041 fn test_block_private_different_10_subnet() {
1042 let result = validate_safe_url("http://10.1.2.3:8545", &[], true);
1044 assert!(result.is_err());
1045 assert!(result.unwrap_err().contains("Private IP"));
1046
1047 let result = validate_safe_url("http://10.100.200.50:8545", &[], true);
1048 assert!(result.is_err());
1049 assert!(result.unwrap_err().contains("Private IP"));
1050 }
1051
1052 #[test]
1053 fn test_non_metadata_link_local_vs_metadata() {
1054 let result = validate_safe_url("http://169.254.169.254:8545", &[], false);
1056 assert!(result.is_err());
1057 assert!(result.unwrap_err().contains("metadata"));
1058
1059 let result = validate_safe_url("http://169.254.1.1:8545", &[], false);
1061 assert!(result.is_ok());
1062 }
1063
1064 #[test]
1065 fn test_secure_redirect_policy_created() {
1066 let _policy = create_secure_redirect_policy();
1068 }
1069}