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, carries no credentials,
6/// query or fragment, is not a private IP (SSRF protection), and matches the
7/// allow-list unless `allow_custom_host` is set.
8pub fn validate_cachekitio_url(
9    url_str: &str,
10    allow_custom_host: bool,
11) -> Result<(), CachekitError> {
12    cachekitio_base_url(url_str, allow_custom_host).map(|_| ())
13}
14
15/// [`validate_cachekitio_url`], then the URL as the parser serialized it,
16/// trailing slashes trimmed: the base every request path is appended to.
17/// Requests go to that serialization, never the raw input, so a client whose
18/// URL parser differs from this one still reads the host that was checked.
19pub(crate) fn cachekitio_base_url(
20    url_str: &str,
21    allow_custom_host: bool,
22) -> Result<String, CachekitError> {
23    let parsed = url::Url::parse(url_str)
24        .map_err(|_| CachekitError::Config("CachekitIO API URL is malformed".to_string()))?;
25
26    if parsed.scheme() != "https" {
27        return Err(CachekitError::Config(
28            "CachekitIO API URL must use HTTPS".to_string(),
29        ));
30    }
31
32    if !parsed.username().is_empty() || parsed.password().is_some() {
33        return Err(CachekitError::Config(
34            "CachekitIO API URL must not carry credentials".to_string(),
35        ));
36    }
37
38    // Request paths are appended to the base URL, so a query or fragment in
39    // it would swallow every one of them.
40    if parsed.query().is_some() || parsed.fragment().is_some() {
41        return Err(CachekitError::Config(
42            "CachekitIO API URL must not carry a query or a fragment".to_string(),
43        ));
44    }
45
46    // Check for private IPs using the parsed Host enum (handles IPv6 brackets correctly).
47    match parsed.host() {
48        Some(url::Host::Ipv4(v4)) if is_private_ip(std::net::IpAddr::V4(v4)) => {
49            return Err(CachekitError::Config(
50                "CachekitIO API URL must not point to a private IP address".to_string(),
51            ));
52        }
53        Some(url::Host::Ipv6(v6)) if is_private_ip(std::net::IpAddr::V6(v6)) => {
54            return Err(CachekitError::Config(
55                "CachekitIO API URL must not point to a private IP address".to_string(),
56            ));
57        }
58        _ => {}
59    }
60
61    if let Some(host) = parsed.host_str() {
62        // Strip brackets from IPv6 host_str for allowlist matching.
63        let host = host.trim_start_matches('[').trim_end_matches(']');
64        if !allow_custom_host && !ALLOWED_HOSTS.contains(&host) {
65            return Err(CachekitError::Config(
66                "API URL hostname not permitted. See documentation.".to_string(),
67            ));
68        }
69    }
70
71    Ok(parsed.as_str().trim_end_matches('/').to_owned())
72}
73
74fn is_private_ip(ip: std::net::IpAddr) -> bool {
75    match ip {
76        std::net::IpAddr::V4(v4) => {
77            v4.is_loopback()
78                || v4.is_private()
79                || v4.is_link_local()
80                || v4.is_unspecified()
81                || v4.octets()[0] == 0
82        }
83        std::net::IpAddr::V6(v6) => {
84            if let Some(v4) = embedded_ipv4(v6) {
85                return is_private_ip(std::net::IpAddr::V4(v4));
86            }
87            let s = v6.segments();
88            // fe80::/10 (link-local) and fec0::/10 (site-local), fc00::/7 (unique
89            // local), and local-use NAT64 64:ff9b:1::/48, which is never global
90            // and fixes no position for the IPv4 address it embeds.
91            (s[0] & 0xff80) == 0xfe80
92                || (s[0] & 0xfe00) == 0xfc00
93                || (s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1)
94        }
95    }
96}
97
98/// The IPv4 address an IPv6 form stands for, checked in its place:
99/// IPv4-mapped `::ffff:a.b.c.d` and IPv4-compatible `::a.b.c.d` (which covers
100/// `::` and `::1`), IPv4-translated `::ffff:0:a.b.c.d`, NAT64 `64:ff9b::/96`,
101/// and 6to4 `2002::/16`.
102fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
103    let v4 = |hi: u16, lo: u16| {
104        let [a, b] = hi.to_be_bytes();
105        let [c, d] = lo.to_be_bytes();
106        std::net::Ipv4Addr::new(a, b, c, d)
107    };
108    match v6.segments() {
109        [0x64, 0xff9b, 0, 0, 0, 0, hi, lo] | [0, 0, 0, 0, 0xffff, 0, hi, lo] => Some(v4(hi, lo)),
110        [0x2002, hi, lo, ..] => Some(v4(hi, lo)),
111        _ => v6.to_ipv4(),
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn accepts_production_url() {
121        assert!(validate_cachekitio_url("https://api.cachekit.io", false).is_ok());
122    }
123
124    #[test]
125    fn accepts_staging_url() {
126        assert!(validate_cachekitio_url("https://api.staging.cachekit.io", false).is_ok());
127    }
128
129    #[test]
130    fn rejects_http() {
131        assert!(validate_cachekitio_url("http://api.cachekit.io", false).is_err());
132    }
133
134    #[test]
135    fn rejects_unknown_host() {
136        assert!(validate_cachekitio_url("https://evil.com", false).is_err());
137    }
138
139    #[test]
140    fn allows_custom_host() {
141        assert!(validate_cachekitio_url("https://my-proxy.internal.com", true).is_ok());
142    }
143
144    #[test]
145    fn blocks_private_ips_even_with_custom_host() {
146        assert!(validate_cachekitio_url("https://127.0.0.1", true).is_err());
147        assert!(validate_cachekitio_url("https://10.0.0.1", true).is_err());
148        assert!(validate_cachekitio_url("https://192.168.1.1", true).is_err());
149        assert!(validate_cachekitio_url("https://169.254.169.254", true).is_err());
150    }
151
152    #[test]
153    fn blocks_ipv4_mapped_ipv6() {
154        // ::ffff:127.0.0.1 and ::ffff:169.254.169.254 must be blocked
155        assert!(validate_cachekitio_url("https://[::ffff:127.0.0.1]", true).is_err());
156        assert!(validate_cachekitio_url("https://[::ffff:10.0.0.1]", true).is_err());
157        assert!(validate_cachekitio_url("https://[::ffff:169.254.169.254]", true).is_err());
158        assert!(validate_cachekitio_url("https://[::ffff:192.168.1.1]", true).is_err());
159    }
160
161    #[test]
162    fn blocks_ipv6_private_forms() {
163        for host in [
164            "[::]",
165            "[::1]",
166            "[fe80::1]",
167            "[febf::1]",        // top of link-local fe80::/10
168            "[fec0::1]",        // site-local fec0::/10
169            "[feff::1]",        // top of site-local
170            "[fc00::1]",        // unique local fc00::/7
171            "[fdff::1]",        // top of unique local
172            "[::7f00:1]",       // IPv4-compatible 127.0.0.1
173            "[::127.0.0.1]",    // same, dotted
174            "[::a00:1]",        // IPv4-compatible 10.0.0.1
175            "[64:ff9b::a00:1]", // NAT64 10.0.0.1
176            "[64:ff9b::127.0.0.1]",
177            "[64:ff9b::a9fe:a9fe]", // NAT64 169.254.169.254
178            "[2002:a00:1::]",       // 6to4 10.0.0.1
179            "[2002:7f00:1::1]",     // 6to4 127.0.0.1
180            "[2002:c0a8:101::]",    // 6to4 192.168.1.1
181            "[64:ff9b:1::a00:1]",   // local-use NAT64 64:ff9b:1::/48, any address
182            "[64:ff9b:1:ffff::1]",
183            "[::ffff:0:7f00:1]", // IPv4-translated 127.0.0.1
184            "[::ffff:0:a00:1]",  // IPv4-translated 10.0.0.1
185        ] {
186            let url = format!("https://{host}");
187            assert!(
188                validate_cachekitio_url(&url, true).is_err(),
189                "{url} must be refused as private"
190            );
191        }
192    }
193
194    #[test]
195    fn allows_public_ipv6_forms() {
196        for host in [
197            "[2001:db8::1]",
198            "[64:ff9b::808:808]", // NAT64 of 8.8.8.8
199            "[2002:808:808::1]",  // 6to4 of 8.8.8.8
200            "[::ffff:8.8.8.8]",   // IPv4-mapped public
201            "[::ffff:0:808:808]", // IPv4-translated public
202        ] {
203            let url = format!("https://{host}");
204            assert!(
205                validate_cachekitio_url(&url, true).is_ok(),
206                "{url} is public"
207            );
208        }
209    }
210
211    #[test]
212    fn rejects_query_and_fragment() {
213        // Request paths are appended to the configured URL as a string, so a
214        // query or fragment there would swallow every one of them.
215        for url in [
216            "https://api.cachekit.io/?",
217            "https://api.cachekit.io?",
218            "https://api.cachekit.io/?x=1",
219            "https://api.cachekit.io/#",
220            "https://api.cachekit.io#frag",
221        ] {
222            let err = validate_cachekitio_url(url, false).unwrap_err();
223            assert!(
224                err.to_string().contains("query or a fragment"),
225                "{url}: {err}"
226            );
227        }
228        assert!(validate_cachekitio_url("https://proxy.example.com/base?", true).is_err());
229        assert!(validate_cachekitio_url("https://proxy.example.com/base/", true).is_ok());
230    }
231
232    #[test]
233    fn rejects_credentials() {
234        for url in [
235            "https://u:p@api.cachekit.io",
236            "https://u@api.cachekit.io",
237            "https://:p@api.cachekit.io",
238        ] {
239            for allow_custom_host in [false, true] {
240                let err = validate_cachekitio_url(url, allow_custom_host).unwrap_err();
241                assert!(
242                    err.to_string().contains("must not carry credentials"),
243                    "{url}: {err}"
244                );
245            }
246        }
247    }
248
249    #[test]
250    fn base_url_is_the_parsed_serialization() {
251        // Requests go to the serialization of the URL that was checked, never
252        // the raw input, so every URL parser downstream reads the same host.
253        for (url, base) in [
254            ("https://api.cachekit.io", "https://api.cachekit.io"),
255            ("https://api.cachekit.io/", "https://api.cachekit.io"),
256            ("https://API.cachekit.io:443//", "https://api.cachekit.io"),
257            (
258                "https://api.cachekit.io\\@evil.example",
259                "https://api.cachekit.io/@evil.example",
260            ),
261            (
262                "https://api.cachekit.io\\evil",
263                "https://api.cachekit.io/evil",
264            ),
265        ] {
266            assert_eq!(cachekitio_base_url(url, false).unwrap(), base, "{url}");
267        }
268        assert_eq!(
269            cachekitio_base_url("https://proxy.example.com/base/", true).unwrap(),
270            "https://proxy.example.com/base"
271        );
272    }
273
274    #[test]
275    fn generic_error_message() {
276        let err = validate_cachekitio_url("https://evil.com", false).unwrap_err();
277        let msg = err.to_string();
278        assert!(
279            !msg.contains("api.cachekit.io"),
280            "Should not enumerate allowlist"
281        );
282        assert!(
283            !msg.contains("allow_custom_host"),
284            "Should not reveal bypass flag"
285        );
286    }
287}