use crate::error::CachekitError;
const ALLOWED_HOSTS: &[&str] = &["api.cachekit.io", "api.staging.cachekit.io"];
pub fn validate_cachekitio_url(
url_str: &str,
allow_custom_host: bool,
) -> Result<(), CachekitError> {
cachekitio_base_url(url_str, allow_custom_host).map(|_| ())
}
pub(crate) fn cachekitio_base_url(
url_str: &str,
allow_custom_host: bool,
) -> Result<String, CachekitError> {
let parsed = url::Url::parse(url_str)
.map_err(|_| CachekitError::Config("CachekitIO API URL is malformed".to_string()))?;
if parsed.scheme() != "https" {
return Err(CachekitError::Config(
"CachekitIO API URL must use HTTPS".to_string(),
));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(CachekitError::Config(
"CachekitIO API URL must not carry credentials".to_string(),
));
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err(CachekitError::Config(
"CachekitIO API URL must not carry a query or a fragment".to_string(),
));
}
match parsed.host() {
Some(url::Host::Ipv4(v4)) if is_private_ip(std::net::IpAddr::V4(v4)) => {
return Err(CachekitError::Config(
"CachekitIO API URL must not point to a private IP address".to_string(),
));
}
Some(url::Host::Ipv6(v6)) if is_private_ip(std::net::IpAddr::V6(v6)) => {
return Err(CachekitError::Config(
"CachekitIO API URL must not point to a private IP address".to_string(),
));
}
_ => {}
}
if let Some(host) = parsed.host_str() {
let host = host.trim_start_matches('[').trim_end_matches(']');
if !allow_custom_host && !ALLOWED_HOSTS.contains(&host) {
return Err(CachekitError::Config(
"API URL hostname not permitted. See documentation.".to_string(),
));
}
}
Ok(parsed.as_str().trim_end_matches('/').to_owned())
}
fn is_private_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.octets()[0] == 0
}
std::net::IpAddr::V6(v6) => {
if let Some(v4) = embedded_ipv4(v6) {
return is_private_ip(std::net::IpAddr::V4(v4));
}
let s = v6.segments();
(s[0] & 0xff80) == 0xfe80
|| (s[0] & 0xfe00) == 0xfc00
|| (s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1)
}
}
}
fn embedded_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
let v4 = |hi: u16, lo: u16| {
let [a, b] = hi.to_be_bytes();
let [c, d] = lo.to_be_bytes();
std::net::Ipv4Addr::new(a, b, c, d)
};
match v6.segments() {
[0x64, 0xff9b, 0, 0, 0, 0, hi, lo] | [0, 0, 0, 0, 0xffff, 0, hi, lo] => Some(v4(hi, lo)),
[0x2002, hi, lo, ..] => Some(v4(hi, lo)),
_ => v6.to_ipv4(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_production_url() {
assert!(validate_cachekitio_url("https://api.cachekit.io", false).is_ok());
}
#[test]
fn accepts_staging_url() {
assert!(validate_cachekitio_url("https://api.staging.cachekit.io", false).is_ok());
}
#[test]
fn rejects_http() {
assert!(validate_cachekitio_url("http://api.cachekit.io", false).is_err());
}
#[test]
fn rejects_unknown_host() {
assert!(validate_cachekitio_url("https://evil.com", false).is_err());
}
#[test]
fn allows_custom_host() {
assert!(validate_cachekitio_url("https://my-proxy.internal.com", true).is_ok());
}
#[test]
fn blocks_private_ips_even_with_custom_host() {
assert!(validate_cachekitio_url("https://127.0.0.1", true).is_err());
assert!(validate_cachekitio_url("https://10.0.0.1", true).is_err());
assert!(validate_cachekitio_url("https://192.168.1.1", true).is_err());
assert!(validate_cachekitio_url("https://169.254.169.254", true).is_err());
}
#[test]
fn blocks_ipv4_mapped_ipv6() {
assert!(validate_cachekitio_url("https://[::ffff:127.0.0.1]", true).is_err());
assert!(validate_cachekitio_url("https://[::ffff:10.0.0.1]", true).is_err());
assert!(validate_cachekitio_url("https://[::ffff:169.254.169.254]", true).is_err());
assert!(validate_cachekitio_url("https://[::ffff:192.168.1.1]", true).is_err());
}
#[test]
fn blocks_ipv6_private_forms() {
for host in [
"[::]",
"[::1]",
"[fe80::1]",
"[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]",
"[64:ff9b::a9fe:a9fe]", "[2002:a00:1::]", "[2002:7f00:1::1]", "[2002:c0a8:101::]", "[64:ff9b:1::a00:1]", "[64:ff9b:1:ffff::1]",
"[::ffff:0:7f00:1]", "[::ffff:0:a00:1]", ] {
let url = format!("https://{host}");
assert!(
validate_cachekitio_url(&url, true).is_err(),
"{url} must be refused as private"
);
}
}
#[test]
fn allows_public_ipv6_forms() {
for host in [
"[2001:db8::1]",
"[64:ff9b::808:808]", "[2002:808:808::1]", "[::ffff:8.8.8.8]", "[::ffff:0:808:808]", ] {
let url = format!("https://{host}");
assert!(
validate_cachekitio_url(&url, true).is_ok(),
"{url} is public"
);
}
}
#[test]
fn rejects_query_and_fragment() {
for url in [
"https://api.cachekit.io/?",
"https://api.cachekit.io?",
"https://api.cachekit.io/?x=1",
"https://api.cachekit.io/#",
"https://api.cachekit.io#frag",
] {
let err = validate_cachekitio_url(url, false).unwrap_err();
assert!(
err.to_string().contains("query or a fragment"),
"{url}: {err}"
);
}
assert!(validate_cachekitio_url("https://proxy.example.com/base?", true).is_err());
assert!(validate_cachekitio_url("https://proxy.example.com/base/", true).is_ok());
}
#[test]
fn rejects_credentials() {
for url in [
"https://u:p@api.cachekit.io",
"https://u@api.cachekit.io",
"https://:p@api.cachekit.io",
] {
for allow_custom_host in [false, true] {
let err = validate_cachekitio_url(url, allow_custom_host).unwrap_err();
assert!(
err.to_string().contains("must not carry credentials"),
"{url}: {err}"
);
}
}
}
#[test]
fn base_url_is_the_parsed_serialization() {
for (url, base) in [
("https://api.cachekit.io", "https://api.cachekit.io"),
("https://api.cachekit.io/", "https://api.cachekit.io"),
("https://API.cachekit.io:443//", "https://api.cachekit.io"),
(
"https://api.cachekit.io\\@evil.example",
"https://api.cachekit.io/@evil.example",
),
(
"https://api.cachekit.io\\evil",
"https://api.cachekit.io/evil",
),
] {
assert_eq!(cachekitio_base_url(url, false).unwrap(), base, "{url}");
}
assert_eq!(
cachekitio_base_url("https://proxy.example.com/base/", true).unwrap(),
"https://proxy.example.com/base"
);
}
#[test]
fn generic_error_message() {
let err = validate_cachekitio_url("https://evil.com", false).unwrap_err();
let msg = err.to_string();
assert!(
!msg.contains("api.cachekit.io"),
"Should not enumerate allowlist"
);
assert!(
!msg.contains("allow_custom_host"),
"Should not reveal bypass flag"
);
}
}