My Claude discovered this.
Fable:
Affects hyper-util 0.1.20 (src/client/legacy/connect/proxy/socks/v5/mod.rs), seen through reqwest 0.13.5 with a socks5:// proxy (local DNS mode).
What happens
reqwest resolves the target locally and builds the destination URI with the address; an IPv6 address is written in brackets, as a URI requires (https://[2606:4700::6810:102]:443). SocksV5 then takes dst.host(), which for an IPv6 authority still carries the brackets ([2606:4700::6810:102]), and does:
let address = match host.parse::<IpAddr>() {
Ok(ip) => Address::Socket(SocketAddr::new(ip, port)),
Err(_) => ... Address::Domain(host, port)
"[2606:4700::6810:102]".parse::<IpAddr>() fails, so the literal goes out as ATYP 0x03 (domain name) with the brackets in the string. A SOCKS5 server that joins host and port (tailscaled's does, with Go's net.JoinHostPort) ends up with [[2606:4700::6810:102]]:443 and fails with "missing port in address". IPv4 literals have no brackets, so they parse and go out as ATYP 0x01.
Fix
Strip the URI brackets before parsing the host as an IP address, so an IPv6 literal goes out as ATYP 0x04:
- let address = match host.parse::<IpAddr>() {
+ let bare = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(&host);
+ let address = match bare.parse::<IpAddr>() {
Ok(ip) => Address::Socket(SocketAddr::new(ip, port)),
A test: a SocksV5 request to https://[::1]:443 must encode ATYP 0x04 with the 16 raw bytes, not ATYP 0x03 with the text [::1].
How it showed up
A Rust server fetching pages through a Tailscale exit node (tailscaled --socks5-server) with reqwest::Proxy::all("socks5://…") and a custom resolver: every host with an AAAA record failed with error sending request (cause: the proxy's "missing port in address"), and hosts with only A records worked. Ordering the resolver's answers IPv4-first works around it.
My Claude discovered this.
Fable:
Affects hyper-util 0.1.20 (
src/client/legacy/connect/proxy/socks/v5/mod.rs), seen through reqwest 0.13.5 with asocks5://proxy (local DNS mode).What happens
reqwest resolves the target locally and builds the destination URI with the address; an IPv6 address is written in brackets, as a URI requires (
https://[2606:4700::6810:102]:443).SocksV5then takesdst.host(), which for an IPv6 authority still carries the brackets ([2606:4700::6810:102]), and does:"[2606:4700::6810:102]".parse::<IpAddr>()fails, so the literal goes out as ATYP 0x03 (domain name) with the brackets in the string. A SOCKS5 server that joins host and port (tailscaled's does, with Go'snet.JoinHostPort) ends up with[[2606:4700::6810:102]]:443and fails with "missing port in address". IPv4 literals have no brackets, so they parse and go out as ATYP 0x01.Fix
Strip the URI brackets before parsing the host as an IP address, so an IPv6 literal goes out as ATYP 0x04:
A test: a
SocksV5request tohttps://[::1]:443must encode ATYP 0x04 with the 16 raw bytes, not ATYP 0x03 with the text[::1].How it showed up
A Rust server fetching pages through a Tailscale exit node (
tailscaled --socks5-server) withreqwest::Proxy::all("socks5://…")and a custom resolver: every host with an AAAA record failed witherror sending request(cause: the proxy's "missing port in address"), and hosts with only A records worked. Ordering the resolver's answers IPv4-first works around it.