From 823633faea1834f2232c9f0e5601a45da42ce36c Mon Sep 17 00:00:00 2001 From: Xuntao Chi Date: Thu, 23 Apr 2026 21:18:49 +0800 Subject: [PATCH 1/2] proxy: honor Windows proxy bypass macros and loopback defaults Parse Windows `ProxyOverride` entries into explicit no-proxy rules while supporting the special `` and `<-loopback>` semantics. By default, preserve the system loopback bypass behavior for `localhost`, `loopback`, `127.0.0.1`, and `::1`, and add tests covering local-name and loopback matching. --- src/client/proxy/matcher.rs | 157 +++++++++++++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 11 deletions(-) diff --git a/src/client/proxy/matcher.rs b/src/client/proxy/matcher.rs index bc488e1..99f4993 100644 --- a/src/client/proxy/matcher.rs +++ b/src/client/proxy/matcher.rs @@ -13,7 +13,7 @@ //! authentication to be used. use std::fmt; -use std::net::IpAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use http::header::HeaderValue; use ipnet::IpNet; @@ -50,6 +50,8 @@ pub struct Builder { http: String, https: String, no: String, + no_local: bool, + no_loopback: bool, } #[derive(Clone)] @@ -66,6 +68,8 @@ enum Auth { struct NoProxy { ips: IpMatcher, domains: DomainMatcher, + local_names: bool, + loopback_hosts: bool, } #[derive(Clone, Debug, Default)] @@ -232,6 +236,8 @@ impl Builder { http: get_first_env(&["HTTP_PROXY", "http_proxy"]), https: get_first_env(&["HTTPS_PROXY", "https_proxy"]), no: get_first_env(&["NO_PROXY", "no_proxy"]), + no_local: false, + no_loopback: false, } } @@ -315,7 +321,9 @@ impl Builder { Matcher { http: parse_env_uri(&self.http).or_else(|| all.clone()), https: parse_env_uri(&self.https).or(all), - no: NoProxy::from_string(&self.no), + no: NoProxy::from_string(&self.no) + .with_local_names(self.no_local) + .with_loopback_hosts(self.no_loopback), } } } @@ -420,6 +428,8 @@ impl NoProxy { NoProxy { ips: IpMatcher(Vec::new()), domains: DomainMatcher(Vec::new()), + local_names: false, + loopback_hosts: false, } } @@ -463,6 +473,8 @@ impl NoProxy { NoProxy { ips: IpMatcher(ips), domains: DomainMatcher(domains), + local_names: false, + loopback_hosts: false, } } @@ -478,16 +490,41 @@ impl NoProxy { }; match host.parse::() { // If we can parse an IP addr, then use it, otherwise, assume it is a domain - Ok(ip) => self.ips.contains(ip), - Err(_) => self.domains.contains(host), + Ok(ip) => self.ips.contains(ip) || self.loopback_hosts && is_loopback_ip(ip), + Err(_) => { + self.loopback_hosts && is_loopback_name(host) + || self.local_names && !host.contains('.') + || self.domains.contains(host) + } } } fn is_empty(&self) -> bool { - self.ips.0.is_empty() && self.domains.0.is_empty() + self.ips.0.is_empty() + && self.domains.0.is_empty() + && !self.local_names + && !self.loopback_hosts + } + + fn with_local_names(mut self, local_names: bool) -> Self { + self.local_names = local_names; + self + } + + fn with_loopback_hosts(mut self, loopback_hosts: bool) -> Self { + self.loopback_hosts = loopback_hosts; + self } } +fn is_loopback_ip(ip: IpAddr) -> bool { + ip == IpAddr::V4(Ipv4Addr::LOCALHOST) || ip == IpAddr::V6(Ipv6Addr::LOCALHOST) +} + +fn is_loopback_name(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") || host.eq_ignore_ascii_case("loopback") +} + impl IpMatcher { fn contains(&self, addr: IpAddr) -> bool { for ip in &self.0 { @@ -662,6 +699,12 @@ mod mac { #[cfg(feature = "client-proxy-system")] #[cfg(windows)] mod win { + struct ProxyOverride { + no: String, + no_local: bool, + no_loopback: bool, + } + pub(super) fn with_system(builder: &mut super::Builder) { let settings = if let Ok(settings) = windows_registry::CURRENT_USER .open("Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings") @@ -685,16 +728,59 @@ mod win { } if builder.no.is_empty() { + builder.no_loopback = true; + if let Ok(val) = settings.get_string("ProxyOverride") { - builder.no = val - .split(';') - .map(|s| s.trim()) - .collect::>() - .join(",") - .replace("*.", ""); + let proxy_override = parse_proxy_override(&val); + builder.no = proxy_override.no; + builder.no_local = proxy_override.no_local; + builder.no_loopback = proxy_override.no_loopback; } } } + + fn parse_proxy_override(val: &str) -> ProxyOverride { + let mut no_local = false; + let mut no_loopback = true; + let no = val + .split(';') + .map(str::trim) + .filter_map(|part| { + if part.is_empty() { + None + } else if part.eq_ignore_ascii_case("") { + no_local = true; + None + } else if part.eq_ignore_ascii_case("<-loopback>") { + no_loopback = false; + None + } else { + Some(part) + } + }) + .collect::>() + .join(",") + .replace("*.", ""); + + ProxyOverride { + no, + no_local, + no_loopback, + } + } + + #[cfg(test)] + mod tests { + #[test] + fn test_parse_proxy_override_macros() { + let rules = + super::parse_proxy_override("*.example.com; ; <-loopback>; 10.0.0.1 ;"); + + assert_eq!(rules.no, "example.com,10.0.0.1"); + assert!(rules.no_local); + assert!(!rules.no_loopback); + } + } } #[cfg(test)] @@ -926,4 +1012,53 @@ mod tests { .intercept(&"http://Www.Example.Com".parse().unwrap()) .is_none()); } + + #[test] + fn test_no_proxy_local_names() { + let mut builder = Matcher::builder(); + builder.all = "http://proxy.local".into(); + builder.no_local = true; + let p = builder.build(); + + assert!(p.intercept(&"http://webserver".parse().unwrap()).is_none()); + assert!(p.intercept(&"http://INTRANET".parse().unwrap()).is_none()); + + assert!(p + .intercept(&"http://webserver.example.com".parse().unwrap()) + .is_some()); + assert!(p.intercept(&"http://10.0.0.1".parse().unwrap()).is_some()); + assert!(p.intercept(&"http://[::1]".parse().unwrap()).is_some()); + } + + #[test] + fn test_no_proxy_loopback_hosts() { + let mut builder = Matcher::builder(); + builder.all = "http://proxy.local".into(); + builder.no_loopback = true; + let p = builder.build(); + + assert!(p.intercept(&"http://127.0.0.1".parse().unwrap()).is_none()); + assert!(p.intercept(&"http://[::1]".parse().unwrap()).is_none()); + assert!(p.intercept(&"http://localhost".parse().unwrap()).is_none()); + assert!(p.intercept(&"http://LOCALHOST".parse().unwrap()).is_none()); + assert!(p.intercept(&"http://loopback".parse().unwrap()).is_none()); + assert!(p.intercept(&"http://LOOPBACK".parse().unwrap()).is_none()); + + assert!(p.intercept(&"http://webserver".parse().unwrap()).is_some()); + assert!(p.intercept(&"http://10.0.0.1".parse().unwrap()).is_some()); + } + + #[test] + fn test_no_proxy_loopback_hosts_disabled() { + let mut builder = Matcher::builder(); + builder.all = "http://proxy.local".into(); + let p = builder.build(); + + assert!(p.intercept(&"http://127.0.0.1".parse().unwrap()).is_some()); + assert!(p.intercept(&"http://[::1]".parse().unwrap()).is_some()); + assert!(p.intercept(&"http://localhost".parse().unwrap()).is_some()); + assert!(p.intercept(&"http://LOCALHOST".parse().unwrap()).is_some()); + assert!(p.intercept(&"http://loopback".parse().unwrap()).is_some()); + assert!(p.intercept(&"http://LOOPBACK".parse().unwrap()).is_some()); + } } From dcb49d7ad531f865405b027f5749c0898491bf9e Mon Sep 17 00:00:00 2001 From: Xuntao Chi Date: Fri, 24 Apr 2026 00:17:07 +0800 Subject: [PATCH 2/2] refactor(proxy): fold the newly added no_loopback flag --- src/client/proxy/matcher.rs | 124 ++++++++++++------------------------ 1 file changed, 39 insertions(+), 85 deletions(-) diff --git a/src/client/proxy/matcher.rs b/src/client/proxy/matcher.rs index 99f4993..76366c4 100644 --- a/src/client/proxy/matcher.rs +++ b/src/client/proxy/matcher.rs @@ -13,7 +13,7 @@ //! authentication to be used. use std::fmt; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::net::IpAddr; use http::header::HeaderValue; use ipnet::IpNet; @@ -51,7 +51,6 @@ pub struct Builder { https: String, no: String, no_local: bool, - no_loopback: bool, } #[derive(Clone)] @@ -69,7 +68,6 @@ struct NoProxy { ips: IpMatcher, domains: DomainMatcher, local_names: bool, - loopback_hosts: bool, } #[derive(Clone, Debug, Default)] @@ -237,7 +235,6 @@ impl Builder { https: get_first_env(&["HTTPS_PROXY", "https_proxy"]), no: get_first_env(&["NO_PROXY", "no_proxy"]), no_local: false, - no_loopback: false, } } @@ -321,9 +318,7 @@ impl Builder { Matcher { http: parse_env_uri(&self.http).or_else(|| all.clone()), https: parse_env_uri(&self.https).or(all), - no: NoProxy::from_string(&self.no) - .with_local_names(self.no_local) - .with_loopback_hosts(self.no_loopback), + no: NoProxy::from_string(&self.no).with_local_names(self.no_local), } } } @@ -429,7 +424,6 @@ impl NoProxy { ips: IpMatcher(Vec::new()), domains: DomainMatcher(Vec::new()), local_names: false, - loopback_hosts: false, } } @@ -474,7 +468,6 @@ impl NoProxy { ips: IpMatcher(ips), domains: DomainMatcher(domains), local_names: false, - loopback_hosts: false, } } @@ -490,39 +483,19 @@ impl NoProxy { }; match host.parse::() { // If we can parse an IP addr, then use it, otherwise, assume it is a domain - Ok(ip) => self.ips.contains(ip) || self.loopback_hosts && is_loopback_ip(ip), - Err(_) => { - self.loopback_hosts && is_loopback_name(host) - || self.local_names && !host.contains('.') - || self.domains.contains(host) - } + Ok(ip) => self.ips.contains(ip), + Err(_) => self.local_names && !host.contains('.') || self.domains.contains(host), } } fn is_empty(&self) -> bool { - self.ips.0.is_empty() - && self.domains.0.is_empty() - && !self.local_names - && !self.loopback_hosts + self.ips.0.is_empty() && self.domains.0.is_empty() && !self.local_names } fn with_local_names(mut self, local_names: bool) -> Self { self.local_names = local_names; self } - - fn with_loopback_hosts(mut self, loopback_hosts: bool) -> Self { - self.loopback_hosts = loopback_hosts; - self - } -} - -fn is_loopback_ip(ip: IpAddr) -> bool { - ip == IpAddr::V4(Ipv4Addr::LOCALHOST) || ip == IpAddr::V6(Ipv6Addr::LOCALHOST) -} - -fn is_loopback_name(host: &str) -> bool { - host.eq_ignore_ascii_case("localhost") || host.eq_ignore_ascii_case("loopback") } impl IpMatcher { @@ -699,10 +672,20 @@ mod mac { #[cfg(feature = "client-proxy-system")] #[cfg(windows)] mod win { + const LOOPBACK_BYPASS: [&str; 4] = ["localhost", "loopback", "127.0.0.1", "::1"]; + struct ProxyOverride { no: String, no_local: bool, - no_loopback: bool, + } + + impl Default for ProxyOverride { + fn default() -> Self { + Self { + no: LOOPBACK_BYPASS.join(","), + no_local: false, + } + } } pub(super) fn with_system(builder: &mut super::Builder) { @@ -728,21 +711,19 @@ mod win { } if builder.no.is_empty() { - builder.no_loopback = true; - - if let Ok(val) = settings.get_string("ProxyOverride") { - let proxy_override = parse_proxy_override(&val); - builder.no = proxy_override.no; - builder.no_local = proxy_override.no_local; - builder.no_loopback = proxy_override.no_loopback; - } + let proxy_override = settings + .get_string("ProxyOverride") + .map(|val| parse_proxy_override(&val)) + .unwrap_or_default(); + builder.no = proxy_override.no; + builder.no_local = proxy_override.no_local; } } fn parse_proxy_override(val: &str) -> ProxyOverride { let mut no_local = false; - let mut no_loopback = true; - let no = val + let mut disable_loopback_bypass = false; + let mut no = val .split(';') .map(str::trim) .filter_map(|part| { @@ -752,21 +733,19 @@ mod win { no_local = true; None } else if part.eq_ignore_ascii_case("<-loopback>") { - no_loopback = false; + disable_loopback_bypass = true; None } else { Some(part) } }) - .collect::>() - .join(",") - .replace("*.", ""); - - ProxyOverride { - no, - no_local, - no_loopback, + .collect::>(); + + if !disable_loopback_bypass { + no.extend(LOOPBACK_BYPASS.iter()); } + + ProxyOverride { no: no.join(",").replace("*.", ""), no_local } } #[cfg(test)] @@ -778,7 +757,14 @@ mod win { assert_eq!(rules.no, "example.com,10.0.0.1"); assert!(rules.no_local); - assert!(!rules.no_loopback); + } + + #[test] + fn test_parse_proxy_override_defaults_loopback() { + let rules = super::parse_proxy_override("*.example.com"); + + assert_eq!(rules.no, "example.com,localhost,loopback,127.0.0.1,::1"); + assert!(!rules.no_local); } } } @@ -1029,36 +1015,4 @@ mod tests { assert!(p.intercept(&"http://10.0.0.1".parse().unwrap()).is_some()); assert!(p.intercept(&"http://[::1]".parse().unwrap()).is_some()); } - - #[test] - fn test_no_proxy_loopback_hosts() { - let mut builder = Matcher::builder(); - builder.all = "http://proxy.local".into(); - builder.no_loopback = true; - let p = builder.build(); - - assert!(p.intercept(&"http://127.0.0.1".parse().unwrap()).is_none()); - assert!(p.intercept(&"http://[::1]".parse().unwrap()).is_none()); - assert!(p.intercept(&"http://localhost".parse().unwrap()).is_none()); - assert!(p.intercept(&"http://LOCALHOST".parse().unwrap()).is_none()); - assert!(p.intercept(&"http://loopback".parse().unwrap()).is_none()); - assert!(p.intercept(&"http://LOOPBACK".parse().unwrap()).is_none()); - - assert!(p.intercept(&"http://webserver".parse().unwrap()).is_some()); - assert!(p.intercept(&"http://10.0.0.1".parse().unwrap()).is_some()); - } - - #[test] - fn test_no_proxy_loopback_hosts_disabled() { - let mut builder = Matcher::builder(); - builder.all = "http://proxy.local".into(); - let p = builder.build(); - - assert!(p.intercept(&"http://127.0.0.1".parse().unwrap()).is_some()); - assert!(p.intercept(&"http://[::1]".parse().unwrap()).is_some()); - assert!(p.intercept(&"http://localhost".parse().unwrap()).is_some()); - assert!(p.intercept(&"http://LOCALHOST".parse().unwrap()).is_some()); - assert!(p.intercept(&"http://loopback".parse().unwrap()).is_some()); - assert!(p.intercept(&"http://LOOPBACK".parse().unwrap()).is_some()); - } }