use url::Url;
use super::client::NetError;
#[derive(Clone, PartialEq, Eq)]
pub struct OriginHeaders {
pub host: String,
pub headers: Vec<(String, String)>,
}
impl std::fmt::Debug for OriginHeaders {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let names: Vec<&str> = self.headers.iter().map(|(name, _)| name.as_str()).collect();
f.debug_struct("OriginHeaders")
.field("host", &self.host)
.field("names", &names)
.finish_non_exhaustive()
}
}
impl OriginHeaders {
pub fn headers_for(&self, url: &Url) -> &[(String, String)] {
match url.host_str() {
Some(host) if host.eq_ignore_ascii_case(&self.host) => &self.headers,
_ => &[],
}
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ScopedHeaders {
pub(crate) origin: Option<OriginHeaders>,
pub(crate) subdomains: Option<OriginHeaders>,
}
impl ScopedHeaders {
pub(crate) fn headers_for(&self, url: &Url) -> &[(String, String)] {
if let Some(origin) = &self.origin
&& url
.host_str()
.is_some_and(|host| host.eq_ignore_ascii_case(&origin.host))
{
return &origin.headers;
}
let Some(subdomains) = &self.subdomains else {
return &[];
};
let (Ok(url::Host::Domain(seed)), Some(url::Host::Domain(target))) =
(url::Host::parse(&subdomains.host), url.host())
else {
return &[];
};
let target = target.to_ascii_lowercase();
if target.len() > seed.len() + 1 && target.ends_with(&format!(".{seed}")) {
&subdomains.headers
} else {
&[]
}
}
}
pub fn has_userinfo(url: &Url) -> bool {
!url.username().is_empty() || url.password().is_some()
}
pub(crate) fn refuse_userinfo(url: &Url) -> Result<(), NetError> {
if !has_userinfo(url) {
return Ok(());
}
Err(NetError::Blocked(format!(
"a URL with credentials in it is refused: {}",
without_userinfo(url)
)))
}
pub(crate) fn without_userinfo(url: &Url) -> Url {
let mut clean = url.clone();
let _ = clean.set_password(None);
let _ = clean.set_username("");
clean
}
#[cfg(test)]
mod tests {
use super::*;
fn url(s: &str) -> Url {
Url::parse(s).expect("test URL must parse")
}
fn credential() -> OriginHeaders {
OriginHeaders {
host: "example.com".to_owned(),
headers: vec![
("X-Custom".to_owned(), "value".to_owned()),
("Authorization".to_owned(), "Basic dXNlcjpwdw==".to_owned()),
],
}
}
#[test]
fn the_scoped_host_gets_the_headers_on_any_scheme_and_port() {
let credential = credential();
assert_eq!(
credential.headers_for(&url("https://EXAMPLE.com:8443/a")),
credential.headers.as_slice()
);
}
#[test]
fn another_host_gets_nothing() {
let credential = credential();
for other in [
"http://cdn.test/a.js",
"http://sub.example.com/",
"http://example.com.evil.test/",
] {
assert!(
credential.headers_for(&url(other)).is_empty(),
"{other} must get nothing"
);
}
}
#[test]
fn public_subdomain_headers_use_proper_dns_boundaries_and_keep_seed_credentials() {
let scoped = ScopedHeaders {
origin: Some(credential()),
subdomains: Some(OriginHeaders {
host: "EXAMPLE.com".to_owned(),
headers: vec![("Accept".to_owned(), "application/json".to_owned())],
}),
};
assert_eq!(scoped.headers_for(&url("http://example.com/")), credential().headers);
for target in ["http://sub.example.com/", "https://a.docs.EXAMPLE.com:8443/"] {
assert_eq!(
scoped.headers_for(&url(target)),
[("Accept".to_owned(), "application/json".to_owned())]
);
}
for target in [
"http://other.test/",
"http://example.com.evil.test/",
"http://notexample.com/",
"http://.example.com/",
"http://127.0.0.1/",
"http://[::1]/",
] {
assert_eq!(scoped.headers_for(&url(target)), &[], "{target}");
}
}
#[test]
fn subdomain_headers_default_to_none_and_never_cover_ip_seeds() {
let scoped = ScopedHeaders {
origin: Some(credential()),
subdomains: None,
};
assert_eq!(scoped.headers_for(&url("http://sub.example.com/")), &[]);
for seed in ["127.0.0.1", "[::1]"] {
let scoped = ScopedHeaders {
origin: None,
subdomains: Some(OriginHeaders {
host: seed.to_owned(),
headers: credential().headers,
}),
};
assert_eq!(scoped.headers_for(&url("custom://sub.127.0.0.1/")), &[]);
assert_eq!(scoped.headers_for(&url("http://sub.example.com/")), &[]);
}
}
#[test]
fn public_policy_debug_output_hides_values() {
let scoped = ScopedHeaders {
origin: Some(credential()),
subdomains: Some(credential()),
};
let rendered = format!("{scoped:?}");
assert!(
!rendered.contains("dXNlcjpwdw") && !rendered.contains("value"),
"{rendered}"
);
assert!(rendered.contains("Authorization"), "{rendered}");
}
#[test]
fn a_url_with_userinfo_is_refused_without_it() {
let error = refuse_userinfo(&url("http://user:s3cret@example.com/a")).expect_err("must be refused");
let text = error.to_string();
assert!(!text.contains("s3cret"), "{text}");
assert!(text.contains("http://example.com/a"), "{text}");
assert!(refuse_userinfo(&url("http://example.com/a")).is_ok());
}
#[test]
fn debug_output_hides_the_value() {
let rendered = format!("{:?}", credential());
assert!(
!rendered.contains("dXNlcjpwdw") && !rendered.contains("value"),
"{rendered}"
);
assert!(rendered.contains("Authorization"), "{rendered}");
}
}