use std::collections::HashSet;
use std::fmt;
use std::net::IpAddr;
use ipnet::{IpNet, Ipv4Net};
use url::{Host, Url};
use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
use crate::error::Error;
use crate::util::{glob_match, redact_offending_token, split_host_port, strip_brackets};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum HostPattern {
All,
Cidr(IpNet),
Exact {
host: Host,
port: Option<u16>,
},
Domain {
suffix: String,
match_self: bool,
port: Option<u16>,
},
Wildcard {
pattern: String,
port: Option<u16>,
},
Local,
SubtractImplicit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BypassDialect {
Suffix,
Windows,
MacOs,
Gnome,
}
impl BypassDialect {
pub(crate) fn trim(self, entry: &str) -> &str {
match self {
BypassDialect::Gnome => entry.trim_end(),
BypassDialect::MacOs => entry,
BypassDialect::Suffix | BypassDialect::Windows => entry.trim(),
}
}
}
impl HostPattern {
pub fn parse(entry: &str) -> Result<Option<Self>, Error> {
Self::parse_in(entry, BypassDialect::Suffix)
}
pub(crate) fn parse_in(entry: &str, dialect: BypassDialect) -> Result<Option<Self>, Error> {
let entry = dialect.trim(entry);
if entry.is_empty() {
return Ok(None);
}
let lowered = entry.to_ascii_lowercase();
match lowered.as_str() {
"*" if !matches!(dialect, BypassDialect::MacOs | BypassDialect::Gnome) => {
return Ok(Some(HostPattern::All));
}
LOCAL_TOKEN => return Ok(Some(HostPattern::Local)),
NO_LOOPBACK_TOKEN => return Ok(Some(HostPattern::SubtractImplicit)),
_ => {}
}
if entry.contains('@') {
return Err(Error::bypass(
entry,
format!(
"entry looks like a proxy URL with credentials ({}), not a bare \
host[:port] or CIDR; bypass/no-proxy lists cannot carry a \
username or password",
redact_offending_token(entry)
),
));
}
if entry.chars().any(char::is_whitespace) {
return Err(Error::bypass(
entry,
"entry contains whitespace, so it is not a single host[:port] or CIDR \
(separate entries with ',', or on Windows with ';' or a space)",
));
}
if lowered.contains("://") {
return Err(Error::bypass(
entry,
"entry names a scheme, and a bypass pattern here applies to every scheme, \
so the restriction cannot be honoured (write the host on its own, for \
example example.com)",
));
}
if dialect == BypassDialect::Windows && lowered.contains('/') {
return Err(Error::bypass(
entry,
"entry contains a '/', which Windows does not read as a CIDR block — it \
invalidates the whole bypass list (write the range with wildcards, for \
example 10.* rather than 10.0.0.0/8)",
));
}
if dialect == BypassDialect::Windows
&& lowered.starts_with('.')
&& !lowered.trim_start_matches('.').is_empty()
{
return Err(Error::bypass(
entry,
"entry starts with a '.', which Windows does not read as a subdomain rule — \
WinINet refuses the whole bypass list over one (write the subdomains as \
*.example.com rather than .example.com)",
));
}
if let Ok(net) = lowered.parse::<IpNet>() {
return Ok(Some(HostPattern::Cidr(reduce_mapped_net(net))));
}
if lowered.contains('/') {
return Err(Error::bypass(
entry,
"entry contains a '/', so it can only be a CIDR block, but it is not a \
valid one (an address, a '/', and a prefix length — for example \
10.0.0.0/8 or fe80::/10)",
));
}
let (bracketed_text, port) =
split_host_port(&lowered).map_err(|reason| Error::bypass(entry, reason))?;
let host_text = strip_brackets(bracketed_text);
if dialect == BypassDialect::MacOs && port.is_some() {
return Err(Error::bypass(
entry,
"entry carries a port, which macOS does not read as a restriction — it \
compares the whole entry to the destination's host name, so no destination \
could ever match it (write the host on its own)",
));
}
if host_text.len() != bracketed_text.len()
&& !matches!(
crate::endpoint::parse_host(host_text),
Ok(Host::Ipv4(_) | Host::Ipv6(_))
)
{
return Err(Error::bypass(
entry,
"entry is bracketed, so it can only be an address literal, but it is not a \
valid one (for example [::1] or [2001:db8::1])",
));
}
if let Some(bad) = host_text.chars().find(|c| is_forbidden_host_char(*c)) {
return Err(Error::bypass(
entry,
format!(
"entry contains {bad:?}, which cannot appear in a host name, so no \
destination could ever match it"
),
));
}
let encoded;
let host_text = if host_text.is_ascii() {
host_text
} else {
encoded = idna_ascii(host_text).map_err(|reason| Error::bypass(entry, reason))?;
&encoded
};
let subdomain_body = host_text
.strip_prefix("*.")
.or_else(|| host_text.strip_prefix('.'));
let labelled = subdomain_body.unwrap_or(host_text);
let labelled = labelled.strip_suffix('.').unwrap_or(labelled);
if !labelled.is_empty() && labelled.split('.').any(str::is_empty) {
return Err(Error::bypass(
entry,
"entry has an empty label (two dots in a row), so no destination could \
ever match it (write example.com, .example.com or *.example.com)",
));
}
let suffix_body = subdomain_body
.map(|rest| rest.strip_suffix('.').unwrap_or(rest))
.filter(|body| !body.is_empty());
if let Some(body) = suffix_body {
let fits_a_dotted_quad = body.contains('*')
&& body.matches('.').count() < 3
&& body
.bytes()
.all(|b| b.is_ascii_digit() || b == b'.' || b == b'*');
match crate::endpoint::parse_host(body) {
Ok(Host::Domain(_)) => {}
Ok(_) => {
return Err(Error::bypass(
entry,
"entry names a subdomain of an address literal, which has none, so \
no destination could ever match it (write the address on its own, \
or a CIDR range such as 10.0.0.0/8)",
));
}
Err(_) if fits_a_dotted_quad => {}
Err(_) => return Err(Error::bypass(entry, UNREACHABLE_NAME)),
}
}
if let Some(rest) = host_text.strip_prefix("*.") {
let rest = rest.strip_suffix('.').unwrap_or(rest);
if rest.is_empty() {
return Ok(None);
}
if !rest.contains('*') {
return Ok(Some(HostPattern::Domain {
suffix: format!(".{rest}"),
match_self: dialect == BypassDialect::Gnome,
port,
}));
}
if dialect == BypassDialect::Gnome {
return Err(Error::bypass(entry, GNOME_LITERAL_STAR));
}
if dialect == BypassDialect::MacOs {
return Err(Error::bypass(entry, MACOS_LITERAL_STAR));
}
return Ok(Some(HostPattern::Wildcard {
pattern: format!("*.{rest}"),
port,
}));
}
let host_text = host_text.strip_suffix('.').unwrap_or(host_text);
if host_text.is_empty() {
return Ok(None);
}
if let Ok(ip) = host_text.parse::<IpAddr>() {
let host = match ip {
IpAddr::V4(v4) => Host::Ipv4(v4),
IpAddr::V6(v6) => Host::Ipv6(v6),
};
return Ok(Some(HostPattern::Exact { host, port }));
}
if host_text.contains('*') {
if dialect == BypassDialect::Gnome {
return Err(Error::bypass(entry, GNOME_LITERAL_STAR));
}
if dialect == BypassDialect::MacOs {
let head = host_text.strip_suffix(".*").filter(|head| {
!head.is_empty() && !head.contains('*') && !head.starts_with('.')
});
return match head {
Some(head) => Ok(Some(HostPattern::Wildcard {
pattern: format!("{head}.*"),
port,
})),
None => Err(Error::bypass(entry, MACOS_LITERAL_STAR)),
};
}
let pattern = if host_text.starts_with('.') {
format!("*{host_text}")
} else {
host_text.to_owned()
};
return Ok(Some(HostPattern::Wildcard { pattern, port }));
}
if let Some(rest) = host_text.strip_prefix('.') {
if rest.is_empty() {
return Ok(None);
}
return Ok(Some(HostPattern::Domain {
suffix: host_text.to_owned(),
match_self: dialect == BypassDialect::Gnome,
port,
}));
}
let Ok(host) = crate::endpoint::parse_host(host_text) else {
return Err(Error::bypass(entry, UNREACHABLE_NAME));
};
if matches!(host, Host::Ipv4(_))
|| matches!(dialect, BypassDialect::Windows | BypassDialect::MacOs)
{
return Ok(Some(HostPattern::Exact { host, port }));
}
Ok(Some(HostPattern::Domain {
suffix: format!(".{host_text}"),
match_self: true,
port,
}))
}
fn matches(&self, host_text: &str, ip: Option<IpAddr>, port: Option<u16>) -> bool {
match self {
HostPattern::All => true,
HostPattern::Cidr(net) => ip.is_some_and(|ip| net.contains(&ip)),
HostPattern::Exact {
host,
port: rule_port,
} => {
let hit = match (host_ip(host), ip) {
(Some(rule_ip), Some(destination_ip)) => rule_ip == destination_ip,
_ => host_text.strip_suffix('.').unwrap_or(host_text) == host_key(host),
};
hit && port_matches(*rule_port, port)
}
HostPattern::Domain {
suffix,
match_self,
port: rule_port,
} => {
let host_text = host_text.strip_suffix('.').unwrap_or(host_text);
let bare = suffix.strip_prefix('.').unwrap_or(suffix);
let hit = !bare.is_empty()
&& strip_suffix_ascii_case(host_text, bare).is_some_and(|rest| {
rest.ends_with('.') || (*match_self && rest.is_empty())
});
hit && port_matches(*rule_port, port)
}
HostPattern::Wildcard {
pattern,
port: rule_port,
} => {
let host_text = host_text.strip_suffix('.').unwrap_or(host_text);
let hit = if pattern.bytes().any(|byte| byte.is_ascii_uppercase()) {
glob_match(&pattern.to_ascii_lowercase(), host_text)
} else {
glob_match(pattern, host_text)
};
hit && port_matches(*rule_port, port)
}
HostPattern::Local => is_simple_host_name(host_text, ip),
HostPattern::SubtractImplicit => false,
}
}
}
impl fmt::Display for HostPattern {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HostPattern::All => f.write_str("*"),
HostPattern::Cidr(net) => write!(f, "{net}"),
HostPattern::Exact { host, port } => write_with_port(f, &host_display(host), *port),
HostPattern::Domain {
suffix,
match_self,
port,
} => {
let base = match (*match_self, suffix.strip_prefix('.')) {
(true, Some(bare)) => bare.to_owned(),
(false, None) => format!(".{suffix}"),
_ => suffix.clone(),
};
write_with_port(f, &base, *port)
}
HostPattern::Wildcard { pattern, port } => write_with_port(f, pattern, *port),
HostPattern::Local => f.write_str(LOCAL_TOKEN),
HostPattern::SubtractImplicit => f.write_str(NO_LOOPBACK_TOKEN),
}
}
}
pub const LOCAL_TOKEN: &str = "<local>";
pub const NO_LOOPBACK_TOKEN: &str = "<-loopback>";
const UNREACHABLE_NAME: &str = "entry is not a name any destination could carry, so no \
destination could ever match it (a last label that reads as \
a number, as in example.123, is taken for an address and \
refused as one; so is an unbracketed IPv6 that is not a \
valid address, as in 2001:db8:1)";
const GNOME_LITERAL_STAR: &str = "entry contains a '*' that GNOME does not read as a \
wildcard — only a leading '*.' is one there — so no \
destination could ever match it (write *.example.com for \
a domain, or the host on its own)";
const MACOS_LITERAL_STAR: &str = "entry contains a '*' that macOS does not read as a \
wildcard — only a leading '*.' or a trailing '.*' is one \
there — so no destination could ever match it (write \
*.example.com for a domain, or the host on its own)";
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BypassRules {
pub patterns: Vec<HostPattern>,
pub exclude_simple_hostnames: bool,
pub reversed_exceptions: bool,
pub rejected: Vec<RejectedValue>,
pub require_explicit_port: bool,
}
impl Default for BypassRules {
fn default() -> Self {
Self::new()
}
}
impl BypassRules {
#[must_use]
pub const fn new() -> Self {
Self {
patterns: Vec::new(),
exclude_simple_hostnames: false,
reversed_exceptions: false,
rejected: Vec::new(),
require_explicit_port: false,
}
}
#[must_use]
pub fn bypass_loopback(&self) -> bool {
!self
.patterns
.iter()
.any(|p| matches!(p, HostPattern::SubtractImplicit))
}
pub(crate) fn push_pattern(&mut self, pattern: HostPattern) {
self.patterns.push(pattern);
}
pub(crate) fn dedup_patterns(&mut self) {
let mut seen = HashSet::with_capacity(self.patterns.len());
self.patterns.retain(|pattern| {
if matches!(pattern, HostPattern::SubtractImplicit) {
seen.clear();
return true;
}
seen.insert(pattern.clone())
});
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
pub(crate) fn push_entry_in(&mut self, entry: &str, dialect: BypassDialect) {
match HostPattern::parse_in(entry, dialect) {
Ok(Some(pattern)) => self.push_pattern(pattern),
Ok(None) => {}
Err(err) => {
crate::trace::warning!(
error = %crate::trace::SafeError(&err),
"skipping an unparseable bypass list entry"
);
self.rejected.push(RejectedValue::new(
RejectionKind::InvalidBypassPattern,
RejectionSource::BypassList,
entry,
));
}
}
}
#[must_use]
pub fn excludes_simple_hostnames(&self) -> bool {
self.exclude_simple_hostnames
|| self
.patterns
.iter()
.any(|p| matches!(p, HostPattern::Local))
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
&& !self.exclude_simple_hostnames
&& !self.reversed_exceptions
}
#[must_use]
pub fn matches(&self, host: &Host, port: Option<u16>) -> bool {
let text = host_key(host);
if text.is_empty() {
return false;
}
let ip = host_ip(host);
let implicit = is_loopback(&text, ip) || is_link_local(ip);
let unnamed = self.reversed_exceptions && self.rejected.is_empty();
if self.exclude_simple_hostnames
&& is_simple_host_name(&text, ip)
&& (self.bypass_loopback() || !implicit)
{
return true;
}
for pattern in self.patterns.iter().rev() {
if matches!(pattern, HostPattern::SubtractImplicit) {
if implicit {
return unnamed;
}
} else if pattern.matches(&text, ip, port) {
return !self.reversed_exceptions;
}
}
implicit || unnamed
}
#[must_use]
pub fn matches_url(&self, url: &Url) -> bool {
let Some(host) = crate::endpoint::request_host(url) else {
return false;
};
let port = if self.require_explicit_port {
url.port()
} else {
url.port_or_known_default()
};
self.matches(&host, port)
}
#[must_use]
pub fn matches_authority(&self, authority: &str) -> bool {
let Ok((host_text, port)) = split_host_port(authority.trim()) else {
return false;
};
let Ok(host) = crate::endpoint::parse_host(host_text) else {
return false;
};
self.matches(&host, port)
}
}
fn is_forbidden_host_char(c: char) -> bool {
c.is_control()
|| matches!(
c,
'#' | '%' | '<' | '>' | '?' | '[' | '\\' | ']' | '^' | '|'
)
}
fn idna_ascii(host_text: &str) -> Result<String, String> {
const LOAN: &str = ".a";
let mut out = String::with_capacity(host_text.len());
for (index, label) in host_text.split('.').enumerate() {
if index > 0 {
out.push('.');
}
if label.is_ascii() {
out.push_str(label);
continue;
}
if label.contains('*') {
return Err(
"entry mixes a '*' glob with non-ASCII text in one label, which has no \
punycode spelling; write the label as punycode (xn--…) instead"
.to_owned(),
);
}
match Host::parse(&format!("{label}{LOAN}")) {
Ok(Host::Domain(ascii)) if ascii.ends_with(LOAN) => {
out.push_str(&ascii[..ascii.len() - LOAN.len()]);
}
_ => {
return Err(
"entry contains non-ASCII text that is not a valid internationalised \
domain name"
.to_owned(),
);
}
}
}
Ok(out)
}
fn port_matches(rule_port: Option<u16>, port: Option<u16>) -> bool {
match rule_port {
None => true,
Some(expected) => port == Some(expected),
}
}
fn is_loopback(host_text: &str, ip: Option<IpAddr>) -> bool {
if let Some(ip) = ip {
return ip.is_loopback();
}
let name = host_text.strip_suffix('.').unwrap_or(host_text);
name == "localhost" || name == "loopback" || name.ends_with(".localhost")
}
fn is_link_local(ip: Option<IpAddr>) -> bool {
match ip {
Some(IpAddr::V4(v4)) => v4.is_link_local(),
Some(IpAddr::V6(v6)) => v6.is_unicast_link_local(),
None => false,
}
}
fn is_simple_host_name(host_text: &str, ip: Option<IpAddr>) -> bool {
ip.is_none() && !host_text.contains('.')
}
fn host_key(host: &Host) -> String {
match host {
Host::Domain(domain) if !domain.is_ascii() => idna_ascii(domain)
.unwrap_or_else(|_| domain.clone())
.to_ascii_lowercase(),
Host::Domain(domain) => domain.to_ascii_lowercase(),
Host::Ipv4(ip) => ip.to_string(),
Host::Ipv6(ip) => ip.to_string(),
}
}
fn strip_suffix_ascii_case<'a>(text: &'a str, suffix: &str) -> Option<&'a str> {
let split = text.len().checked_sub(suffix.len())?;
if !text.is_char_boundary(split) {
return None;
}
text[split..]
.eq_ignore_ascii_case(suffix)
.then(|| &text[..split])
}
fn host_display(host: &Host) -> String {
match host {
Host::Ipv6(ip) => format!("[{ip}]"),
other => other.to_string(),
}
}
fn host_ip(host: &Host) -> Option<IpAddr> {
match host {
Host::Domain(_) => None,
Host::Ipv4(ip) => Some(IpAddr::V4(*ip)),
Host::Ipv6(ip) => Some(ip.to_ipv4_mapped().map_or(IpAddr::V6(*ip), IpAddr::V4)),
}
}
fn reduce_mapped_net(net: IpNet) -> IpNet {
let IpNet::V6(v6) = net else {
return net;
};
let (Some(addr), true) = (v6.addr().to_ipv4_mapped(), v6.prefix_len() >= 96) else {
return net;
};
Ipv4Net::new(addr, v6.prefix_len() - 96).map_or(net, IpNet::V4)
}
fn write_with_port(f: &mut fmt::Formatter<'_>, base: &str, port: Option<u16>) -> fmt::Result {
match port {
Some(port) => write!(f, "{base}:{port}"),
None => f.write_str(base),
}
}