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
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum RedirectDecision {
304 Follow,
305 Stop,
306}
307
308pub fn evaluate_redirect_decision(
312 target_url: &url::Url,
313 previous_urls: &[url::Url],
314) -> RedirectDecision {
315 if previous_urls.len() > 1 {
317 warn!(
318 redirect_count = previous_urls.len(),
319 "Blocking redirect: too many redirects in chain"
320 );
321 return RedirectDecision::Stop;
322 }
323
324 let Some(original_url) = previous_urls.first() else {
325 warn!("Blocking redirect: no previous URL found");
326 return RedirectDecision::Stop;
327 };
328
329 let original_host = original_url.host_str().unwrap_or("");
331 let target_host = target_url.host_str().unwrap_or("");
332 if !original_host.eq_ignore_ascii_case(target_host) {
333 warn!(
334 original_host = original_host,
335 target_host = target_host,
336 "Blocking redirect: host mismatch"
337 );
338 return RedirectDecision::Stop;
339 }
340
341 let original_port = original_url.port_or_known_default();
343 let target_port = target_url.port_or_known_default();
344 if original_port != target_port {
345 warn!(
346 original_port = ?original_port,
347 target_port = ?target_port,
348 "Blocking redirect: port mismatch"
349 );
350 return RedirectDecision::Stop;
351 }
352
353 let original_scheme = original_url.scheme();
355 let target_scheme = target_url.scheme();
356 if original_scheme == "http" && target_scheme == "https" {
357 tracing::debug!(
358 original = %original_url,
359 target = %target_url,
360 "Allowing HTTP to HTTPS redirect"
361 );
362 RedirectDecision::Follow
363 } else {
364 warn!(
365 original_scheme = original_scheme,
366 target_scheme = target_scheme,
367 "Blocking redirect: only HTTP to HTTPS upgrades are allowed"
368 );
369 RedirectDecision::Stop
370 }
371}
372
373pub fn create_secure_redirect_policy() -> Policy {
383 Policy::custom(|attempt: Attempt| {
384 match evaluate_redirect_decision(attempt.url(), attempt.previous()) {
385 RedirectDecision::Follow => attempt.follow(),
386 RedirectDecision::Stop => attempt.stop(),
387 }
388 })
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 fn u(s: &str) -> url::Url {
396 url::Url::parse(s).unwrap()
397 }
398
399 #[test]
400 fn redirect_allows_http_to_https_same_host_explicit_port() {
401 let target = u("https://example.com:8545/rpc");
404 let prev = vec![u("http://example.com:8545/rpc")];
405 assert_eq!(
406 evaluate_redirect_decision(&target, &prev),
407 RedirectDecision::Follow
408 );
409 }
410
411 #[test]
412 fn redirect_blocks_cross_host() {
413 let target = u("https://other.com/rpc");
414 let prev = vec![u("https://example.com/rpc")];
415 assert_eq!(
416 evaluate_redirect_decision(&target, &prev),
417 RedirectDecision::Stop
418 );
419 }
420
421 #[test]
422 fn redirect_blocks_https_to_http_downgrade() {
423 let target = u("http://example.com/rpc");
424 let prev = vec![u("https://example.com/rpc")];
425 assert_eq!(
426 evaluate_redirect_decision(&target, &prev),
427 RedirectDecision::Stop
428 );
429 }
430
431 #[test]
432 fn redirect_blocks_chain_longer_than_one() {
433 let target = u("https://example.com/c");
434 let prev = vec![u("http://example.com/a"), u("https://example.com/b")];
435 assert_eq!(
436 evaluate_redirect_decision(&target, &prev),
437 RedirectDecision::Stop
438 );
439 }
440
441 #[test]
442 fn redirect_blocks_port_mismatch() {
443 let target = u("https://example.com:9999/rpc");
444 let prev = vec![u("http://example.com:8545/rpc")];
445 assert_eq!(
446 evaluate_redirect_decision(&target, &prev),
447 RedirectDecision::Stop
448 );
449 }
450
451 #[test]
452 fn test_private_ipv4_detection() {
453 assert!(is_private_ip_range(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
454 assert!(is_private_ip_range(&IpAddr::V4(Ipv4Addr::new(
455 172, 16, 0, 1
456 ))));
457 assert!(is_private_ip_range(&IpAddr::V4(Ipv4Addr::new(
458 192, 168, 1, 1
459 ))));
460 assert!(!is_private_ip_range(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
461 }
462
463 #[test]
464 fn test_loopback_detection() {
465 assert!(is_loopback(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
466 assert!(is_loopback(&IpAddr::V6(Ipv6Addr::new(
467 0, 0, 0, 0, 0, 0, 0, 1
468 ))));
469 assert!(!is_loopback(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
470 }
471
472 #[test]
473 fn test_link_local_detection() {
474 assert!(is_link_local(&IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1))));
475 assert!(is_link_local(&IpAddr::V6(Ipv6Addr::new(
476 0xfe80, 0, 0, 0, 0, 0, 0, 1
477 ))));
478 assert!(!is_link_local(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
479 }
480
481 #[test]
482 fn test_metadata_endpoint_detection() {
483 assert!(is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
484 169, 254, 169, 254
485 ))));
486 assert!(is_metadata_endpoint(&IpAddr::V6(Ipv6Addr::new(
487 0xfd00, 0xec2, 0, 0, 0, 0, 0, 0x254
488 ))));
489 assert!(!is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
490 8, 8, 8, 8
491 ))));
492 }
493
494 #[test]
495 fn test_unspecified_detection() {
496 assert!(is_unspecified(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))));
497 assert!(is_unspecified(&IpAddr::V6(Ipv6Addr::new(
498 0, 0, 0, 0, 0, 0, 0, 0
499 ))));
500 assert!(!is_unspecified(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
501 }
502
503 #[test]
504 fn test_validate_public_ip() {
505 let result = validate_safe_url("http://8.8.8.8:8545", &[], false);
506 assert!(result.is_ok());
507 }
508
509 #[test]
510 fn test_block_private_ip() {
511 let result = validate_safe_url("http://192.168.1.1:8545", &[], true);
512 assert!(result.is_err());
513 assert!(result.unwrap_err().contains("Private IP"));
514 }
515
516 #[test]
517 fn test_allow_private_ip_when_disabled() {
518 let result = validate_safe_url("http://192.168.1.1:8545", &[], false);
519 assert!(result.is_ok());
520 }
521
522 #[test]
523 fn test_block_loopback() {
524 let result = validate_safe_url("http://127.0.0.1:8545", &[], true);
525 assert!(result.is_err());
526 assert!(result.unwrap_err().contains("Loopback"));
527 }
528
529 #[test]
530 fn test_block_metadata_endpoint_always() {
531 let result = validate_safe_url("http://169.254.169.254/latest/meta-data", &[], false);
533 assert!(result.is_err());
534 assert!(result.unwrap_err().contains("metadata"));
535 }
536
537 #[test]
538 fn test_allow_list_enforced_when_provided() {
539 let result = validate_safe_url(
541 "https://eth-mainnet.g.alchemy.com/v2/demo",
542 &["eth-mainnet.g.alchemy.com".to_string()],
543 false,
544 );
545 assert!(result.is_ok());
546 }
547
548 #[test]
549 fn test_allow_list_case_insensitive() {
550 let result = validate_safe_url(
553 "https://eth-mainnet.g.alchemy.com/v2/demo",
554 &["ETH-MAINNET.G.ALCHEMY.COM".to_string()],
555 false,
556 );
557 assert!(result.is_ok());
558
559 let result = validate_safe_url(
561 "https://ETH-MAINNET.G.ALCHEMY.COM/v2/demo",
562 &["eth-mainnet.g.alchemy.com".to_string()],
563 false,
564 );
565 assert!(result.is_ok());
566
567 let result = validate_safe_url(
569 "https://Eth-Mainnet.G.Alchemy.COM/v2/demo",
570 &["ETH-mainnet.g.ALCHEMY.com".to_string()],
571 false,
572 );
573 assert!(result.is_ok());
574 }
575
576 #[test]
577 fn test_allow_list_rejects_unlisted_host() {
578 let result = validate_safe_url(
580 "https://mainnet.infura.io/v3/demo",
581 &["eth-mainnet.g.alchemy.com".to_string()],
582 false,
583 );
584 assert!(result.is_err());
585 assert!(result
586 .unwrap_err()
587 .contains("not in the allowed hosts list"));
588 }
589
590 #[test]
591 fn test_empty_allow_list_permits_all() {
592 let result = validate_safe_url("https://mainnet.infura.io/v3/demo", &[], false);
594 assert!(result.is_ok());
595 }
596
597 #[test]
598 fn test_invalid_url() {
599 let result = validate_safe_url("not-a-url", &[], false);
600 assert!(result.is_err());
601 assert!(result.unwrap_err().contains("Invalid URL format"));
602 }
603
604 #[test]
605 fn test_url_without_host() {
606 let result = validate_safe_url("file:///path/to/file", &[], false);
608 assert!(result.is_err());
609 assert!(result.unwrap_err().contains("Invalid URL scheme"));
610 }
611
612 #[test]
613 fn test_unspecified_always_blocked() {
614 let result = validate_safe_url("http://0.0.0.0:8545", &[], false);
615 assert!(result.is_err());
616 assert!(result.unwrap_err().contains("Unspecified"));
617 }
618
619 #[test]
620 fn test_sanitize_url() {
621 assert_eq!(
622 sanitize_url("https://example.com/path?key=secret#fragment"),
623 "https://example.com/path"
624 );
625 assert_eq!(sanitize_url("invalid"), "[invalid URL]");
626 }
627
628 #[test]
629 fn test_sanitize_url_for_error_redacts_path() {
630 assert_eq!(
632 sanitize_url_for_error("https://mainnet.infura.io/v3/SECRET_API_KEY"),
633 "https://mainnet.infura.io/[path redacted]"
634 );
635 assert_eq!(
636 sanitize_url_for_error("https://eth-mainnet.g.alchemy.com/v2/MY_API_KEY"),
637 "https://eth-mainnet.g.alchemy.com/[path redacted]"
638 );
639 }
640
641 #[test]
642 fn test_sanitize_url_for_error_redacts_query() {
643 assert_eq!(
645 sanitize_url_for_error("https://api.example.com/rpc?apikey=secret"),
646 "https://api.example.com/[path redacted]"
647 );
648 }
649
650 #[test]
651 fn test_sanitize_url_for_error_preserves_port() {
652 assert_eq!(
654 sanitize_url_for_error("https://rpc.example.com:8545/path"),
655 "https://rpc.example.com:8545/[path redacted]"
656 );
657 assert_eq!(
658 sanitize_url_for_error("http://localhost:8545/secret"),
659 "http://localhost:8545/[path redacted]"
660 );
661 }
662
663 #[test]
664 fn test_sanitize_url_for_error_handles_invalid() {
665 assert_eq!(sanitize_url_for_error("not-a-url"), "[invalid URL]");
667 assert_eq!(sanitize_url_for_error(""), "[invalid URL]");
668 }
669
670 #[test]
671 fn test_sanitize_url_for_error_preserves_scheme() {
672 assert_eq!(
673 sanitize_url_for_error("http://example.com/path"),
674 "http://example.com/[path redacted]"
675 );
676 assert_eq!(
677 sanitize_url_for_error("https://example.com/path"),
678 "https://example.com/[path redacted]"
679 );
680 }
681
682 #[test]
685 fn test_block_invalid_scheme() {
686 let result = validate_safe_url("ftp://example.com:8545", &[], false);
688 assert!(result.is_err());
689 assert!(result.unwrap_err().contains("Invalid URL scheme"));
690
691 let result = validate_safe_url("gopher://example.com:8545", &[], false);
693 assert!(result.is_err());
694 assert!(result.unwrap_err().contains("Invalid URL scheme"));
695 }
696
697 #[test]
698 fn test_allow_valid_schemes() {
699 let result = validate_safe_url("http://example.com:8545", &[], false);
701 assert!(result.is_ok());
702
703 let result = validate_safe_url("https://example.com:8545", &[], false);
705 assert!(result.is_ok());
706 }
707
708 #[test]
709 fn test_block_localhost_hostname() {
710 let result = validate_safe_url("http://localhost:8545", &[], true);
712 assert!(result.is_err());
713 assert!(result.unwrap_err().contains("not allowed"));
714 }
715
716 #[test]
717 fn test_allow_localhost_when_disabled() {
718 let result = validate_safe_url("http://localhost:8545", &[], false);
720 assert!(result.is_ok());
721 }
722
723 #[test]
724 fn test_block_localhost_subdomain() {
725 let result = validate_safe_url("http://subdomain.localhost:8545", &[], true);
727 assert!(result.is_err());
728 assert!(result.unwrap_err().contains("not allowed"));
729 }
730
731 #[test]
732 fn test_block_metadata_google_internal_always() {
733 let result = validate_safe_url(
736 "http://metadata.google.internal/computeMetadata/v1",
737 &[],
738 true,
739 );
740 assert!(result.is_err());
741 assert!(result.unwrap_err().contains("metadata"));
742
743 let result = validate_safe_url(
745 "http://metadata.google.internal/computeMetadata/v1",
746 &[],
747 false,
748 );
749 assert!(result.is_err());
750 assert!(result.unwrap_err().contains("metadata"));
751 }
752
753 #[test]
754 fn test_block_internal_domain() {
755 let result = validate_safe_url("http://some-service.internal:8545", &[], true);
757 assert!(result.is_err());
758 assert!(result.unwrap_err().contains("not allowed"));
759 }
760
761 #[test]
762 fn test_allow_list_with_ip_address() {
763 let result = validate_safe_url("http://8.8.8.8:8545", &["8.8.8.8".to_string()], false);
765 assert!(result.is_ok());
766 }
767
768 #[test]
769 fn test_dangerous_hostname_detection() {
770 assert!(is_dangerous_hostname("localhost"));
772 assert!(is_dangerous_hostname("LOCALHOST")); assert!(is_dangerous_hostname("sub.localhost"));
774 assert!(is_dangerous_hostname("service.internal"));
776 assert!(is_dangerous_hostname("some-app.internal"));
777 assert!(!is_dangerous_hostname("example.com"));
779 assert!(!is_dangerous_hostname("eth-mainnet.g.alchemy.com"));
780 assert!(is_dangerous_hostname("metadata.google.internal"));
783 }
784
785 #[test]
786 fn test_metadata_hostname_detection() {
787 assert!(is_metadata_hostname("metadata.google.internal"));
789 assert!(is_metadata_hostname("METADATA.GOOGLE.INTERNAL")); assert!(!is_metadata_hostname("localhost"));
792 assert!(!is_metadata_hostname("example.com"));
793 assert!(!is_metadata_hostname("service.internal"));
794 }
795
796 #[test]
797 fn test_ipv4_mapped_detection() {
798 let mapped_loopback: Ipv6Addr = "::ffff:127.0.0.1".parse().unwrap();
800 assert!(mapped_loopback.to_ipv4_mapped().is_some());
801 assert!(mapped_loopback.to_ipv4_mapped().unwrap().is_loopback());
802
803 let mapped_private: Ipv6Addr = "::ffff:192.168.1.1".parse().unwrap();
804 assert!(mapped_private.to_ipv4_mapped().is_some());
805 assert!(mapped_private.to_ipv4_mapped().unwrap().is_private());
806 }
807
808 #[test]
811 fn test_private_ipv6_detection() {
812 assert!(is_private_ip_range(&IpAddr::V6(Ipv6Addr::new(
814 0xfc00, 0, 0, 0, 0, 0, 0, 1
815 ))));
816 assert!(is_private_ip_range(&IpAddr::V6(Ipv6Addr::new(
817 0xfd00, 0, 0, 0, 0, 0, 0, 1
818 ))));
819 assert!(is_private_ip_range(&IpAddr::V6(Ipv6Addr::new(
820 0xfdff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff
821 ))));
822 assert!(!is_private_ip_range(&IpAddr::V6(Ipv6Addr::new(
824 0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888
825 ))));
826 }
827
828 #[test]
829 fn test_block_link_local_ipv4() {
830 let result = validate_safe_url("http://169.254.1.1:8545", &[], true);
833 assert!(result.is_err());
834 assert!(result.unwrap_err().contains("Link-local"));
835 }
836
837 #[test]
838 fn test_allow_link_local_ipv4_when_disabled() {
839 let result = validate_safe_url("http://169.254.1.1:8545", &[], false);
841 assert!(result.is_ok());
842 }
843
844 #[test]
845 fn test_loopback_range_boundary() {
846 let result = validate_safe_url("http://127.0.0.1:8545", &[], true);
848 assert!(result.is_err());
849 assert!(result.unwrap_err().contains("Loopback"));
850
851 let result = validate_safe_url("http://127.255.255.1:8545", &[], true);
852 assert!(result.is_err());
853 assert!(result.unwrap_err().contains("Loopback"));
854
855 let result = validate_safe_url("http://127.0.0.255:8545", &[], true);
856 assert!(result.is_err());
857 assert!(result.unwrap_err().contains("Loopback"));
858 }
859
860 #[test]
861 fn test_private_ip_range_boundaries() {
862 let result = validate_safe_url("http://10.0.0.0:8545", &[], true);
864 assert!(result.is_err());
865 assert!(result.unwrap_err().contains("Private IP"));
866
867 let result = validate_safe_url("http://10.255.255.255:8545", &[], true);
868 assert!(result.is_err());
869 assert!(result.unwrap_err().contains("Private IP"));
870
871 let result = validate_safe_url("http://172.16.0.0:8545", &[], true);
873 assert!(result.is_err());
874 assert!(result.unwrap_err().contains("Private IP"));
875
876 let result = validate_safe_url("http://172.31.255.255:8545", &[], true);
877 assert!(result.is_err());
878 assert!(result.unwrap_err().contains("Private IP"));
879
880 let result = validate_safe_url("http://172.15.255.255:8545", &[], true);
882 assert!(result.is_ok());
883
884 let result = validate_safe_url("http://172.32.0.1:8545", &[], true);
886 assert!(result.is_ok());
887
888 let result = validate_safe_url("http://192.168.0.0:8545", &[], true);
890 assert!(result.is_err());
891 assert!(result.unwrap_err().contains("Private IP"));
892
893 let result = validate_safe_url("http://192.168.255.255:8545", &[], true);
894 assert!(result.is_err());
895 assert!(result.unwrap_err().contains("Private IP"));
896 }
897
898 #[test]
899 fn test_multiple_allowed_hosts() {
900 let allowed = vec![
902 "eth-mainnet.g.alchemy.com".to_string(),
903 "mainnet.infura.io".to_string(),
904 "rpc.ankr.com".to_string(),
905 ];
906
907 let result = validate_safe_url("https://eth-mainnet.g.alchemy.com/v2/key", &allowed, false);
908 assert!(result.is_ok());
909
910 let result = validate_safe_url("https://mainnet.infura.io/v3/key", &allowed, false);
911 assert!(result.is_ok());
912
913 let result = validate_safe_url("https://rpc.ankr.com/eth", &allowed, false);
914 assert!(result.is_ok());
915
916 let result = validate_safe_url("https://other-provider.com/rpc", &allowed, false);
918 assert!(result.is_err());
919 assert!(result
920 .unwrap_err()
921 .contains("not in the allowed hosts list"));
922 }
923
924 #[test]
925 fn test_url_with_credentials() {
926 let result = validate_safe_url("http://user:pass@8.8.8.8:8545", &[], false);
928 assert!(result.is_ok());
929
930 let result = validate_safe_url("http://user:pass@192.168.1.1:8545", &[], true);
932 assert!(result.is_err());
933 assert!(result.unwrap_err().contains("Private IP"));
934 }
935
936 #[test]
937 fn test_url_without_port() {
938 let result = validate_safe_url("http://8.8.8.8", &[], false);
940 assert!(result.is_ok());
941
942 let result = validate_safe_url("https://example.com", &[], false);
943 assert!(result.is_ok());
944
945 let result = validate_safe_url("http://192.168.1.1", &[], true);
946 assert!(result.is_err());
947 assert!(result.unwrap_err().contains("Private IP"));
948 }
949
950 #[test]
951 fn test_url_with_path_and_query() {
952 let result = validate_safe_url("https://example.com/path/to/rpc?key=value", &[], false);
954 assert!(result.is_ok());
955
956 let result = validate_safe_url(
957 "https://192.168.1.1/path/to/rpc?key=value#fragment",
958 &[],
959 true,
960 );
961 assert!(result.is_err());
962 assert!(result.unwrap_err().contains("Private IP"));
963 }
964
965 #[test]
966 fn test_sanitize_url_no_path() {
967 assert_eq!(sanitize_url("https://example.com"), "https://example.com/");
969
970 assert_eq!(
971 sanitize_url("https://example.com:8545"),
972 "https://example.com:8545/"
973 );
974 }
975
976 #[test]
977 fn test_sanitize_url_preserves_path() {
978 assert_eq!(
980 sanitize_url("https://example.com/api/v1/rpc"),
981 "https://example.com/api/v1/rpc"
982 );
983 }
984
985 #[test]
986 fn test_sanitize_url_for_error_no_path() {
987 assert_eq!(
989 sanitize_url_for_error("https://example.com"),
990 "https://example.com/[path redacted]"
991 );
992 }
993
994 #[test]
995 fn test_sanitize_url_for_error_with_credentials() {
996 let result = sanitize_url_for_error("https://user:pass@example.com/path");
998 assert!(result.contains("example.com"));
999 assert!(result.contains("[path redacted]"));
1000 }
1001
1002 #[test]
1003 fn test_is_link_local_ipv6_variations() {
1004 assert!(is_link_local(&IpAddr::V6(Ipv6Addr::new(
1006 0xfe80, 0, 0, 0, 0, 0, 0, 1
1007 ))));
1008 assert!(is_link_local(&IpAddr::V6(Ipv6Addr::new(
1009 0xfe80, 0, 0, 0, 0x1234, 0x5678, 0x9abc, 0xdef0
1010 ))));
1011 assert!(is_link_local(&IpAddr::V6(Ipv6Addr::new(
1012 0xfebf, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff
1013 ))));
1014 assert!(!is_link_local(&IpAddr::V6(Ipv6Addr::new(
1016 0xfec0, 0, 0, 0, 0, 0, 0, 1
1017 ))));
1018 }
1019
1020 #[test]
1021 fn test_validate_ip_address_directly() {
1022 let mapped_loopback = IpAddr::V6("::ffff:127.0.0.1".parse().unwrap());
1027 let result = validate_ip_address(&mapped_loopback, true, "http://test");
1028 assert!(result.is_err());
1029 assert!(result.unwrap_err().contains("Loopback"));
1030
1031 let mapped_private = IpAddr::V6("::ffff:192.168.1.1".parse().unwrap());
1033 let result = validate_ip_address(&mapped_private, true, "http://test");
1034 assert!(result.is_err());
1035 assert!(result.unwrap_err().contains("Private IP"));
1036
1037 let mapped_metadata = IpAddr::V6("::ffff:169.254.169.254".parse().unwrap());
1039 let result = validate_ip_address(&mapped_metadata, false, "http://test");
1040 assert!(result.is_err());
1041 assert!(result.unwrap_err().contains("metadata"));
1042
1043 let mapped_public = IpAddr::V6("::ffff:8.8.8.8".parse().unwrap());
1045 let result = validate_ip_address(&mapped_public, true, "http://test");
1046 assert!(result.is_ok());
1047 }
1048
1049 #[test]
1050 fn test_allow_internal_domain_when_disabled() {
1051 let result = validate_safe_url("http://some-service.internal:8545", &[], false);
1054 assert!(result.is_ok());
1055 }
1056
1057 #[test]
1058 fn test_localhost_uppercase() {
1059 let result = validate_safe_url("http://LOCALHOST:8545", &[], true);
1061 assert!(result.is_err());
1062 assert!(result.unwrap_err().contains("not allowed"));
1063
1064 let result = validate_safe_url("http://LocalHost:8545", &[], true);
1065 assert!(result.is_err());
1066 assert!(result.unwrap_err().contains("not allowed"));
1067 }
1068
1069 #[test]
1070 fn test_data_uri_scheme_rejected() {
1071 let result = validate_safe_url("data:text/html,<h1>test</h1>", &[], false);
1073 assert!(result.is_err());
1074 }
1076
1077 #[test]
1078 fn test_javascript_uri_scheme_rejected() {
1079 let result = validate_safe_url("javascript:alert(1)", &[], false);
1081 assert!(result.is_err());
1082 }
1083
1084 #[test]
1089 fn test_metadata_endpoint_ipv4_variations() {
1090 assert!(is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
1092 169, 254, 169, 254
1093 ))));
1094 assert!(!is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
1096 169, 254, 169, 253
1097 ))));
1098 assert!(!is_metadata_endpoint(&IpAddr::V4(Ipv4Addr::new(
1099 169, 254, 1, 1
1100 ))));
1101 }
1102
1103 #[test]
1104 fn test_block_private_different_10_subnet() {
1105 let result = validate_safe_url("http://10.1.2.3:8545", &[], true);
1107 assert!(result.is_err());
1108 assert!(result.unwrap_err().contains("Private IP"));
1109
1110 let result = validate_safe_url("http://10.100.200.50:8545", &[], true);
1111 assert!(result.is_err());
1112 assert!(result.unwrap_err().contains("Private IP"));
1113 }
1114
1115 #[test]
1116 fn test_non_metadata_link_local_vs_metadata() {
1117 let result = validate_safe_url("http://169.254.169.254:8545", &[], false);
1119 assert!(result.is_err());
1120 assert!(result.unwrap_err().contains("metadata"));
1121
1122 let result = validate_safe_url("http://169.254.1.1:8545", &[], false);
1124 assert!(result.is_ok());
1125 }
1126
1127 #[test]
1128 fn test_secure_redirect_policy_created() {
1129 let _policy = create_secure_redirect_policy();
1131 }
1132}