Summary
With the client-proxy-system feature on Windows, Matcher::from_system() reads the WinINET ProxyOverride bypass list but loses its IP-wildcard semantics: entries like 127.* / 192.168.* survive translation as domain rules, and NoProxy::contains() only consults the IP matcher for IP-literal hosts — so they never match. <local> is also carried through as a literal domain and never matches anything.
The practical consequence: on a very common configuration (Windows + Clash/V2Ray-style proxies, whose default bypass list is exactly localhost;127.*;192.168.*;10.*;172.16.*;…;<local>), loopback dials to http://127.0.0.1:… or http://[::1]:… are routed through the user's proxy, while http://localhost:… goes direct. Applications talking to local services (dev servers, local inference runtimes, IPC-over-HTTP) silently detour through the proxy — or fail outright when the proxy rejects/mishandles loopback CONNECTs — depending on nothing but which host spelling they used.
Reproduction
Registry state (set by Clash with "system proxy" enabled — real values from the machine used to verify):
HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings
ProxyEnable = 1
ProxyServer = 127.0.0.1:7897
ProxyOverride = localhost;127.*;192.168.*;10.*;172.16.*;…;172.31.*;<local>
Probe (hyper-util 0.1.20, all *_PROXY/NO_PROXY env vars removed first so the registry path is actually exercised):
use hyper_util::client::proxy::matcher::Matcher;
let m = Matcher::from_system();
for dst in [
"http://127.0.0.1:11434/",
"http://localhost:11434/",
"http://[::1]:9700/",
"http://192.168.1.5:8080/",
"https://example.com/",
] {
let uri: http::Uri = dst.parse().unwrap();
println!("{dst} -> {:?}", m.intercept(&uri).map(|i| i.uri().to_string()));
}
Actual output:
http://127.0.0.1:11434/ -> Some("http://127.0.0.1:7897/") // expected: None (bypassed by 127.*)
http://localhost:11434/ -> None // ok (exact domain entry)
http://[::1]:9700/ -> Some("http://127.0.0.1:7897/") // expected: None (<local> / loopback)
http://192.168.1.5:8080/ -> Some("http://127.0.0.1:7897/") // expected: None (bypassed by 192.168.*)
https://example.com/ -> Some("http://127.0.0.1:7897/") // ok
WinINET itself (and every browser using it) bypasses the proxy for all of the first four.
Root cause
win::with_system translates ProxyOverride by only splitting/joining and stripping *. prefixes:
https://github.com/hyperium/hyper-util/blob/master/src/client/proxy/matcher.rs#L688-L695
builder.no = val
.split(';')
.map(|s| s.trim())
.collect::<Vec<&str>>()
.join(",")
.replace("*.", "");
127.* contains no *. substring, so it reaches NoProxy::from_string unchanged, fails both the IpNet and IpAddr parses, and lands in the domain matcher.
NoProxy::contains() parses 127.0.0.1 as an IpAddr and consults only the IP matcher — the domain entry 127.* is unreachable for IP-literal hosts, and DomainMatcher has no wildcard logic besides leading-dot suffixes and bare * anyway.
<local> (WinINET's "bypass all single-label hosts") is likewise kept as the literal domain <local>, which can never equal a real host.
So the only entries of a typical WinINET bypass list that survive translation are exact hostnames (localhost) and *.example.com-style entries.
Suggested direction
Translate WinINET wildcard patterns into forms NoProxy can already evaluate, e.g.:
127.* → 127.0.0.0/8, 192.168.* → 192.168.0.0/16, 10.* → 10.0.0.0/8 (dotted prefix + * → CIDR with an 8·n-bit mask); patterns that don't fit a clean CIDR could fall back to being dropped as today.
<local> → at minimum localhost,127.0.0.1,::1 (a faithful "no dot in hostname" rule would need a dedicated flag on NoProxy).
Happy to provide more detail from the verification runs if useful. Verified on hyper-util 0.1.20 and current master (translation code unchanged), Windows 11, Clash (Mihomo) as the system proxy.
Summary
With the
client-proxy-systemfeature on Windows,Matcher::from_system()reads the WinINETProxyOverridebypass list but loses its IP-wildcard semantics: entries like127.*/192.168.*survive translation as domain rules, andNoProxy::contains()only consults the IP matcher for IP-literal hosts — so they never match.<local>is also carried through as a literal domain and never matches anything.The practical consequence: on a very common configuration (Windows + Clash/V2Ray-style proxies, whose default bypass list is exactly
localhost;127.*;192.168.*;10.*;172.16.*;…;<local>), loopback dials tohttp://127.0.0.1:…orhttp://[::1]:…are routed through the user's proxy, whilehttp://localhost:…goes direct. Applications talking to local services (dev servers, local inference runtimes, IPC-over-HTTP) silently detour through the proxy — or fail outright when the proxy rejects/mishandles loopback CONNECTs — depending on nothing but which host spelling they used.Reproduction
Registry state (set by Clash with "system proxy" enabled — real values from the machine used to verify):
Probe (hyper-util 0.1.20, all
*_PROXY/NO_PROXYenv vars removed first so the registry path is actually exercised):Actual output:
WinINET itself (and every browser using it) bypasses the proxy for all of the first four.
Root cause
win::with_systemtranslatesProxyOverrideby only splitting/joining and stripping*.prefixes:https://github.com/hyperium/hyper-util/blob/master/src/client/proxy/matcher.rs#L688-L695
127.*contains no*.substring, so it reachesNoProxy::from_stringunchanged, fails both theIpNetandIpAddrparses, and lands in the domain matcher.NoProxy::contains()parses127.0.0.1as anIpAddrand consults only the IP matcher — the domain entry127.*is unreachable for IP-literal hosts, andDomainMatcherhas no wildcard logic besides leading-dot suffixes and bare*anyway.<local>(WinINET's "bypass all single-label hosts") is likewise kept as the literal domain<local>, which can never equal a real host.So the only entries of a typical WinINET bypass list that survive translation are exact hostnames (
localhost) and*.example.com-style entries.Suggested direction
Translate WinINET wildcard patterns into forms
NoProxycan already evaluate, e.g.:127.*→127.0.0.0/8,192.168.*→192.168.0.0/16,10.*→10.0.0.0/8(dotted prefix +*→ CIDR with an 8·n-bit mask); patterns that don't fit a clean CIDR could fall back to being dropped as today.<local>→ at minimumlocalhost,127.0.0.1,::1(a faithful "no dot in hostname" rule would need a dedicated flag onNoProxy).Happy to provide more detail from the verification runs if useful. Verified on hyper-util 0.1.20 and current master (translation code unchanged), Windows 11, Clash (Mihomo) as the system proxy.