use std::net::IpAddr;
pub const HARD_BYPASS: &[&str] = &["127.0.0.0/8", "::1", "localhost"];
#[derive(Debug, Clone)]
enum Matcher {
All,
Ip { ip: IpAddr, port: Option<u16> },
Cidr { network: IpAddr, prefix: u8 },
Domain {
suffix: Option<String>,
exact: Option<String>,
port: Option<u16>,
},
}
impl Matcher {
fn matches(&self, host: &str, port: u16, ip: Option<&IpAddr>) -> bool {
match self {
Self::All => true,
Self::Ip { ip: entry, port: p } => {
ip == Some(entry) && p.is_none_or(|want| want == port)
}
Self::Cidr { network, prefix } => ip.is_some_and(|ip| contains(network, *prefix, ip)),
Self::Domain {
suffix,
exact,
port: p,
} => {
if ip.is_some() {
return false;
}
let hit = suffix.as_deref().is_some_and(|s| host.ends_with(s))
|| exact.as_deref() == Some(host);
hit && p.is_none_or(|want| want == port)
}
}
}
fn is_loopback_only(&self) -> bool {
match self {
Self::All => false,
Self::Ip { ip, .. } => ip.is_loopback(),
Self::Cidr { network, prefix } => match network {
IpAddr::V4(v4) => v4.is_loopback() && *prefix >= 8,
IpAddr::V6(v6) => v6.is_loopback() && *prefix == 128,
},
Self::Domain { suffix, exact, .. } => {
let loopback_name = |n: &str| n == "localhost" || n.ends_with(".localhost");
suffix
.as_deref()
.is_none_or(|s| loopback_name(s.trim_start_matches('.')))
&& exact.as_deref().is_none_or(loopback_name)
}
}
}
}
fn contains(network: &IpAddr, prefix: u8, ip: &IpAddr) -> bool {
match (network, ip) {
(IpAddr::V4(net), IpAddr::V4(ip)) => {
let mask = if prefix == 0 {
0
} else {
u32::MAX << (32 - u32::from(prefix))
};
u32::from(*net) & mask == u32::from(*ip) & mask
}
(IpAddr::V6(net), IpAddr::V6(ip)) => {
let mask = if prefix == 0 {
0
} else {
u128::MAX << (128 - u32::from(prefix))
};
u128::from(*net) & mask == u128::from(*ip) & mask
}
_ => false,
}
}
fn mask(addr: IpAddr, prefix: u8) -> IpAddr {
match addr {
IpAddr::V4(v4) => {
let bits = if prefix == 0 {
0
} else {
u32::from(v4) & (u32::MAX << (32 - u32::from(prefix)))
};
IpAddr::V4(bits.into())
}
IpAddr::V6(v6) => {
let bits = if prefix == 0 {
0
} else {
u128::from(v6) & (u128::MAX << (128 - u32::from(prefix)))
};
IpAddr::V6(bits.into())
}
}
}
fn normalize(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
IpAddr::V4(_) => ip,
}
}
fn split_host_port(entry: &str) -> Option<(&str, &str)> {
if let Some(rest) = entry.strip_prefix('[') {
let close = rest.find(']')?;
let port = rest[close + 1..].strip_prefix(':')?;
return Some((&rest[..close], port));
}
let colon = entry.find(':')?;
let (host, port) = (&entry[..colon], &entry[colon + 1..]);
if port.contains(':') {
return None;
}
Some((host, port))
}
#[derive(Debug, Clone)]
pub struct NoProxyMatcher {
matchers: Vec<Matcher>,
has_non_loopback: bool,
}
impl NoProxyMatcher {
pub fn new(user_entries: &str) -> (Self, Vec<String>) {
let mut matchers = Vec::new();
let mut warnings = Vec::new();
let mut has_non_loopback = false;
for entry in HARD_BYPASS {
if let Ok(matcher) = parse_entry(entry, true) {
matchers.push(matcher);
}
}
for raw in user_entries.split(',') {
let entry = raw.trim().to_ascii_lowercase();
if entry.is_empty() {
continue;
}
match parse_entry(&entry, false) {
Ok(matcher) => {
has_non_loopback |= !matcher.is_loopback_only();
matchers.push(matcher);
}
Err(reason) => warnings.push(format!("no_proxy entry {raw:?} ignored: {reason}")),
}
}
(
Self {
matchers,
has_non_loopback,
},
warnings,
)
}
pub fn matches(&self, host: &str, port: u16) -> bool {
let trimmed = host.trim();
let unbracketed = trimmed
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(trimmed);
let host = unbracketed.to_ascii_lowercase();
let ip = host.parse::<IpAddr>().ok().map(normalize);
self.matchers
.iter()
.any(|m| m.matches(&host, port, ip.as_ref()))
}
pub fn has_non_loopback_entry(&self) -> bool {
self.has_non_loopback
}
}
fn parse_entry(entry: &str, hard: bool) -> Result<Matcher, String> {
if entry == "*" {
return Ok(Matcher::All);
}
if let Some((addr, len)) = entry.split_once('/') {
let addr: IpAddr = addr
.parse()
.map_err(|_| format!("{addr:?} is not an IP address"))?;
let prefix: u8 = len
.parse()
.map_err(|_| format!("{len:?} is not a prefix length"))?;
let width: u8 = if addr.is_ipv4() { 32 } else { 128 };
if prefix > width {
return Err(format!("/{prefix} exceeds the {width}-bit address width"));
}
return Ok(Matcher::Cidr {
network: mask(addr, prefix),
prefix,
});
}
let (host, port) = match split_host_port(entry) {
Some((host, port)) => {
if host.is_empty() {
return Err("no host before the port".to_string());
}
let port = port
.parse::<u16>()
.map_err(|_| format!("{port:?} is not a port number"))?;
(host, Some(port))
}
None => (entry, None),
};
if let Ok(ip) = host.parse::<IpAddr>() {
return Ok(Matcher::Ip {
ip: normalize(ip),
port,
});
}
if host.starts_with('[') {
return Err("bracketed hosts are only valid as [ipv6]:port".to_string());
}
if host.is_empty() {
return Err("no host".to_string());
}
let host = if host.starts_with("*.") {
&host[1..]
} else {
host
};
if let Some(sub) = host.strip_prefix('.') {
if sub.is_empty() {
return Err("no domain after the leading dot".to_string());
}
return Ok(Matcher::Domain {
suffix: Some(host.to_string()),
exact: None,
port,
});
}
Ok(Matcher::Domain {
suffix: (!hard).then(|| format!(".{host}")),
exact: Some(host.to_string()),
port,
})
}
#[cfg(test)]
mod tests {
use super::*;
const GO_NO_PROXY: &str = "foobar.com, .barbaz.net, *.wildcard.io, 192.168.1.1, \
192.168.1.2:81, 192.168.1.3:80, 10.0.0.0/30, 2001:db8::52:0:1, \
[2001:db8::52:0:2]:443, [2001:db8::52:0:3]:80, 2002:db8:a::45/64";
const GO_ROWS: &[(&str, bool, &str)] = &[
("localhost", true, "hardcoded in Go, HARD_BYPASS here"),
("127.0.0.1", true, "loopback"),
("127.0.0.2", true, "loopback, and 127.0.0.0/8 covers it"),
(
"[::1]",
true,
"loopback, bracketed as a URL authority carries it",
),
("[::2]", false, "not a loopback address"),
("192.168.1.1", true, "matches exact IPv4"),
("192.168.1.2", false, "ports do not match"),
("192.168.1.3", true, "matches exact IPv4:port"),
("192.168.1.4", false, "no match"),
("10.0.0.2", true, "matches IPv4/CIDR"),
("[2001:db8::52:0:1]", true, "matches exact IPv6"),
("[2001:db8::52:0:2]", false, "no match"),
("[2001:db8::52:0:3]", true, "matches exact [IPv6]:port"),
("[2002:db8:a::123]", true, "matches IPv6/CIDR"),
("[fe80::424b:c8be:1643:a1b6]", false, "no match"),
("barbaz.net", false, "does not match as .barbaz.net"),
("www.barbaz.net", true, "does match as .barbaz.net"),
("foobar.com", true, "does match as foobar.com"),
(
"www.foobar.com",
true,
"match because no_proxy includes foobar.com",
),
("foofoobar.com", false, "not match as a part of foobar.com"),
("baz.com", false, "not match as a part of barbaz.com"),
("localhost.net", false, "not match as suffix of address"),
("local.localhost", false, "not match as prefix as address"),
("barbarbaz.net", false, "not match, wrong domain"),
("wildcard.io", false, "does not match as *.wildcard.io"),
("nested.wildcard.io", true, "match as *.wildcard.io"),
("awildcard.io", false, "not a match because of '*'"),
];
#[test]
fn go_use_proxy_table() {
let (matcher, warnings) = NoProxyMatcher::new(GO_NO_PROXY);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
for (host, want, why) in GO_ROWS {
assert_eq!(
matcher.matches(host, 80),
*want,
"matches({host}, 80) -- {why}"
);
}
}
#[test]
fn wildcard_bypasses_everything() {
let (matcher, warnings) = NoProxyMatcher::new("*");
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
for (host, _, _) in GO_ROWS {
assert!(matcher.matches(host, 80), "* must bypass {host}");
}
assert!(matcher.matches("anything.example", 9999));
}
#[test]
fn portless_entry_is_reported_not_applied() {
let (matcher, warnings) = NoProxyMatcher::new(":1");
assert!(!matcher.matches("example.com", 80));
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("no host"), "{warnings:?}");
}
#[test]
fn loopback_bypasses_with_an_empty_user_list() {
let (matcher, warnings) = NoProxyMatcher::new("");
assert!(warnings.is_empty(), "{warnings:?}");
assert!(matcher.matches("127.0.0.1", 443));
assert!(matcher.matches("127.0.0.1", 8080));
assert!(matcher.matches("127.0.0.53", 1));
assert!(matcher.matches("[::1]", 443));
assert!(matcher.matches("::1", 443));
assert!(matcher.matches("localhost", 7443));
assert!(matcher.matches("LOCALHOST", 7443));
}
#[test]
fn loopback_bypasses_alongside_an_unrelated_user_entry() {
let (matcher, warnings) = NoProxyMatcher::new("internal.example.com");
assert!(warnings.is_empty(), "{warnings:?}");
assert!(matcher.matches("127.0.0.1", 4317));
assert!(matcher.matches("[::1]", 4317));
assert!(matcher.matches("localhost", 7443));
assert!(matcher.matches("internal.example.com", 443));
}
#[test]
fn a_non_loopback_host_is_not_bypassed_with_an_empty_list() {
let (matcher, warnings) = NoProxyMatcher::new("");
assert!(warnings.is_empty(), "{warnings:?}");
assert!(!matcher.matches("api.openlatch.ai", 443));
assert!(!matcher.matches("10.0.0.1", 443));
assert!(!matcher.matches("[2001:db8::1]", 443));
assert!(!matcher.matches("localhost.example.com", 443));
}
#[test]
fn user_input_cannot_remove_the_hard_bypass() {
for list in ["", "example.com", "10.0.0.0/8", " , , ", ".foo.com:8080"] {
let (matcher, _) = NoProxyMatcher::new(list);
assert!(matcher.matches("127.0.0.1", 1), "list {list:?}");
assert!(matcher.matches("[::1]", 1), "list {list:?}");
assert!(matcher.matches("localhost", 1), "list {list:?}");
}
}
#[test]
fn bare_dotted_and_star_forms() {
let (bare, _) = NoProxyMatcher::new("foo.com");
assert!(bare.matches("foo.com", 443));
assert!(bare.matches("bar.foo.com", 443));
assert!(bare.matches("a.b.foo.com", 443));
assert!(!bare.matches("barfoo.com", 443));
assert!(!bare.matches("foo.com.evil.net", 443));
let (dotted, _) = NoProxyMatcher::new(".foo.com");
assert!(!dotted.matches("foo.com", 443));
assert!(dotted.matches("bar.foo.com", 443));
let (star, _) = NoProxyMatcher::new("*.foo.com");
assert!(!star.matches("foo.com", 443));
assert!(star.matches("bar.foo.com", 443));
}
#[test]
fn a_bare_host_entry_matches_any_port() {
let (bare, _) = NoProxyMatcher::new("foo.com, 10.1.2.3");
assert!(bare.matches("foo.com", 80));
assert!(bare.matches("foo.com", 65535));
assert!(bare.matches("10.1.2.3", 1));
let (pinned, _) = NoProxyMatcher::new("foo.com:8080, 10.1.2.3:9000");
assert!(pinned.matches("foo.com", 8080));
assert!(!pinned.matches("foo.com", 8081));
assert!(pinned.matches("10.1.2.3", 9000));
assert!(!pinned.matches("10.1.2.3", 9001));
}
#[test]
fn ipv6_brackets_are_stripped_on_the_host_side_only() {
let (matcher, warnings) = NoProxyMatcher::new("fe80::1, [2001:db8::2]:8443, fe80::/10");
assert!(warnings.is_empty(), "{warnings:?}");
assert!(matcher.matches("[fe80::1]", 443));
assert!(matcher.matches("fe80::1", 443));
assert!(matcher.matches("[2001:db8::2]", 8443));
assert!(!matcher.matches("[2001:db8::2]", 8444));
assert!(matcher.matches("[fe80::abcd]", 443));
assert!(!matcher.matches("[2001:db8::3]", 443));
}
#[test]
fn cidr_blocks() {
let (matcher, warnings) = NoProxyMatcher::new("10.0.0.0/8, 172.16.5.9/16, fe80::/10");
assert!(warnings.is_empty(), "{warnings:?}");
assert!(matcher.matches("10.255.255.254", 443));
assert!(!matcher.matches("11.0.0.1", 443));
assert!(matcher.matches("172.16.99.1", 443));
assert!(!matcher.matches("172.17.0.1", 443));
assert!(matcher.matches("[fe80::1]", 443));
assert!(!matcher.matches("[fec0::1]", 443));
assert!(matcher.matches("10.0.0.0", 443));
assert!(!matcher.matches("ten.example.com", 443));
}
#[test]
fn ipv4_mapped_hosts_fold_to_ipv4() {
let (matcher, _) = NoProxyMatcher::new("");
assert!(matcher.matches("[::ffff:127.0.0.1]", 443));
assert!(!matcher.matches("[::ffff:8.8.8.8]", 443));
}
#[test]
fn matching_is_case_insensitive() {
let (matcher, _) = NoProxyMatcher::new("Internal.Corp, .Cache.Corp");
assert!(matcher.matches("INTERNAL.CORP", 443));
assert!(matcher.matches("api.Internal.Corp", 443));
assert!(matcher.matches("NODE.cache.corp", 443));
}
#[test]
fn blank_entries_are_skipped_silently() {
let (matcher, warnings) = NoProxyMatcher::new(" , foo.com ,, ,");
assert!(warnings.is_empty(), "{warnings:?}");
assert!(matcher.matches("foo.com", 443));
}
#[test]
fn unparsable_entries_are_reported() {
let cases = [
("10.0.0.0/99", "exceeds"),
("10.0.0.0/abc", "prefix length"),
("not-an-ip/24", "IP address"),
("foo.com:http", "port number"),
("foo.com:99999", "port number"),
("[::1]", "bracketed"),
(":8080", "no host"),
(".", "leading dot"),
];
for (entry, needle) in cases {
let (_, warnings) = NoProxyMatcher::new(entry);
assert_eq!(warnings.len(), 1, "entry {entry:?} -> {warnings:?}");
assert!(
warnings[0].contains(needle),
"entry {entry:?} -> {warnings:?}"
);
}
}
#[test]
fn a_bad_entry_does_not_invalidate_the_list() {
let (matcher, warnings) = NoProxyMatcher::new("foo.com, 10.0.0.0/99, bar.com");
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(matcher.matches("foo.com", 443));
assert!(matcher.matches("bar.com", 443));
assert!(matcher.matches("127.0.0.1", 443));
}
#[test]
fn has_non_loopback_entry() {
for list in [
"",
" ",
",,",
"127.0.0.1",
"::1",
"127.0.0.0/8",
"localhost",
"10.0.0.0/99",
] {
let (matcher, _) = NoProxyMatcher::new(list);
assert!(
!matcher.has_non_loopback_entry(),
"list {list:?} adds nothing beyond loopback"
);
}
for list in [
"*",
"example.com",
"10.0.0.0/8",
"0.0.0.0/0",
"::/0",
"127.0.0.0/4",
] {
let (matcher, _) = NoProxyMatcher::new(list);
assert!(
matcher.has_non_loopback_entry(),
"list {list:?} reaches past loopback"
);
}
}
#[test]
fn hostile_input_never_panics() {
let long = "a".repeat(4096);
let lists = [
"*",
"**",
"*.",
".",
"..",
":",
"::",
":::",
"[",
"]",
"[]",
"[]:",
"[]:80",
"[::1",
"::1]",
"/",
"//",
"/32",
"10.0.0.0/",
"/24",
"a:b:c",
"-",
"%",
"\u{1f600}",
"\u{1f600}.com",
"foo..com",
"foo.com:",
"foo.com::80",
"0.0.0.0/0",
"::/0",
long.as_str(),
];
for list in lists {
let (matcher, _) = NoProxyMatcher::new(list);
for host in [
"",
"foo.com",
"127.0.0.1",
"[::1]",
"[",
"]",
"::",
"\u{1f600}",
] {
let _ = matcher.matches(host, 0);
let _ = matcher.matches(host, 65535);
}
}
}
}