pub const REFERRER_CHECK_NAME: &str = "referrer";
pub const REFERRER_MISSING_STATUS: u16 = 403;
pub const REFERRER_MISSING_BODY: &str = "Referrer required";
pub const REFERRER_INVALID_STATUS: u16 = 403;
pub const REFERRER_INVALID_BODY: &str = "Invalid referrer";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReferrerVerdict {
Allowed,
Missing,
Invalid {
referrer: String,
},
}
#[must_use]
pub fn url_netloc(url: &str) -> &str {
let Some(rest) = url.split_once("://").map(|(_, after)| after) else {
return "";
};
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
&rest[..end]
}
#[must_use]
pub fn normalize_allowed_domain(entry: &str) -> String {
if entry.contains("://") {
url_netloc(entry).to_lowercase()
} else {
let lowered = entry.to_lowercase();
match lowered.find('/') {
Some(slash) => lowered[..slash].to_owned(),
None => lowered,
}
}
}
#[must_use]
pub fn is_referrer_domain_allowed(referrer: &str, allowed_domains: &[String]) -> bool {
let referrer_domain = url_netloc(referrer).to_lowercase();
for allowed in allowed_domains {
let normalized = normalize_allowed_domain(allowed);
if referrer_domain == normalized || referrer_domain.ends_with(&format!(".{normalized}")) {
return true;
}
}
false
}
#[must_use]
pub fn decide(referrer: Option<&str>, allowed_domains: &[String]) -> ReferrerVerdict {
let Some(raw) = referrer else {
return ReferrerVerdict::Missing;
};
if raw.is_empty() {
return ReferrerVerdict::Missing;
}
if is_referrer_domain_allowed(raw, allowed_domains) {
ReferrerVerdict::Allowed
} else {
ReferrerVerdict::Invalid {
referrer: raw.to_owned(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn allowed(entries: &[&str]) -> Vec<String> {
entries.iter().map(|entry| (*entry).to_owned()).collect()
}
#[test]
fn netloc_extraction_matches_urlparse_shapes() {
assert_eq!(url_netloc("https://example.com/path"), "example.com");
assert_eq!(
url_netloc("http://example.com:8080/x?y=1#z"),
"example.com:8080"
);
assert_eq!(
url_netloc("https://user:pw@example.com/"),
"user:pw@example.com"
);
assert_eq!(url_netloc("https://example.com"), "example.com");
assert_eq!(url_netloc("example.com/path"), "");
assert_eq!(url_netloc(""), "");
}
#[test]
fn allowed_entry_normalization_covers_the_three_shapes() {
assert_eq!(normalize_allowed_domain("Example.COM"), "example.com");
assert_eq!(
normalize_allowed_domain("example.com/page"),
"example.com",
"a path-suffixed entry is cut at the first slash"
);
assert_eq!(
normalize_allowed_domain("https://Example.com/page"),
"example.com",
"a full-URL entry parses to its netloc"
);
}
#[test]
fn exact_and_subdomain_hosts_pass_others_fail() {
let domains = allowed(&["example.com"]);
assert!(is_referrer_domain_allowed(
"https://example.com/x",
&domains
));
assert!(is_referrer_domain_allowed(
"https://EXAMPLE.com/x",
&domains
));
assert!(is_referrer_domain_allowed(
"https://api.example.com/x",
&domains
));
assert!(
!is_referrer_domain_allowed("https://notexample.com/x", &domains),
"the subdomain arm is endswith(.domain), never a substring test"
);
assert!(
!is_referrer_domain_allowed("https://example.com.evil.test/x", &domains),
"a suffixed host is not a subdomain"
);
assert!(!is_referrer_domain_allowed(
"https://other.test/x",
&domains
));
}
#[test]
fn schemeless_referrers_have_no_host_and_fail() {
let domains = allowed(&["example.com"]);
assert!(!is_referrer_domain_allowed("example.com", &domains));
}
#[test]
fn decide_splits_missing_allowed_invalid() {
let domains = allowed(&["example.com"]);
assert_eq!(decide(None, &domains), ReferrerVerdict::Missing);
assert_eq!(decide(Some(""), &domains), ReferrerVerdict::Missing);
assert_eq!(
decide(Some("https://example.com/"), &domains),
ReferrerVerdict::Allowed
);
assert_eq!(
decide(Some("https://evil.test/"), &domains),
ReferrerVerdict::Invalid {
referrer: "https://evil.test/".to_owned()
}
);
}
#[test]
fn multiple_entries_allow_any_match() {
let domains = allowed(&["a.test", "https://b.test/page"]);
assert_eq!(
decide(Some("https://a.test/x"), &domains),
ReferrerVerdict::Allowed
);
assert_eq!(
decide(Some("https://b.test/y"), &domains),
ReferrerVerdict::Allowed
);
assert_eq!(
decide(Some("https://c.test/y"), &domains),
ReferrerVerdict::Invalid {
referrer: "https://c.test/y".to_owned()
}
);
}
}