Skip to content

hyper-util: system proxy on Windows ignores WinINET ProxyOverride IP wildcards (127.* etc.), loopback dials get proxied #4128

Description

@fmw666

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-clientArea: client.C-bugCategory: bug. Something is wrong. This is bad!E-easyEffort: easy. A task that would be a great starting point for a new contributor.K-hyper-utilCrate: hyper-util

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions