Skip to main content

cachekit/
url_validator.rs

1use crate::error::CachekitError;
2
3const ALLOWED_HOSTS: &[&str] = &["api.cachekit.io", "api.staging.cachekit.io"];
4
5/// Validate that a CachekitIO API URL uses HTTPS, is not a private IP (SSRF
6/// protection), and matches the allow-list unless `allow_custom_host` is set.
7pub 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    // Check for private IPs using the parsed Host enum (handles IPv6 brackets correctly).
21    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        // Strip brackets from IPv6 host_str for allowlist matching.
37        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            // Detect IPv4-mapped (::ffff:x.x.x.x) addresses and check the embedded
59            // IPv4 address against the private range blocklist.
60            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                // fe80::/10 (link-local) and fc00::/7 (unique local)
66                || (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        // ::ffff:127.0.0.1 and ::ffff:169.254.169.254 must be blocked
112        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}