1use crate::error::CachekitError;
2
3const ALLOWED_HOSTS: &[&str] = &["api.cachekit.io", "api.staging.cachekit.io"];
4
5pub 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
15pub(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 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 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 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 (s[0] & 0xff80) == 0xfe80
92 || (s[0] & 0xfe00) == 0xfc00
93 || (s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1)
94 }
95 }
96}
97
98fn 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 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]", "[fec0::1]", "[feff::1]", "[fc00::1]", "[fdff::1]", "[::7f00:1]", "[::127.0.0.1]", "[::a00:1]", "[64:ff9b::a00:1]", "[64:ff9b::127.0.0.1]",
177 "[64:ff9b::a9fe:a9fe]", "[2002:a00:1::]", "[2002:7f00:1::1]", "[2002:c0a8:101::]", "[64:ff9b:1::a00:1]", "[64:ff9b:1:ffff::1]",
183 "[::ffff:0:7f00:1]", "[::ffff:0:a00:1]", ] {
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]", "[2002:808:808::1]", "[::ffff:8.8.8.8]", "[::ffff:0:808:808]", ] {
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 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 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}