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

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions