cachekit/
url_validator.rs1use crate::error::CachekitError;
2
3const ALLOWED_HOSTS: &[&str] = &["api.cachekit.io", "api.staging.cachekit.io"];
4
5pub fn validate_cachekitio_url(
8 url_str: &str,
9 allow_custom_host: bool,
10) -> Result<(), CachekitError> {
11 let parsed = url::Url::parse(url_str)
12 .map_err(|_| CachekitError::Config("CachekitIO API URL is malformed".to_string()))?;
13
14 if parsed.scheme() != "https" {
15 return Err(CachekitError::Config(
16 "CachekitIO API URL must use HTTPS".to_string(),
17 ));
18 }
19
20 match parsed.host() {
22 Some(url::Host::Ipv4(v4)) if is_private_ip(std::net::IpAddr::V4(v4)) => {
23 return Err(CachekitError::Config(
24 "CachekitIO API URL must not point to a private IP address".to_string(),
25 ));
26 }
27 Some(url::Host::Ipv6(v6)) if is_private_ip(std::net::IpAddr::V6(v6)) => {
28 return Err(CachekitError::Config(
29 "CachekitIO API URL must not point to a private IP address".to_string(),
30 ));
31 }
32 _ => {}
33 }
34
35 if let Some(host) = parsed.host_str() {
36 let host = host.trim_start_matches('[').trim_end_matches(']');
38 if !allow_custom_host && !ALLOWED_HOSTS.contains(&host) {
39 return Err(CachekitError::Config(
40 "API URL hostname not permitted. See documentation.".to_string(),
41 ));
42 }
43 }
44
45 Ok(())
46}
47
48fn is_private_ip(ip: std::net::IpAddr) -> bool {
49 match ip {
50 std::net::IpAddr::V4(v4) => {
51 v4.is_loopback()
52 || v4.is_private()
53 || v4.is_link_local()
54 || v4.is_unspecified()
55 || v4.octets()[0] == 0
56 }
57 std::net::IpAddr::V6(v6) => {
58 if let Some(v4) = v6.to_ipv4_mapped() {
61 return is_private_ip(std::net::IpAddr::V4(v4));
62 }
63 v6.is_loopback()
64 || v6.is_unspecified()
65 || (v6.segments()[0] & 0xffc0) == 0xfe80
67 || (v6.segments()[0] & 0xfe00) == 0xfc00
68 }
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 #[test]
77 fn accepts_production_url() {
78 assert!(validate_cachekitio_url("https://api.cachekit.io", false).is_ok());
79 }
80
81 #[test]
82 fn accepts_staging_url() {
83 assert!(validate_cachekitio_url("https://api.staging.cachekit.io", false).is_ok());
84 }
85
86 #[test]
87 fn rejects_http() {
88 assert!(validate_cachekitio_url("http://api.cachekit.io", false).is_err());
89 }
90
91 #[test]
92 fn rejects_unknown_host() {
93 assert!(validate_cachekitio_url("https://evil.com", false).is_err());
94 }
95
96 #[test]
97 fn allows_custom_host() {
98 assert!(validate_cachekitio_url("https://my-proxy.internal.com", true).is_ok());
99 }
100
101 #[test]
102 fn blocks_private_ips_even_with_custom_host() {
103 assert!(validate_cachekitio_url("https://127.0.0.1", true).is_err());
104 assert!(validate_cachekitio_url("https://10.0.0.1", true).is_err());
105 assert!(validate_cachekitio_url("https://192.168.1.1", true).is_err());
106 assert!(validate_cachekitio_url("https://169.254.169.254", true).is_err());
107 }
108
109 #[test]
110 fn blocks_ipv4_mapped_ipv6() {
111 assert!(validate_cachekitio_url("https://[::ffff:127.0.0.1]", true).is_err());
113 assert!(validate_cachekitio_url("https://[::ffff:10.0.0.1]", true).is_err());
114 assert!(validate_cachekitio_url("https://[::ffff:169.254.169.254]", true).is_err());
115 assert!(validate_cachekitio_url("https://[::ffff:192.168.1.1]", true).is_err());
116 }
117
118 #[test]
119 fn generic_error_message() {
120 let err = validate_cachekitio_url("https://evil.com", false).unwrap_err();
121 let msg = err.to_string();
122 assert!(
123 !msg.contains("api.cachekit.io"),
124 "Should not enumerate allowlist"
125 );
126 assert!(
127 !msg.contains("allow_custom_host"),
128 "Should not reveal bypass flag"
129 );
130 }
131}