use std::net::IpAddr;
fn normalize_host(host: &str) -> Result<String, idna::Errors> {
let trimmed = host.trim().trim_end_matches('.');
if trimmed.chars().any(char::is_control) {
return Err(idna::Errors::default());
}
let ascii = idna::domain_to_ascii(trimmed)?;
Ok(ascii.trim_end_matches('.').to_string())
}
fn normalize_entry(entry: &str) -> String {
normalize_host(entry).unwrap_or_else(|_| entry.trim().trim_end_matches('.').to_lowercase())
}
#[must_use]
pub fn host_pattern_matches(pattern: &str, host: &str) -> bool {
if pattern == "*" {
return !host.is_empty();
}
if let Some(suffix) = pattern.strip_prefix('*') {
if !suffix.starts_with('.') {
return false;
}
let Some(prefix) = host.strip_suffix(suffix) else {
return false;
};
return !prefix.is_empty() && !prefix.ends_with('.');
}
let mut pattern_labels = pattern.split('.');
let mut host_labels = host.split('.');
loop {
match (pattern_labels.next(), host_labels.next()) {
(Some(p), Some(h)) => {
if p == "*" {
if h.is_empty() {
return false;
}
} else if p != h {
return false;
}
}
(None, None) => return true,
_ => return false,
}
}
}
pub fn validate_host_pattern(pattern: &str) -> Result<(), String> {
if pattern.is_empty() {
return Err("pattern is empty".to_string());
}
if pattern == "*" {
return Ok(());
}
if let Some(suffix) = pattern.strip_prefix('*') {
if !suffix.starts_with('.') {
return Err(format!(
"pattern '{pattern}': a leading '*' must be followed by '.' (e.g. '*.example.com')"
));
}
let rest = &suffix[1..];
if rest.is_empty() {
return Err(format!(
"pattern '{pattern}': '*.' must be followed by a domain"
));
}
if rest.contains('*') {
return Err(format!(
"pattern '{pattern}': a leading '*.' wildcard cannot be combined with another '*' \
elsewhere in the pattern — the remainder is matched literally, so this can never match"
));
}
return Ok(());
}
for label in pattern.split('.') {
if label.contains('*') && label != "*" {
return Err(format!(
"pattern '{pattern}': '*' must occupy a whole label (e.g. 'a.*.b.com'), not part of \
one like '{label}' — a real hostname label can never contain '*', so this can never match"
));
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterResult {
Allow,
DenyHost {
host: String,
},
DenyLinkLocal {
ip: IpAddr,
},
DenyNotAllowed {
host: String,
},
}
impl FilterResult {
#[must_use]
pub fn is_allowed(&self) -> bool {
matches!(self, FilterResult::Allow)
}
#[must_use]
pub fn reason(&self) -> String {
match self {
FilterResult::Allow => "allowed by host filter".to_string(),
FilterResult::DenyHost { host } => {
format!("host {} is in the deny list", sanitize_for_display(host))
}
FilterResult::DenyLinkLocal { ip } => {
format!(
"resolved IP {} is in the link-local range (cloud metadata protection)",
ip
)
}
FilterResult::DenyNotAllowed { host } => {
format!(
"host {} is not in the allowlist",
sanitize_for_display(host)
)
}
}
}
}
fn sanitize_for_display(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
fn is_link_local(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.octets()[0] == 169 && v4.octets()[1] == 254,
IpAddr::V6(v6) => {
if (v6.segments()[0] & 0xffc0) == 0xfe80 {
return true;
}
if let Some(v4) = v6.to_ipv4_mapped() {
return v4.octets()[0] == 169 && v4.octets()[1] == 254;
}
false
}
}
}
const DENY_HOSTS: &[&str] = &[
"169.254.169.254",
"metadata.google.internal",
"metadata.azure.internal",
];
fn partition_hosts(entries: &[String]) -> (Vec<String>, Vec<String>) {
let mut exact = Vec::new();
let mut patterns = Vec::new();
for entry in entries {
if entry.contains('*') {
patterns.push(normalize_entry(entry));
} else {
exact.push(normalize_entry(entry));
}
}
(exact, patterns)
}
#[derive(Debug, Clone)]
pub struct HostFilter {
allowed_hosts: Vec<String>,
allowed_patterns: Vec<String>,
deny_hosts: Vec<String>,
deny_patterns: Vec<String>,
strict: bool,
}
impl HostFilter {
#[must_use]
pub fn new(allowed_hosts: &[String]) -> Self {
let (exact, patterns) = partition_hosts(allowed_hosts);
Self {
allowed_hosts: exact,
allowed_patterns: patterns,
deny_hosts: DENY_HOSTS.iter().map(|s| normalize_entry(s)).collect(),
deny_patterns: Vec::new(),
strict: false,
}
}
#[must_use]
pub fn new_strict(allowed_hosts: &[String]) -> Self {
let mut filter = Self::new(allowed_hosts);
filter.strict = true;
filter
}
#[must_use]
pub fn allow_all() -> Self {
Self {
allowed_hosts: Vec::new(),
allowed_patterns: Vec::new(),
deny_hosts: DENY_HOSTS.iter().map(|s| normalize_entry(s)).collect(),
deny_patterns: Vec::new(),
strict: false,
}
}
#[must_use]
pub fn with_denied_hosts(mut self, denied: &[String]) -> Self {
let (exact, patterns) = partition_hosts(denied);
self.deny_hosts.extend(exact);
self.deny_patterns.extend(patterns);
self
}
#[must_use]
pub fn check_host(&self, host: &str, resolved_ips: &[IpAddr]) -> FilterResult {
let Ok(lower_host) = normalize_host(host) else {
return FilterResult::DenyNotAllowed {
host: host.to_string(),
};
};
if self.deny_hosts.contains(&lower_host) {
return FilterResult::DenyHost {
host: host.to_string(),
};
}
if self
.deny_patterns
.iter()
.any(|pattern| host_pattern_matches(pattern, &lower_host))
{
return FilterResult::DenyHost {
host: host.to_string(),
};
}
for ip in resolved_ips {
if is_link_local(ip) {
return FilterResult::DenyLinkLocal { ip: *ip };
}
}
if self.allowed_hosts.is_empty() && self.allowed_patterns.is_empty() {
if self.strict {
return FilterResult::DenyNotAllowed {
host: host.to_string(),
};
}
return FilterResult::Allow;
}
if self.allowed_hosts.contains(&lower_host) {
return FilterResult::Allow;
}
if self
.allowed_patterns
.iter()
.any(|pattern| host_pattern_matches(pattern, &lower_host))
{
return FilterResult::Allow;
}
FilterResult::DenyNotAllowed {
host: host.to_string(),
}
}
pub fn check_deny(&self, host: &str) -> Option<FilterResult> {
let lower_host = normalize_entry(host);
if self.deny_hosts.contains(&lower_host) {
return Some(FilterResult::DenyHost {
host: host.to_string(),
});
}
if self
.deny_patterns
.iter()
.any(|pattern| host_pattern_matches(pattern, &lower_host))
{
return Some(FilterResult::DenyHost {
host: host.to_string(),
});
}
None
}
#[must_use]
pub fn normalize_authority_host(host: &str) -> String {
normalize_entry(host)
}
#[must_use]
pub fn allowed_count(&self) -> usize {
self.allowed_hosts
.len()
.saturating_add(self.allowed_patterns.len())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
fn public_ip() -> Vec<IpAddr> {
vec![IpAddr::V4(Ipv4Addr::new(104, 18, 7, 96))]
}
#[test]
fn test_validate_host_pattern_accepts_all_three_wildcard_forms() {
assert!(validate_host_pattern("*").is_ok());
assert!(validate_host_pattern("*.example.com").is_ok());
assert!(validate_host_pattern("jenkins.*.ci.example.com").is_ok());
assert!(validate_host_pattern("api.openai.com").is_ok());
}
#[test]
fn test_validate_host_pattern_rejects_empty() {
assert!(validate_host_pattern("").is_err());
}
#[test]
fn test_validate_host_pattern_rejects_partial_label_wildcard() {
assert!(validate_host_pattern("foo*bar.com").is_err());
assert!(validate_host_pattern("foo.ba*r.com").is_err());
}
#[test]
fn test_validate_host_pattern_rejects_bare_leading_star_without_dot() {
assert!(validate_host_pattern("*example.com").is_err());
}
#[test]
fn test_validate_host_pattern_rejects_dangling_leading_wildcard() {
assert!(validate_host_pattern("*.").is_err());
}
#[test]
fn test_validate_host_pattern_rejects_leading_wildcard_combined_with_another() {
assert!(validate_host_pattern("*.foo.*.com").is_err());
}
#[test]
fn test_exact_host_allowed() {
let filter = HostFilter::new(&["api.openai.com".to_string()]);
let result = filter.check_host("api.openai.com", &public_ip());
assert!(result.is_allowed());
}
#[test]
fn test_exact_host_case_insensitive() {
let filter = HostFilter::new(&["API.OpenAI.COM".to_string()]);
let result = filter.check_host("api.openai.com", &public_ip());
assert!(result.is_allowed());
}
#[test]
fn test_host_not_in_allowlist() {
let filter = HostFilter::new(&["api.openai.com".to_string()]);
let result = filter.check_host("evil.com", &public_ip());
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyNotAllowed { .. }));
}
#[test]
fn test_wildcard_subdomain_match() {
let filter = HostFilter::new(&["*.googleapis.com".to_string()]);
let result = filter.check_host("storage.googleapis.com", &public_ip());
assert!(result.is_allowed());
let result = filter.check_host("us-central1-aiplatform.googleapis.com", &public_ip());
assert!(result.is_allowed());
}
#[test]
fn test_wildcard_does_not_match_bare_domain() {
let filter = HostFilter::new(&["*.googleapis.com".to_string()]);
let result = filter.check_host("googleapis.com", &public_ip());
assert!(!result.is_allowed());
}
#[test]
fn test_partial_label_wildcard_never_matches() {
assert!(!host_pattern_matches(
"jenkins-*.example.com",
"jenkins-prod.example.com"
));
}
#[test]
fn test_leading_wildcard_rejects_empty_label_before_suffix() {
assert!(!host_pattern_matches("*.example.com", "..example.com"));
assert!(!host_pattern_matches("*.example.com", "a..example.com"));
assert!(host_pattern_matches("*.example.com", "a.example.com"));
assert!(host_pattern_matches("*.example.com", "a.b.example.com"));
}
#[test]
fn test_non_leading_wildcard_label_matches_exactly_one_label() {
let filter = HostFilter::new(&["jenkins.*.ci.example.com".to_string()]);
assert!(
filter
.check_host("jenkins.prod.ci.example.com", &public_ip())
.is_allowed()
);
assert!(
filter
.check_host("jenkins.stage.ci.example.com", &public_ip())
.is_allowed()
);
assert!(
!filter
.check_host("jenkins.ci.example.com", &public_ip())
.is_allowed()
);
assert!(
!filter
.check_host("jenkins.foo.bar.ci.example.com", &public_ip())
.is_allowed()
);
assert!(
!filter
.check_host("other.prod.ci.example.com", &public_ip())
.is_allowed()
);
}
#[test]
fn test_non_leading_wildcard_label_rejects_empty_label() {
assert!(!host_pattern_matches(
"jenkins.*.ci.example.com",
"jenkins..ci.example.com"
));
}
#[test]
fn test_non_leading_wildcard_label_deny() {
let filter =
HostFilter::allow_all().with_denied_hosts(&["jenkins.*.ci.example.com".to_string()]);
assert!(
!filter
.check_host("jenkins.prod.ci.example.com", &public_ip())
.is_allowed()
);
assert!(
filter
.check_host("jenkins.ci.example.com", &public_ip())
.is_allowed()
);
}
#[test]
fn test_deny_cloud_metadata_hostname() {
let filter = HostFilter::new(&["169.254.169.254".to_string()]);
let result = filter.check_host("169.254.169.254", &public_ip());
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyHost { .. }));
}
#[test]
fn test_deny_google_metadata() {
let filter = HostFilter::new(&["metadata.google.internal".to_string()]);
let result = filter.check_host("metadata.google.internal", &public_ip());
assert!(!result.is_allowed());
}
#[test]
fn test_allow_all_mode() {
let filter = HostFilter::allow_all();
let result = filter.check_host("any-host.example.com", &public_ip());
assert!(result.is_allowed());
}
#[test]
fn test_allow_all_allows_private_networks() {
let filter = HostFilter::allow_all();
let private_ip = vec![IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))];
let result = filter.check_host("internal.corp.com", &private_ip);
assert!(result.is_allowed());
}
#[test]
fn test_allow_all_allows_192_168() {
let filter = HostFilter::allow_all();
let private_ip = vec![IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))];
let result = filter.check_host("nas.local", &private_ip);
assert!(result.is_allowed());
}
#[test]
fn test_deny_link_local_ipv4() {
let filter = HostFilter::new(&["*.example.com".to_string()]);
let link_local = vec![IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))];
let result = filter.check_host("api.example.com", &link_local);
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyLinkLocal { .. }));
}
#[test]
fn test_deny_link_local_ipv6() {
let filter = HostFilter::new(&["*.example.com".to_string()]);
let link_local = vec![IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1))];
let result = filter.check_host("api.example.com", &link_local);
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyLinkLocal { .. }));
}
#[test]
fn test_deny_ipv4_mapped_ipv6_link_local() {
let filter = HostFilter::new(&["attacker.com".to_string()]);
let mapped = vec![IpAddr::V6(Ipv6Addr::new(
0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0xa9fe,
))];
let result = filter.check_host("attacker.com", &mapped);
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyLinkLocal { .. }));
}
#[test]
fn test_deny_ipv4_mapped_ipv6_other_link_local() {
let filter = HostFilter::allow_all();
let mapped = vec![IpAddr::V6(Ipv6Addr::new(
0, 0, 0, 0, 0, 0xffff, 0xa9fe, 0x0001,
))];
let result = filter.check_host("evil.com", &mapped);
assert!(!result.is_allowed());
}
#[test]
fn test_ipv4_mapped_ipv6_non_link_local_allowed() {
let filter = HostFilter::allow_all();
let mapped = vec![IpAddr::V6(Ipv6Addr::new(
0, 0, 0, 0, 0, 0xffff, 0x6812, 0x0760,
))];
let result = filter.check_host("example.com", &mapped);
assert!(result.is_allowed());
}
#[test]
fn test_dns_rebinding_to_metadata_ip() {
let filter = HostFilter::new(&["attacker.com".to_string()]);
let metadata_ip = vec![IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))];
let result = filter.check_host("attacker.com", &metadata_ip);
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyLinkLocal { .. }));
}
#[test]
fn test_dns_rebinding_allow_all_blocked() {
let filter = HostFilter::allow_all();
let metadata_ip = vec![IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))];
let result = filter.check_host("evil.com", &metadata_ip);
assert!(!result.is_allowed());
}
#[test]
fn test_empty_resolved_ips_skips_link_local_check() {
let filter = HostFilter::new(&["api.openai.com".to_string()]);
let result = filter.check_host("api.openai.com", &[]);
assert!(result.is_allowed());
}
#[test]
fn test_multiple_ips_any_link_local_denied() {
let filter = HostFilter::new(&["multi.example.com".to_string()]);
let ips = vec![
IpAddr::V4(Ipv4Addr::new(104, 18, 7, 96)),
IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1)),
];
let result = filter.check_host("multi.example.com", &ips);
assert!(!result.is_allowed());
}
#[test]
fn test_user_deny_host_exact() {
let filter = HostFilter::allow_all().with_denied_hosts(&["evil.com".to_string()]);
let result = filter.check_host("evil.com", &public_ip());
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyHost { .. }));
}
#[test]
fn test_user_deny_host_does_not_affect_others() {
let filter = HostFilter::allow_all().with_denied_hosts(&["evil.com".to_string()]);
let result = filter.check_host("good.com", &public_ip());
assert!(result.is_allowed());
}
#[test]
fn test_user_deny_host_wildcard() {
let filter = HostFilter::allow_all().with_denied_hosts(&["*.ads.example.com".to_string()]);
assert!(
!filter
.check_host("tracker.ads.example.com", &public_ip())
.is_allowed()
);
assert!(
!filter
.check_host("pixel.ads.example.com", &public_ip())
.is_allowed()
);
assert!(
filter
.check_host("ads.example.com", &public_ip())
.is_allowed()
);
}
#[test]
fn test_user_deny_beats_allowlist() {
let filter =
HostFilter::new(&["evil.com".to_string()]).with_denied_hosts(&["evil.com".to_string()]);
let result = filter.check_host("evil.com", &public_ip());
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyHost { .. }));
}
#[test]
fn test_user_deny_case_insensitive() {
let filter = HostFilter::allow_all().with_denied_hosts(&["Evil.COM".to_string()]);
let result = filter.check_host("evil.com", &public_ip());
assert!(!result.is_allowed());
}
#[test]
fn test_check_deny_matches_host_port_entry() {
let filter = HostFilter::allow_all().with_denied_hosts(&["127.0.0.1:8975".to_string()]);
assert!(matches!(
filter.check_deny("127.0.0.1:8975"),
Some(FilterResult::DenyHost { .. })
));
assert!(filter.check_deny("127.0.0.1").is_none());
assert!(filter.check_deny("127.0.0.1:8787").is_none());
}
#[test]
fn test_check_deny_ignores_allowlist() {
let filter = HostFilter::new(&["good.com".to_string()]);
assert!(filter.check_deny("good.com").is_none());
assert!(filter.check_deny("anything-else.com").is_none());
}
#[test]
fn test_allowed_count() {
let filter = HostFilter::new(&[
"api.openai.com".to_string(),
"*.googleapis.com".to_string(),
"github.com".to_string(),
]);
assert_eq!(filter.allowed_count(), 3);
}
#[test]
fn test_filter_result_reason() {
let allow = FilterResult::Allow;
assert!(allow.reason().contains("allowed"));
let deny = FilterResult::DenyNotAllowed {
host: "evil.com".to_string(),
};
assert!(deny.reason().contains("evil.com"));
}
#[test]
fn test_filter_result_reason_strips_terminal_escape_sequences() {
let deny = FilterResult::DenyNotAllowed {
host: "evil\x1b[31mFAKE ADMIN PROMPT\x1b[0m.com".to_string(),
};
let reason = deny.reason();
assert!(!reason.contains('\x1b'));
assert!(reason.contains("evil"));
assert!(reason.contains("FAKE ADMIN PROMPT"));
let deny_host = FilterResult::DenyHost {
host: "\x07evil.com".to_string(),
};
assert!(!deny_host.reason().contains('\x07'));
let link_local = FilterResult::DenyLinkLocal {
ip: IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
};
assert!(link_local.reason().contains("link-local"));
}
#[test]
fn test_strict_filter_empty_allowlist_denies() {
let filter = HostFilter::new_strict(&[]);
let result = filter.check_host("example.com", &public_ip());
assert!(matches!(result, FilterResult::DenyNotAllowed { .. }));
}
#[test]
fn test_strict_filter_respects_explicit_allowlist() {
let filter = HostFilter::new_strict(&["api.openai.com".to_string()]);
let allowed = filter.check_host("api.openai.com", &public_ip());
assert!(matches!(allowed, FilterResult::Allow));
let denied = filter.check_host("evil.com", &public_ip());
assert!(matches!(denied, FilterResult::DenyNotAllowed { .. }));
}
#[test]
fn test_non_strict_empty_allowlist_allows() {
let filter = HostFilter::new(&[]);
let result = filter.check_host("example.com", &public_ip());
assert!(matches!(result, FilterResult::Allow));
}
#[test]
fn test_trailing_dot_bypass_on_hardcoded_metadata_deny() {
let filter = HostFilter::allow_all();
let result = filter.check_host("metadata.google.internal.", &public_ip());
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyHost { .. }));
let result = filter.check_host("metadata.azure.internal.", &public_ip());
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyHost { .. }));
}
#[test]
fn test_trailing_dot_bypass_on_user_deny_entry() {
let filter = HostFilter::allow_all().with_denied_hosts(&["evil.com".to_string()]);
let result = filter.check_host("evil.com.", &public_ip());
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyHost { .. }));
}
#[test]
fn test_trailing_dot_on_deny_entry_itself_still_matches() {
let filter = HostFilter::allow_all().with_denied_hosts(&["evil.com.".to_string()]);
let result = filter.check_host("evil.com", &public_ip());
assert!(!result.is_allowed());
}
#[test]
fn test_unicode_and_punycode_deny_entries_are_equivalent() {
let filter = HostFilter::allow_all().with_denied_hosts(&["münchen.de".to_string()]);
let result = filter.check_host("xn--mnchen-3ya.de", &public_ip());
assert!(!result.is_allowed());
let filter = HostFilter::allow_all().with_denied_hosts(&["xn--mnchen-3ya.de".to_string()]);
let result = filter.check_host("münchen.de", &public_ip());
assert!(!result.is_allowed());
}
#[test]
fn test_unicode_dns_label_separators_normalize_like_dot() {
let filter = HostFilter::allow_all().with_denied_hosts(&["evil.com".to_string()]);
let result = filter.check_host("evil\u{3002}com", &public_ip());
assert!(!result.is_allowed());
}
#[test]
fn test_trailing_unicode_label_separator_bypass_on_metadata_deny() {
let filter = HostFilter::allow_all();
let result = filter.check_host("metadata.google.internal\u{3002}", &public_ip());
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyHost { .. }));
}
#[test]
fn test_embedded_nul_byte_fails_closed() {
let filter = HostFilter::allow_all();
let result = filter.check_host("metadata.google.internal\0", &public_ip());
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyNotAllowed { .. }));
}
#[test]
fn test_control_characters_fail_closed() {
let filter = HostFilter::allow_all();
for host in ["a\tb.com", "a\nb.com", "a\u{7f}b.com", "a\u{1}b.com"] {
let result = filter.check_host(host, &public_ip());
assert!(!result.is_allowed(), "host {host:?} should fail closed");
}
}
#[test]
fn test_malformed_punycode_host_fails_closed() {
let filter = HostFilter::allow_all();
let result = filter.check_host("xn--zz", &public_ip());
assert!(!result.is_allowed());
assert!(matches!(result, FilterResult::DenyNotAllowed { .. }));
}
#[test]
fn test_trailing_dot_does_not_break_wildcard_allow() {
let filter = HostFilter::new(&["*.googleapis.com".to_string()]);
let result = filter.check_host("storage.googleapis.com.", &public_ip());
assert!(result.is_allowed());
}
#[test]
fn test_check_deny_matches_idna_invalid_entry() {
let filter = HostFilter::allow_all().with_denied_hosts(&["xn--zz:443".to_string()]);
assert!(matches!(
filter.check_deny("xn--zz:443"),
Some(FilterResult::DenyHost { .. })
));
}
}