use http::{HeaderMap, Uri};
#[derive(Debug, Clone, Default)]
pub struct ScopedHeaders {
headers: HeaderMap,
allowed_host_suffixes: Vec<String>,
}
impl ScopedHeaders {
pub fn new(headers: HeaderMap, allowed_host_suffixes: Vec<String>) -> Self {
let allowed_host_suffixes = allowed_host_suffixes
.into_iter()
.map(|s| s.trim_end_matches('.').to_ascii_lowercase())
.collect();
Self {
headers,
allowed_host_suffixes,
}
}
pub fn allows_host(&self, host: &str) -> bool {
let host = host.trim_end_matches('.').to_ascii_lowercase();
self.allowed_host_suffixes.iter().any(|suffix| {
host == *suffix
|| (host.len() > suffix.len()
&& host.ends_with(suffix)
&& host.as_bytes()[host.len() - suffix.len() - 1] == b'.')
})
}
pub fn apply(&self, uri: &Uri, headers: &mut HeaderMap) {
let Some(host) = uri.host() else {
return;
};
if !self.allows_host(host) {
return;
}
for (name, value) in self.headers.iter() {
if !headers.contains_key(name) {
headers.append(name, value.clone());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::{HeaderName, HeaderValue};
const TEST_HEADER: &str = "x-test-token";
const SUFFIX: &str = "speed.cloudflare.com";
fn scoped(suffixes: &[&str]) -> ScopedHeaders {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static(TEST_HEADER),
HeaderValue::from_static("secret"),
);
ScopedHeaders::new(headers, suffixes.iter().map(|s| s.to_string()).collect())
}
#[test]
fn allows_exact_match() {
assert!(scoped(&[SUFFIX]).allows_host("speed.cloudflare.com"));
}
#[test]
fn allows_subdomain_match() {
assert!(scoped(&[SUFFIX]).allows_host("staging.speed.cloudflare.com"));
}
#[test]
fn allows_deep_subdomain_match() {
assert!(scoped(&[SUFFIX]).allows_host("a.b.speed.cloudflare.com"));
}
#[test]
fn allows_any_of_multiple_suffixes() {
let scoped = scoped(&["example.com", SUFFIX]);
assert!(scoped.allows_host("speed.cloudflare.com"));
assert!(scoped.allows_host("www.example.com"));
}
#[test]
fn allows_case_insensitive_host() {
assert!(scoped(&[SUFFIX]).allows_host("SPEED.Cloudflare.COM"));
}
#[test]
fn allows_trailing_dot_fqdn() {
assert!(scoped(&[SUFFIX]).allows_host("speed.cloudflare.com."));
}
#[test]
fn allows_trailing_dot_subdomain() {
assert!(scoped(&[SUFFIX]).allows_host("staging.speed.cloudflare.com."));
}
#[test]
fn allows_suffix_normalized_case_and_trailing_dot() {
assert!(scoped(&["SPEED.Cloudflare.com."]).allows_host("speed.cloudflare.com"));
}
#[test]
fn rejects_look_alike_prefix() {
assert!(!scoped(&[SUFFIX]).allows_host("notspeed.cloudflare.com"));
}
#[test]
fn rejects_look_alike_suffix() {
assert!(!scoped(&[SUFFIX]).allows_host("speed.cloudflare.com.evil.com"));
}
#[test]
fn rejects_empty_host() {
assert!(!scoped(&[SUFFIX]).allows_host(""));
}
#[test]
fn rejects_unrelated_host() {
assert!(!scoped(&[SUFFIX]).allows_host("example.com"));
}
#[test]
fn rejects_host_shorter_than_suffix() {
assert!(!scoped(&[SUFFIX]).allows_host("cloudflare.com"));
}
#[test]
fn rejects_partial_trailing_label() {
assert!(!scoped(&[SUFFIX]).allows_host("xspeed.cloudflare.com"));
}
#[test]
fn apply_attaches_header_to_matching_host() {
let uri: Uri = "https://staging.speed.cloudflare.com/config"
.parse()
.unwrap();
let mut headers = HeaderMap::new();
scoped(&[SUFFIX]).apply(&uri, &mut headers);
assert_eq!(
headers.get(TEST_HEADER).map(|v| v.as_bytes()),
Some(b"secret".as_slice())
);
}
#[test]
fn apply_does_not_attach_header_to_non_matching_host() {
let uri: Uri = "https://evil.com/config".parse().unwrap();
let mut headers = HeaderMap::new();
scoped(&[SUFFIX]).apply(&uri, &mut headers);
assert!(!headers.contains_key(TEST_HEADER));
}
#[test]
fn apply_is_noop_when_uri_has_no_host() {
let uri: Uri = "/relative/path".parse().unwrap();
assert!(uri.host().is_none());
let mut headers = HeaderMap::new();
scoped(&[SUFFIX]).apply(&uri, &mut headers);
assert!(headers.is_empty());
}
#[test]
fn apply_does_not_overwrite_existing_header() {
let uri: Uri = "https://speed.cloudflare.com/config".parse().unwrap();
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static(TEST_HEADER),
HeaderValue::from_static("explicit"),
);
scoped(&[SUFFIX]).apply(&uri, &mut headers);
assert_eq!(
headers.get(TEST_HEADER).map(|v| v.as_bytes()),
Some(b"explicit".as_slice())
);
}
}