From 6b649b34b08831523edc0e7e9e3cb1127ec46130 Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 8 May 2026 22:07:50 +0900 Subject: [PATCH 01/22] Route proxy-enabled checks through is_proxy_enabled_for_host helper In preparation for NO_PROXY support (#2446), centralize the proxy-enabled decision in a single helper so the upcoming bypass logic can be added in one place rather than to six divergent call sites. The helper's body for now is identical to the existing condition; the host parameter is unused until set_no_proxy() lands. Refactored sites: ClientImpl::create_client_socket ClientImpl::handle_request (HTTP request rewrite) ClientImpl::setup_redirect_client ClientImpl::process_request (SSL is_proxy_enabled flag) SSLClient::setup_proxy_connection SSLClient::ensure_socket_connection The two prepare_default_headers Proxy-Authorization injection blocks (currently gated only on proxy auth credentials being set) are intentionally not wrapped here. Doing so would change behavior in the rare misconfiguration case where credentials are set without set_proxy, so the gating is deferred to the NO_PROXY commit where it becomes meaningful. No behavior change. All 608 unit tests and the 22 squid-backed proxy tests pass. --- httplib.h | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/httplib.h b/httplib.h index d54534c951..95b4b2d60c 100644 --- a/httplib.h +++ b/httplib.h @@ -2265,6 +2265,8 @@ class ClientImpl { std::chrono::time_point start_time, Response &res, bool &success, Error &error); + bool is_proxy_enabled_for_host(const std::string &host) const; + // All of: // shutdown_ssl // shutdown_socket @@ -12333,8 +12335,17 @@ inline void ClientImpl::copy_settings(const ClientImpl &rhs) { #endif } +inline bool +ClientImpl::is_proxy_enabled_for_host(const std::string &host) const { + // The host parameter is unused while NO_PROXY support is not yet wired in; + // it will be consulted once set_no_proxy() lands. Keeping the signature now + // means the call sites do not need to change again later. + (void)host; + return !proxy_host_.empty() && proxy_port_ != -1; +} + inline socket_t ClientImpl::create_client_socket(Error &error) const { - if (!proxy_host_.empty() && proxy_port_ != -1) { + if (is_proxy_enabled_for_host(host_)) { return detail::create_client_socket( proxy_host_, std::string(), proxy_port_, address_family_, tcp_nodelay_, ipv6_v6only_, socket_options_, connection_timeout_sec_, @@ -12945,7 +12956,7 @@ inline bool ClientImpl::handle_request(Stream &strm, Request &req, bool ret; - if (!is_ssl() && !proxy_host_.empty() && proxy_port_ != -1) { + if (!is_ssl() && is_proxy_enabled_for_host(host_)) { auto req2 = req; req2.path = "http://" + detail::make_host_and_port_string(host_, port_, false) + @@ -13153,7 +13164,7 @@ inline void ClientImpl::setup_redirect_client(ClientType &client) { // Setup proxy configuration (CRITICAL ORDER - proxy must be set // before proxy auth) - if (!proxy_host_.empty() && proxy_port_ != -1) { + if (is_proxy_enabled_for_host(host_)) { // First set proxy host and port client.set_proxy(proxy_host_, proxy_port_); @@ -13574,7 +13585,7 @@ inline bool ClientImpl::process_request(Stream &strm, Request &req, #ifdef CPPHTTPLIB_SSL_ENABLED if (is_ssl() && !expect_100_continue) { - auto is_proxy_enabled = !proxy_host_.empty() && proxy_port_ != -1; + auto is_proxy_enabled = is_proxy_enabled_for_host(host_); if (!is_proxy_enabled) { if (tls::is_peer_closed(socket_.ssl, socket_.sock)) { error = Error::SSLPeerCouldBeClosed_; @@ -15617,7 +15628,7 @@ inline bool SSLClient::setup_proxy_connection( Socket &socket, std::chrono::time_point start_time, Response &res, bool &success, Error &error) { - if (proxy_host_.empty() || proxy_port_ == -1) { return true; } + if (!is_proxy_enabled_for_host(host_)) { return true; } if (!connect_with_proxy(socket, start_time, res, success, error)) { return false; @@ -15730,7 +15741,7 @@ inline bool SSLClient::connect_with_proxy( inline bool SSLClient::ensure_socket_connection(Socket &socket, Error &error) { if (!ClientImpl::ensure_socket_connection(socket, error)) { return false; } - if (!proxy_host_.empty() && proxy_port_ != -1) { return true; } + if (is_proxy_enabled_for_host(host_)) { return true; } if (!initialize_ssl(socket, error)) { shutdown_socket(socket); From 91f38e634c49e6e692c474a65f971c6023eacc1b Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 8 May 2026 22:16:53 +0900 Subject: [PATCH 02/22] Add detail::parse_proxy_url with control-char and scheme validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building block for the upcoming set_proxy_from_env (#2446). Parses "http(s)://[user[:pass]@]host[:port][/...]" into a detail::ProxyUrl struct. Rejects: - empty input - any control character (< 0x20 or 0x7F), including CR/LF/NUL — these would otherwise let a malicious env value inject extra header lines into a CONNECT request or Proxy-Authorization header - schemes other than http and https - ports outside [1, 65535] - malformed IPv6 host literals (validated via inet_pton(AF_INET6)) - non-numeric or trailing-garbage port strings Notes: - userinfo is split on the LAST '@' so passwords containing '@' are preserved in the password field - if no port is present, defaults to 80 (http) / 443 (https) - integer parse goes through detail::from_chars to stay compatible with -fno-exceptions builds The helper has no callers yet; it lands consumer-side when set_proxy_from_env arrives. All 608 unit tests pass. --- httplib.h | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/httplib.h b/httplib.h index 95b4b2d60c..48b98b32f5 100644 --- a/httplib.h +++ b/httplib.h @@ -2988,6 +2988,22 @@ struct FileStat { std::string make_host_and_port_string(const std::string &host, int port, bool is_ssl); +// Parsed representation of an HTTP(S) proxy URL. +struct ProxyUrl { + std::string scheme; // "http" or "https" + std::string host; // bracket-stripped for IPv6 literals + int port = -1; + std::string username; // empty if absent + std::string password; // empty if absent +}; + +// Parses "http(s)://[user[:pass]@]host[:port][/...]" into out. Rejects +// any input containing CR, LF, NUL, or other control characters; rejects +// schemes other than http/https; rejects ports outside [1, 65535]; for +// IPv6 literals (in [...]) requires the address to parse via inet_pton. +// When the port is omitted, defaults to 80 (http) / 443 (https). +bool parse_proxy_url(const std::string &url, ProxyUrl &out); + std::string trim_copy(const std::string &s); void divide( @@ -10515,6 +10531,103 @@ make_host_and_port_string_always_port(const std::string &host, int port) { return prepare_host_string(host) + ":" + std::to_string(port); } +inline bool parse_proxy_url(const std::string &url, ProxyUrl &out) { + if (url.empty()) { return false; } + + // Reject control characters anywhere in the input. CRLF/NUL would let a + // malicious env value inject extra header lines into a CONNECT request or + // a Proxy-Authorization header. + for (auto c : url) { + auto uc = static_cast(c); + if (uc < 0x20 || uc == 0x7F) { return false; } + } + + // Scheme: only http and https are supported. + std::size_t scheme_end = 0; + if (url.compare(0, 7, "http://") == 0) { + out.scheme = "http"; + scheme_end = 7; + } else if (url.compare(0, 8, "https://") == 0) { + out.scheme = "https"; + scheme_end = 8; + } else { + return false; + } + + // Authority terminates at the first '/', '?', or '#', or the end of input. + auto authority_end = url.find_first_of("/?#", scheme_end); + if (authority_end == std::string::npos) { authority_end = url.size(); } + auto authority = url.substr(scheme_end, authority_end - scheme_end); + if (authority.empty()) { return false; } + + // Split userinfo from host_port on the LAST '@' so passwords containing '@' + // are preserved. + std::string userinfo; + std::string host_port; + auto at_pos = authority.rfind('@'); + if (at_pos != std::string::npos) { + userinfo = authority.substr(0, at_pos); + host_port = authority.substr(at_pos + 1); + } else { + host_port = authority; + } + if (host_port.empty()) { return false; } + + if (!userinfo.empty()) { + auto colon = userinfo.find(':'); + if (colon == std::string::npos) { + out.username = userinfo; + } else { + out.username = userinfo.substr(0, colon); + out.password = userinfo.substr(colon + 1); + } + } + + // host_port: "[ipv6]:port", "[ipv6]", "host:port", or "host". + std::string host; + std::string port_str; + if (host_port.front() == '[') { + auto rb = host_port.find(']'); + if (rb == std::string::npos) { return false; } + host = host_port.substr(1, rb - 1); + if (host.empty()) { return false; } + struct in6_addr tmp; + if (inet_pton(AF_INET6, host.c_str(), &tmp) != 1) { return false; } + auto rest = host_port.substr(rb + 1); + if (!rest.empty()) { + if (rest.front() != ':') { return false; } + port_str = rest.substr(1); + if (port_str.empty()) { return false; } + } + } else { + auto colon = host_port.find(':'); + if (colon == std::string::npos) { + host = host_port; + } else { + host = host_port.substr(0, colon); + port_str = host_port.substr(colon + 1); + if (port_str.empty()) { return false; } + } + if (host.empty()) { return false; } + } + out.host = std::move(host); + + if (port_str.empty()) { + out.port = (out.scheme == "https") ? 443 : 80; + } else { + int port = 0; + auto r = + from_chars(port_str.data(), port_str.data() + port_str.size(), port); + if (r.ec != std::errc{} || r.ptr != port_str.data() + port_str.size()) { + return false; + } + if (port < 1 || port > 65535) { return false; } + out.port = port; + } + + return true; +} + template inline bool check_and_write_headers(Stream &strm, Headers &headers, T header_writer, Error &error) { From 7001ceb397b50d77dc8b835cd6f8ece8bbbc2777 Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 8 May 2026 22:48:53 +0900 Subject: [PATCH 03/22] Add NO_PROXY parsing and matching helpers in detail namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building blocks for the upcoming Client::set_no_proxy (#2446): - NoProxyEntry / NoProxyKind: parsed list entry (wildcard, hostname suffix, IPv4 CIDR, IPv6 CIDR) - NormalizedTarget: pre-normalized form of the connection's target host (lowercase, brackets stripped, trailing dot stripped, with inet_pton already attempted) - parse_no_proxy_entry / parse_no_proxy_list: token / list parsing. Port-specific entries are rejected by design — cpp-httplib's other host-keyed APIs (e.g. set_hostname_addr_map) are hostname-only, so supporting host:port for NO_PROXY alone would be inconsistent. - ipv4_in_cidr / ipv6_in_cidr: CIDR membership. IPv4 special-cases prefix=0 to avoid the (1u << 32) shift UB. IPv6 uses byte-wise memcmp plus a masked partial-byte compare. - normalize_target: prepares the target host for matching. Routes every IP literal through inet_pton so "127.0.0.1" vs "127.000.000.001" vs decimal-form integers cannot be used to bypass a NO_PROXY entry via alternate string forms. - host_matches_no_proxy: matches a normalized target against an entry list. Hostname suffix matching uses a dot-boundary rule so "evilexample.com" does NOT match the entry "example.com". IPv4 and IPv6 entries match only their own address family — IPv4-mapped IPv6 ("::ffff:1.2.3.4") is not cross-matched against IPv4 entries. These helpers have no callers yet; they land consumer-side in the upcoming set_no_proxy / set_proxy_from_env commits. All 608 unit tests pass. --- httplib.h | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) diff --git a/httplib.h b/httplib.h index 48b98b32f5..c837ab6be7 100644 --- a/httplib.h +++ b/httplib.h @@ -3004,6 +3004,62 @@ struct ProxyUrl { // When the port is omitted, defaults to 80 (http) / 443 (https). bool parse_proxy_url(const std::string &url, ProxyUrl &out); +// One parsed NO_PROXY list entry. +enum class NoProxyKind { + Wildcard, // "*" + HostnameSuffix, // "example.com" or ".example.com" + IPv4Cidr, // "10.0.0.0/8" (or single IP, treated as /32) + IPv6Cidr, // "fe80::/10" (or single IP, treated as /128) +}; + +struct NoProxyEntry { + NoProxyKind kind = NoProxyKind::Wildcard; + std::string hostname_pattern; // lowercased, leading/trailing dot stripped + struct in_addr v4_net {}; + struct in6_addr v6_net {}; + int prefix_bits = 0; +}; + +// Parses a single NO_PROXY token (already trimmed, non-empty). Returns +// false on any malformed input. Port-specific entries ("host:port") are +// rejected by design: cpp-httplib's other host-keyed APIs (e.g. +// set_hostname_addr_map) are also keyed on hostname only, so supporting +// port granularity for NO_PROXY alone would be inconsistent. +bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out); + +// Parses a comma-separated NO_PROXY value into a vector of entries. +// Malformed tokens are silently dropped (matching curl/Go behavior; +// failing loud on a single bad entry would break working deployments). +std::vector parse_no_proxy_list(const std::string &value); + +// Pre-parsed form of the connection's target host, ready for matching +// against a NoProxyEntry list without re-running inet_pton on every entry. +struct NormalizedTarget { + std::string hostname; // lowercase; brackets and trailing dot removed + bool is_ipv4 = false; + bool is_ipv6 = false; + struct in_addr v4 {}; + struct in6_addr v6 {}; +}; + +NormalizedTarget normalize_target(const std::string &host); + +// CIDR membership tests. +// prefix_bits in [0, 32] for IPv4, [0, 128] for IPv6. +// prefix_bits == 0 always returns true for the matching family. +bool ipv4_in_cidr(const struct in_addr &ip, const struct in_addr &net, + int prefix_bits); +bool ipv6_in_cidr(const struct in6_addr &ip, const struct in6_addr &net, + int prefix_bits); + +// Returns true if the (already normalized) target matches any NO_PROXY +// entry. Hostname suffix matching uses the dot-boundary rule so that +// "evilexample.com" does NOT match "example.com". IPv4 and IPv6 entries +// match only their own address family; "::ffff:1.2.3.4" is not +// cross-matched against IPv4 entries. +bool host_matches_no_proxy(const NormalizedTarget &target, + const std::vector &entries); + std::string trim_copy(const std::string &s); void divide( @@ -10628,6 +10684,180 @@ inline bool parse_proxy_url(const std::string &url, ProxyUrl &out) { return true; } +inline bool ipv4_in_cidr(const struct in_addr &ip, const struct in_addr &net, + int prefix_bits) { + if (prefix_bits < 0 || prefix_bits > 32) { return false; } + // Special-case prefix=0 to avoid undefined behavior of (1u << 32). + if (prefix_bits == 0) { return true; } + uint32_t mask = htonl(0xFFFFFFFFu << (32 - prefix_bits)); + return (ip.s_addr & mask) == (net.s_addr & mask); +} + +inline bool ipv6_in_cidr(const struct in6_addr &ip, const struct in6_addr &net, + int prefix_bits) { + if (prefix_bits < 0 || prefix_bits > 128) { return false; } + if (prefix_bits == 0) { return true; } + int full_bytes = prefix_bits / 8; + int rem_bits = prefix_bits % 8; + if (full_bytes > 0 && std::memcmp(ip.s6_addr, net.s6_addr, + static_cast(full_bytes)) != 0) { + return false; + } + if (rem_bits == 0) { return true; } + auto mask = static_cast(0xFFu << (8 - rem_bits)); + return (ip.s6_addr[full_bytes] & mask) == (net.s6_addr[full_bytes] & mask); +} + +inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { + if (token.empty()) { return false; } + + if (token == "*") { + out.kind = NoProxyKind::Wildcard; + return true; + } + + // Split on '/' for optional CIDR prefix. + auto slash = token.find('/'); + std::string addr_part = + (slash == std::string::npos) ? token : token.substr(0, slash); + std::string prefix_part = + (slash == std::string::npos) ? std::string() : token.substr(slash + 1); + + // Try IPv4. + struct in_addr v4; + if (inet_pton(AF_INET, addr_part.c_str(), &v4) == 1) { + int prefix = 32; + if (!prefix_part.empty()) { + auto r = from_chars(prefix_part.data(), + prefix_part.data() + prefix_part.size(), prefix); + if (r.ec != std::errc{} || + r.ptr != prefix_part.data() + prefix_part.size()) { + return false; + } + if (prefix < 0 || prefix > 32) { return false; } + } + out.kind = NoProxyKind::IPv4Cidr; + out.v4_net = v4; + out.prefix_bits = prefix; + return true; + } + + // Try IPv6. + struct in6_addr v6; + if (inet_pton(AF_INET6, addr_part.c_str(), &v6) == 1) { + int prefix = 128; + if (!prefix_part.empty()) { + auto r = from_chars(prefix_part.data(), + prefix_part.data() + prefix_part.size(), prefix); + if (r.ec != std::errc{} || + r.ptr != prefix_part.data() + prefix_part.size()) { + return false; + } + if (prefix < 0 || prefix > 128) { return false; } + } + out.kind = NoProxyKind::IPv6Cidr; + out.v6_net = v6; + out.prefix_bits = prefix; + return true; + } + + // Not an IP. A '/' here means a prefix on a non-IP entry, which is invalid. + if (slash != std::string::npos) { return false; } + + // ':' in a non-IP token is a port-specific entry, which we don't support. + if (token.find(':') != std::string::npos) { return false; } + + // Hostname suffix. Lowercase, strip leading/trailing dots. + std::string hostname = case_ignore::to_lower(token); + while (!hostname.empty() && hostname.front() == '.') { + hostname.erase(hostname.begin()); + } + while (!hostname.empty() && hostname.back() == '.') { + hostname.pop_back(); + } + if (hostname.empty()) { return false; } + + out.kind = NoProxyKind::HostnameSuffix; + out.hostname_pattern = std::move(hostname); + return true; +} + +inline std::vector parse_no_proxy_list(const std::string &value) { + std::vector entries; + if (value.empty()) { return entries; } + split(value.data(), value.data() + value.size(), ',', + [&](const char *b, const char *e) { + auto token = trim_copy(std::string(b, e)); + if (token.empty()) { return; } + NoProxyEntry entry; + if (parse_no_proxy_entry(token, entry)) { + entries.push_back(std::move(entry)); + } + }); + return entries; +} + +inline NormalizedTarget normalize_target(const std::string &host) { + NormalizedTarget t; + std::string h = host; + + // Strip surrounding brackets if present (IPv6 literal in URL form). + if (h.size() >= 2 && h.front() == '[' && h.back() == ']') { + h = h.substr(1, h.size() - 2); + } + + // Strip a single trailing dot (FQDN canonicalization). + if (!h.empty() && h.back() == '.') { h.pop_back(); } + + t.hostname = case_ignore::to_lower(h); + + if (!t.hostname.empty()) { + if (inet_pton(AF_INET, t.hostname.c_str(), &t.v4) == 1) { + t.is_ipv4 = true; + } else if (inet_pton(AF_INET6, t.hostname.c_str(), &t.v6) == 1) { + t.is_ipv6 = true; + } + } + return t; +} + +inline bool host_matches_no_proxy(const NormalizedTarget &target, + const std::vector &entries) { + if (target.hostname.empty()) { return false; } + for (const auto &e : entries) { + switch (e.kind) { + case NoProxyKind::Wildcard: return true; + case NoProxyKind::IPv4Cidr: + if (target.is_ipv4 && ipv4_in_cidr(target.v4, e.v4_net, e.prefix_bits)) { + return true; + } + break; + case NoProxyKind::IPv6Cidr: + if (target.is_ipv6 && ipv6_in_cidr(target.v6, e.v6_net, e.prefix_bits)) { + return true; + } + break; + case NoProxyKind::HostnameSuffix: + // IP targets do not match hostname patterns. + if (target.is_ipv4 || target.is_ipv6) { break; } + // Exact match. + if (target.hostname == e.hostname_pattern) { return true; } + // Dot-boundary suffix match: target ends with "." + pattern. This is + // what prevents "evilexample.com" from matching "example.com". + if (target.hostname.size() > e.hostname_pattern.size() + 1) { + auto offset = target.hostname.size() - e.hostname_pattern.size(); + if (target.hostname[offset - 1] == '.' && + target.hostname.compare(offset, e.hostname_pattern.size(), + e.hostname_pattern) == 0) { + return true; + } + } + break; + } + } + return false; +} + template inline bool check_and_write_headers(Stream &strm, Headers &headers, T header_writer, Error &error) { From d38753d7887c48b276d11e99582746b0ff858953 Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 8 May 2026 23:03:08 +0900 Subject: [PATCH 04/22] Add Client::set_no_proxy and wire NO_PROXY into proxy decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the user-facing half of #2446 (set_proxy_from_env follows in the next commit). When a NO_PROXY pattern matches the target host, the client now bypasses the configured proxy and the corresponding Proxy-Authorization header is suppressed. Public API: - Client::set_no_proxy(const std::vector &patterns) Patterns: "*", hostname suffix (e.g. "example.com" or ".example.com"), IPv4/IPv6 CIDR (e.g. "10.0.0.0/8", "fe80::/10"), or single IP literals. Replaces any previous list. Malformed entries are silently dropped. Internals: - is_proxy_enabled_for_host now consults no_proxy_entries_, normalizing the target through inet_pton so leading-zero or alternate-form IPs cannot be used to bypass an entry. - prepare_default_headers gates both Proxy-Authorization injection blocks (basic and bearer) on is_proxy_enabled_for_host(host_). Previously, Proxy-Authorization was sent whenever proxy auth credentials were configured, even when the request was going direct to the target. With NO_PROXY now in play, that path would leak proxy credentials to the destination server — analog of the redirect-leak class of bugs (cf. CVE-2023-32681 in Python requests, GHSA-6hrp-7fq9-3qv2 in cpp-httplib). - setup_redirect_client now takes the redirect target host as a parameter and re-evaluates is_proxy_enabled_for_host against it. no_proxy_entries_ is always copied to the redirect client so the bypass policy follows across redirects. This is the cross-origin leak surface that GHSA-c3h8-fqq4-xm4g lives in; centralizing the decision through is_proxy_enabled_for_host removes the chance of branch divergence. - copy_settings copies no_proxy_entries_. The slight behavior change for the rare misconfiguration "set proxy_basic_auth without set_proxy" — Proxy-Authorization is no longer sent in that case — is deliberate. The header has no addressee when the proxy is unset. All 608 unit tests and 22 squid-backed proxy integration tests pass. --- httplib.h | 159 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 104 insertions(+), 55 deletions(-) diff --git a/httplib.h b/httplib.h index c837ab6be7..3e57c6fe1e 100644 --- a/httplib.h +++ b/httplib.h @@ -2024,6 +2024,41 @@ inline ssize_t read_body_content(Stream *stream, BodyReader &br, char *buf, class decompressor; +// Parsed representation of an HTTP(S) proxy URL. +struct ProxyUrl { + std::string scheme; // "http" or "https" + std::string host; // bracket-stripped for IPv6 literals + int port = -1; + std::string username; // empty if absent + std::string password; // empty if absent +}; + +// One parsed NO_PROXY list entry. +enum class NoProxyKind { + Wildcard, // "*" + HostnameSuffix, // "example.com" or ".example.com" + IPv4Cidr, // "10.0.0.0/8" (or single IP, treated as /32) + IPv6Cidr, // "fe80::/10" (or single IP, treated as /128) +}; + +struct NoProxyEntry { + NoProxyKind kind = NoProxyKind::Wildcard; + std::string hostname_pattern; // lowercased, leading/trailing dot stripped + struct in_addr v4_net {}; + struct in6_addr v6_net {}; + int prefix_bits = 0; +}; + +// Pre-parsed form of the connection's target host, ready for matching +// against a NoProxyEntry list without re-running inet_pton on every entry. +struct NormalizedTarget { + std::string hostname; // lowercase; brackets and trailing dot removed + bool is_ipv4 = false; + bool is_ipv6 = false; + struct in_addr v4 {}; + struct in6_addr v6 {}; +}; + } // namespace detail class ClientImpl { @@ -2241,6 +2276,20 @@ class ClientImpl { const std::string &password); void set_proxy_bearer_token_auth(const std::string &token); + // Configures the NO_PROXY bypass list. Each pattern is one of: + // "*" — bypass the proxy for every target + // "example.com" — match example.com and any subdomain (with the + // dot-boundary rule, so "evilexample.com" does + // NOT match) + // ".example.com" — equivalent (leading dot is informational) + // "10.0.0.0/8" — IPv4 CIDR block (or single IP, treated as /32) + // "fe80::/10" — IPv6 CIDR block (or single IP, treated as /128) + // Hostname matching is case-insensitive. IP comparisons are normalized + // through inet_pton so "127.0.0.1" cannot be bypassed via alternate + // string forms. Malformed entries are silently dropped. Calling this + // method replaces any previously configured list; there is no append. + void set_no_proxy(const std::vector &patterns); + void set_logger(Logger logger); void set_error_logger(ErrorLogger error_logger); @@ -2354,6 +2403,8 @@ class ClientImpl { std::string proxy_basic_auth_password_; std::string proxy_bearer_token_auth_token_; + std::vector no_proxy_entries_; + mutable std::mutex logger_mutex_; Logger logger_; ErrorLogger error_logger_; @@ -2375,7 +2426,8 @@ class ClientImpl { const std::string &host, int port, Request &req, Response &res, const std::string &path, const std::string &location, Error &error); - template void setup_redirect_client(ClientType &client); + template + void setup_redirect_client(ClientType &client, const std::string &next_host); bool handle_request(Stream &strm, Request &req, Response &res, bool close_connection, Error &error); std::unique_ptr send_with_content_provider_and_receiver( @@ -2614,6 +2666,7 @@ class Client { void set_proxy_basic_auth(const std::string &username, const std::string &password); void set_proxy_bearer_token_auth(const std::string &token); + void set_no_proxy(const std::vector &patterns); void set_logger(Logger logger); void set_error_logger(ErrorLogger error_logger); @@ -2988,15 +3041,6 @@ struct FileStat { std::string make_host_and_port_string(const std::string &host, int port, bool is_ssl); -// Parsed representation of an HTTP(S) proxy URL. -struct ProxyUrl { - std::string scheme; // "http" or "https" - std::string host; // bracket-stripped for IPv6 literals - int port = -1; - std::string username; // empty if absent - std::string password; // empty if absent -}; - // Parses "http(s)://[user[:pass]@]host[:port][/...]" into out. Rejects // any input containing CR, LF, NUL, or other control characters; rejects // schemes other than http/https; rejects ports outside [1, 65535]; for @@ -3004,22 +3048,6 @@ struct ProxyUrl { // When the port is omitted, defaults to 80 (http) / 443 (https). bool parse_proxy_url(const std::string &url, ProxyUrl &out); -// One parsed NO_PROXY list entry. -enum class NoProxyKind { - Wildcard, // "*" - HostnameSuffix, // "example.com" or ".example.com" - IPv4Cidr, // "10.0.0.0/8" (or single IP, treated as /32) - IPv6Cidr, // "fe80::/10" (or single IP, treated as /128) -}; - -struct NoProxyEntry { - NoProxyKind kind = NoProxyKind::Wildcard; - std::string hostname_pattern; // lowercased, leading/trailing dot stripped - struct in_addr v4_net {}; - struct in6_addr v6_net {}; - int prefix_bits = 0; -}; - // Parses a single NO_PROXY token (already trimmed, non-empty). Returns // false on any malformed input. Port-specific entries ("host:port") are // rejected by design: cpp-httplib's other host-keyed APIs (e.g. @@ -3032,16 +3060,6 @@ bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out); // failing loud on a single bad entry would break working deployments). std::vector parse_no_proxy_list(const std::string &value); -// Pre-parsed form of the connection's target host, ready for matching -// against a NoProxyEntry list without re-running inet_pton on every entry. -struct NormalizedTarget { - std::string hostname; // lowercase; brackets and trailing dot removed - bool is_ipv4 = false; - bool is_ipv6 = false; - struct in_addr v4 {}; - struct in6_addr v6 {}; -}; - NormalizedTarget normalize_target(const std::string &host); // CIDR membership tests. @@ -12663,6 +12681,7 @@ inline void ClientImpl::copy_settings(const ClientImpl &rhs) { proxy_basic_auth_username_ = rhs.proxy_basic_auth_username_; proxy_basic_auth_password_ = rhs.proxy_basic_auth_password_; proxy_bearer_token_auth_token_ = rhs.proxy_bearer_token_auth_token_; + no_proxy_entries_ = rhs.no_proxy_entries_; logger_ = rhs.logger_; error_logger_ = rhs.error_logger_; @@ -12680,11 +12699,10 @@ inline void ClientImpl::copy_settings(const ClientImpl &rhs) { inline bool ClientImpl::is_proxy_enabled_for_host(const std::string &host) const { - // The host parameter is unused while NO_PROXY support is not yet wired in; - // it will be consulted once set_no_proxy() lands. Keeping the signature now - // means the call sites do not need to change again later. - (void)host; - return !proxy_host_.empty() && proxy_port_ != -1; + if (proxy_host_.empty() || proxy_port_ == -1) { return false; } + if (no_proxy_entries_.empty()) { return true; } + auto target = detail::normalize_target(host); + return !detail::host_matches_no_proxy(target, no_proxy_entries_); } inline socket_t ClientImpl::create_client_socket(Error &error) const { @@ -13443,7 +13461,7 @@ inline bool ClientImpl::create_redirect_client( SSLClient redirect_client(host, port); // Setup basic client configuration first - setup_redirect_client(redirect_client); + setup_redirect_client(redirect_client, host); redirect_client.enable_server_certificate_verification( server_certificate_verification_); @@ -13477,7 +13495,7 @@ inline bool ClientImpl::create_redirect_client( ClientImpl redirect_client(host, port); // Setup client with robust configuration - setup_redirect_client(redirect_client); + setup_redirect_client(redirect_client, host); // Execute the redirect return detail::redirect(redirect_client, req, res, path, location, error); @@ -13487,7 +13505,8 @@ inline bool ClientImpl::create_redirect_client( // New method for robust client setup (based on basic_manual_redirect.cpp // logic) template -inline void ClientImpl::setup_redirect_client(ClientType &client) { +inline void ClientImpl::setup_redirect_client(ClientType &client, + const std::string &next_host) { // Copy basic settings first client.set_connection_timeout(connection_timeout_sec_); client.set_read_timeout(read_timeout_sec_, read_timeout_usec_); @@ -13505,9 +13524,15 @@ inline void ClientImpl::setup_redirect_client(ClientType &client) { // host. This function is only called for cross-host redirects; same-host // redirects are handled directly in ClientImpl::redirect(). + // Always carry the NO_PROXY policy across redirects so the bypass list is + // re-evaluated against the redirect target rather than the original host. + client.no_proxy_entries_ = no_proxy_entries_; + // Setup proxy configuration (CRITICAL ORDER - proxy must be set - // before proxy auth) - if (is_proxy_enabled_for_host(host_)) { + // before proxy auth). Gating on is_proxy_enabled_for_host(next_host) + // ensures Proxy-Authorization is not forwarded when the redirect target + // is in NO_PROXY (analog of the cross-origin auth-leak class of bugs). + if (is_proxy_enabled_for_host(next_host)) { // First set proxy host and port client.set_proxy(proxy_host_, proxy_port_); @@ -13602,11 +13627,16 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req, } } - if (!proxy_basic_auth_username_.empty() && - !proxy_basic_auth_password_.empty()) { - if (!req.has_header("Proxy-Authorization")) { - req.headers.insert(make_basic_authentication_header( - proxy_basic_auth_username_, proxy_basic_auth_password_, true)); + // Only inject Proxy-Authorization when the proxy is actually being used + // for this target. Otherwise NO_PROXY-matched requests would leak + // proxy credentials directly to the destination server. + if (is_proxy_enabled_for_host(host_)) { + if (!proxy_basic_auth_username_.empty() && + !proxy_basic_auth_password_.empty()) { + if (!req.has_header("Proxy-Authorization")) { + req.headers.insert(make_basic_authentication_header( + proxy_basic_auth_username_, proxy_basic_auth_password_, true)); + } } } @@ -13617,10 +13647,12 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req, } } - if (!proxy_bearer_token_auth_token_.empty()) { - if (!req.has_header("Proxy-Authorization")) { - req.headers.insert(make_bearer_token_authentication_header( - proxy_bearer_token_auth_token_, true)); + if (is_proxy_enabled_for_host(host_)) { + if (!proxy_bearer_token_auth_token_.empty()) { + if (!req.has_header("Proxy-Authorization")) { + req.headers.insert(make_bearer_token_authentication_header( + proxy_bearer_token_auth_token_, true)); + } } } @@ -15041,6 +15073,20 @@ inline void ClientImpl::set_proxy_bearer_token_auth(const std::string &token) { proxy_bearer_token_auth_token_ = token; } +inline void ClientImpl::set_no_proxy(const std::vector &patterns) { + std::vector parsed; + parsed.reserve(patterns.size()); + for (const auto &p : patterns) { + auto trimmed = detail::trim_copy(p); + if (trimmed.empty()) { continue; } + detail::NoProxyEntry entry; + if (detail::parse_no_proxy_entry(trimmed, entry)) { + parsed.push_back(std::move(entry)); + } + } + no_proxy_entries_ = std::move(parsed); +} + #ifdef CPPHTTPLIB_SSL_ENABLED inline void ClientImpl::set_digest_auth(const std::string &username, const std::string &password) { @@ -15742,6 +15788,9 @@ inline void Client::set_proxy_basic_auth(const std::string &username, inline void Client::set_proxy_bearer_token_auth(const std::string &token) { cli_->set_proxy_bearer_token_auth(token); } +inline void Client::set_no_proxy(const std::vector &patterns) { + cli_->set_no_proxy(patterns); +} inline void Client::set_logger(Logger logger) { cli_->set_logger(std::move(logger)); From 15d0492b826abcc997b2e94e08d0ff53b25ec475 Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 8 May 2026 23:10:37 +0900 Subject: [PATCH 05/22] Add Client::set_proxy_from_env with httpoxy mitigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final user-facing piece for #2446. Reads proxy-related environment variables and configures the client. - HTTPS clients (SSLClient) read https_proxy / HTTPS_PROXY - HTTP clients read http_proxy (lowercase only — see below) - Both also read no_proxy / NO_PROXY - Returns true if at least one variable was found and applied The lowercase-only http_proxy rule mitigates httpoxy / CVE-2016-5385. In CGI / FastCGI environments the uppercase HTTP_PROXY collides with the HTTP_* namespace used to expose request headers, so a remote attacker controlling the "Proxy:" header can inject a proxy URL. cpp-httplib follows curl, Go, and Python requests in honoring only the lowercase form. https_proxy/HTTPS_PROXY and no_proxy/NO_PROXY do not have this problem because their names don't begin with HTTP_. Scheme dispatch uses virtual is_ssl(): an SSLClient picks https_proxy and a plain ClientImpl picks http_proxy. There is intentionally no cross-scheme fallback — the two variables describe different traffic. set_proxy_from_env() reads getenv() synchronously and is documented as "call once at startup" — concurrent setenv from other threads is undefined. All 608 unit tests pass. --- httplib.h | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/httplib.h b/httplib.h index 3e57c6fe1e..ac2a87e82d 100644 --- a/httplib.h +++ b/httplib.h @@ -2059,6 +2059,15 @@ struct NormalizedTarget { struct in6_addr v6 {}; }; +// Proxy-related environment variables, parsed. +struct ProxyEnvSettings { + bool have_http_proxy = false; + bool have_https_proxy = false; + ProxyUrl http_proxy; + ProxyUrl https_proxy; + std::vector no_proxy; +}; + } // namespace detail class ClientImpl { @@ -2290,6 +2299,26 @@ class ClientImpl { // method replaces any previously configured list; there is no append. void set_no_proxy(const std::vector &patterns); + // Configures the client from proxy-related environment variables: + // - HTTPS clients (SSLClient) read https_proxy / HTTPS_PROXY + // - HTTP clients read http_proxy (lowercase only) + // - Both also read no_proxy / NO_PROXY + // Returns true if at least one variable was found and applied. + // + // Security note: only the lowercase http_proxy is read. The uppercase + // form is intentionally ignored to mitigate the httpoxy class of bugs + // (CVE-2016-5385): in CGI/FastCGI environments, HTTP_PROXY collides + // with the HTTP_* namespace used to expose request headers, allowing + // a remote attacker to set the proxy URL via the Proxy: request + // header. cpp-httplib follows curl, Go and Python requests in only + // honoring the lowercase form. https_proxy/HTTPS_PROXY and + // no_proxy/NO_PROXY are safe in either case. + // + // Threading: this function reads getenv() synchronously; call it once + // at startup before issuing any requests. Concurrent setenv from other + // threads while this function runs is undefined. + bool set_proxy_from_env(); + void set_logger(Logger logger); void set_error_logger(ErrorLogger error_logger); @@ -2667,6 +2696,7 @@ class Client { const std::string &password); void set_proxy_bearer_token_auth(const std::string &token); void set_no_proxy(const std::vector &patterns); + bool set_proxy_from_env(); void set_logger(Logger logger); void set_error_logger(ErrorLogger error_logger); @@ -3062,6 +3092,14 @@ std::vector parse_no_proxy_list(const std::string &value); NormalizedTarget normalize_target(const std::string &host); +// Reads proxy-related environment variables. Reads: +// https_proxy / HTTPS_PROXY → ProxyEnvSettings::https_proxy +// http_proxy (lowercase only — see security note in set_proxy_from_env) +// → ProxyEnvSettings::http_proxy +// no_proxy / NO_PROXY → ProxyEnvSettings::no_proxy +// Variables that fail to parse are treated as unset. +ProxyEnvSettings read_proxy_env(); + // CIDR membership tests. // prefix_bits in [0, 32] for IPv4, [0, 128] for IPv6. // prefix_bits == 0 always returns true for the matching family. @@ -10876,6 +10914,42 @@ inline bool host_matches_no_proxy(const NormalizedTarget &target, return false; } +inline ProxyEnvSettings read_proxy_env() { + ProxyEnvSettings out; + + auto try_url = [](const char *value, ProxyUrl &dst) -> bool { + if (!value || *value == '\0') { return false; } + return parse_proxy_url(value, dst); + }; + + // http_proxy: lowercase ONLY. The uppercase form is ignored to mitigate + // httpoxy (CVE-2016-5385): in CGI/FastCGI environments, HTTP_PROXY + // collides with the HTTP_* namespace used to expose request headers, + // letting a remote attacker control the proxy URL via the "Proxy:" + // header. + if (try_url(std::getenv("http_proxy"), out.http_proxy)) { + out.have_http_proxy = true; + } + + // https_proxy is safe in either case: there is no HTTPS_* CGI + // collision because the variable does not start with HTTP_. + if (try_url(std::getenv("https_proxy"), out.https_proxy)) { + out.have_https_proxy = true; + } else if (try_url(std::getenv("HTTPS_PROXY"), out.https_proxy)) { + out.have_https_proxy = true; + } + + const char *no_proxy_value = std::getenv("no_proxy"); + if (!no_proxy_value || *no_proxy_value == '\0') { + no_proxy_value = std::getenv("NO_PROXY"); + } + if (no_proxy_value && *no_proxy_value != '\0') { + out.no_proxy = parse_no_proxy_list(no_proxy_value); + } + + return out; +} + template inline bool check_and_write_headers(Stream &strm, Headers &headers, T header_writer, Error &error) { @@ -15087,6 +15161,39 @@ inline void ClientImpl::set_no_proxy(const std::vector &patterns) { no_proxy_entries_ = std::move(parsed); } +inline bool ClientImpl::set_proxy_from_env() { + auto env = detail::read_proxy_env(); + bool applied = false; + + // is_ssl() is virtual; SSLClient overrides to return true. So an + // SSLClient instance picks https_proxy and a plain ClientImpl picks + // http_proxy. There is intentionally no cross-scheme fallback — + // http_proxy and https_proxy describe different target traffic. + const detail::ProxyUrl *picked = nullptr; + if (is_ssl() && env.have_https_proxy) { + picked = &env.https_proxy; + } else if (!is_ssl() && env.have_http_proxy) { + picked = &env.http_proxy; + } + + if (picked) { + proxy_host_ = picked->host; + proxy_port_ = picked->port; + if (!picked->username.empty()) { + proxy_basic_auth_username_ = picked->username; + proxy_basic_auth_password_ = picked->password; + } + applied = true; + } + + if (!env.no_proxy.empty()) { + no_proxy_entries_ = std::move(env.no_proxy); + applied = true; + } + + return applied; +} + #ifdef CPPHTTPLIB_SSL_ENABLED inline void ClientImpl::set_digest_auth(const std::string &username, const std::string &password) { @@ -15791,6 +15898,7 @@ inline void Client::set_proxy_bearer_token_auth(const std::string &token) { inline void Client::set_no_proxy(const std::vector &patterns) { cli_->set_no_proxy(patterns); } +inline bool Client::set_proxy_from_env() { return cli_->set_proxy_from_env(); } inline void Client::set_logger(Logger logger) { cli_->set_logger(std::move(logger)); From cbd0ec5a9950446d1145873817d9413d00def2a6 Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 8 May 2026 23:20:44 +0900 Subject: [PATCH 06/22] Add NO_PROXY behavior tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 27 black-box tests exercising the public Client API only (no detail:: calls, BORDER-friendly; no EXPECT_NO_THROW, -fno-exceptions-friendly). In-process proxy mock + target server. Each test asserts which side of the routing decision each request landed on, and what headers (in particular Proxy-Authorization) the receiving side saw. Coverage: Suffix matching (dot-boundary rule) - exact-host match - subdomain match - "evilexample.com" does NOT match "example.com" ← regression guard for the classic NO_PROXY suffix-match pitfall - "example.com.evil.com" does NOT match - leading-dot pattern still matches the bare domain (Go/curl convention) - case-insensitive - trailing-dot host normalization Wildcard - "*" bypasses everything IP normalization - exact IPv4 match - "::1" matches "0:0:0:0:0:0:0:1" via inet_pton - IPv4-mapped IPv6 ("::ffff:127.0.0.1") is NOT cross-matched against an IPv4 entry CIDR - basic v4 in-cidr / not-in-cidr - "0.0.0.0/0" (prefix=0; verifies no shift UB) - bare IP treated as /32 - malformed prefix (/33) silently dropped → no NO_PROXY effect Proxy-Authorization handling - suppressed when NO_PROXY matches the target - sent when NO_PROXY does not match Backward compat - default behavior unchanged when set_no_proxy is never called Parsing edge cases - port-specific entries ("host:port") rejected - empty / whitespace tokens dropped Cross-origin redirect (analog of GHSA-6hrp-7fq9-3qv2) - redirect target in NO_PROXY → redirect leg goes direct, no Proxy-Authorization carried over set_proxy_from_env (Unix only — uses setenv/unsetenv) - lowercase http_proxy applied - uppercase HTTP_PROXY ignored (httpoxy / CVE-2016-5385) - NO_PROXY-only env returns true and applies the bypass list - CRLF in env value rejected (cf. CVE-2026-21428) - empty env value treated as unset 635 tests (608 prior + 27 new) pass under both the regular and the split builds. --- test/test.cc | 541 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 541 insertions(+) diff --git a/test/test.cc b/test/test.cc index e9683222b7..affeaf78d7 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18276,3 +18276,544 @@ TEST(KeepAliveTest, DeleteWithoutContentLengthDoesNotEatNextRequest) { EXPECT_EQ(2, delete_count.load()); } + +// ============================================================================= +// NO_PROXY / set_proxy_from_env (#2446) +// ============================================================================= + +namespace no_proxy_test { + +#ifndef _WIN32 +// RAII helper that saves, sets, and restores an environment variable. +class ScopedEnv { +public: + ScopedEnv(const char *name, const char *value) : name_(name) { + auto p = std::getenv(name); + had_prev_ = (p != nullptr); + if (had_prev_) { prev_ = p; } + if (value) { + ::setenv(name, value, 1); + } else { + ::unsetenv(name); + } + } + ~ScopedEnv() { + if (had_prev_) { + ::setenv(name_.c_str(), prev_.c_str(), 1); + } else { + ::unsetenv(name_.c_str()); + } + } + +private: + std::string name_; + std::string prev_; + bool had_prev_ = false; +}; +#endif // !_WIN32 + +// In-process proxy mock + direct target. Each request that arrives bumps +// the corresponding counter, so a test can assert "this request went via +// the proxy" or "this request bypassed the proxy". +class ProxyAndTargetServers { +public: + ProxyAndTargetServers() { + proxy_mock_.Get(".*", [this](const Request &req, Response &res) { + proxy_hits_++; + last_had_proxy_authz_ = req.has_header("Proxy-Authorization"); + res.set_content("via-proxy", "text/plain"); + }); + target_.Get(".*", [this](const Request &req, Response &res) { + target_hits_++; + last_had_proxy_authz_ = req.has_header("Proxy-Authorization"); + res.set_content("direct", "text/plain"); + }); + + proxy_port_ = proxy_mock_.bind_to_any_port("127.0.0.1"); + target_port_ = target_.bind_to_any_port("127.0.0.1"); + proxy_thread_ = std::thread([this] { proxy_mock_.listen_after_bind(); }); + target_thread_ = std::thread([this] { target_.listen_after_bind(); }); + proxy_mock_.wait_until_ready(); + target_.wait_until_ready(); + } + + ~ProxyAndTargetServers() { + proxy_mock_.stop(); + target_.stop(); + if (proxy_thread_.joinable()) { proxy_thread_.join(); } + if (target_thread_.joinable()) { target_thread_.join(); } + } + + Server &proxy_mock() { return proxy_mock_; } + Server &target() { return target_; } + int proxy_port() const { return proxy_port_; } + int target_port() const { return target_port_; } + int proxy_hits() const { return proxy_hits_.load(); } + int target_hits() const { return target_hits_.load(); } + bool last_had_proxy_authz() const { return last_had_proxy_authz_.load(); } + + void reset_counters() { + proxy_hits_ = 0; + target_hits_ = 0; + last_had_proxy_authz_ = false; + } + +private: + Server proxy_mock_; + Server target_; + std::thread proxy_thread_; + std::thread target_thread_; + int proxy_port_ = 0; + int target_port_ = 0; + std::atomic proxy_hits_{0}; + std::atomic target_hits_{0}; + std::atomic last_had_proxy_authz_{false}; +}; + +// Helper: build a client targeted at `host` with a hostname mapping to +// 127.0.0.1, the proxy pointed at the mock. +inline std::unique_ptr make_client(const std::string &host, + ProxyAndTargetServers &s) { + auto cli = detail::make_unique(host, s.target_port()); + cli->set_hostname_addr_map({{host, "127.0.0.1"}}); + cli->set_proxy("127.0.0.1", s.proxy_port()); + return cli; +} + +} // namespace no_proxy_test + +using no_proxy_test::make_client; +using no_proxy_test::ProxyAndTargetServers; + +// ---- Hostname suffix matching: dot-boundary rule +// ----------------------------- + +TEST(NoProxyTest, ExactHostnameBypasses) { + ProxyAndTargetServers s; + auto cli = make_client("example.com", s); + cli->set_no_proxy({"example.com"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +TEST(NoProxyTest, SubdomainBypasses) { + ProxyAndTargetServers s; + auto cli = make_client("foo.example.com", s); + cli->set_no_proxy({"example.com"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +TEST(NoProxyTest, EvilExampleDoesNotMatchExample) { + // Regression guard: "evilexample.com" must not be considered a subdomain + // of "example.com". Without the dot-boundary rule, a naive endsWith + // check would let traffic bypass the proxy and leak credentials direct + // to the attacker host. + ProxyAndTargetServers s; + auto cli = make_client("evilexample.com", s); + cli->set_no_proxy({"example.com"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_GE(s.proxy_hits(), 1); + EXPECT_EQ(0, s.target_hits()); +} + +TEST(NoProxyTest, ExampleDotEvilDoesNotMatchExample) { + ProxyAndTargetServers s; + auto cli = make_client("example.com.evil.com", s); + cli->set_no_proxy({"example.com"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_GE(s.proxy_hits(), 1); + EXPECT_EQ(0, s.target_hits()); +} + +TEST(NoProxyTest, LeadingDotPatternMatchesBareDomain) { + ProxyAndTargetServers s; + auto cli = make_client("example.com", s); + cli->set_no_proxy({".example.com"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +TEST(NoProxyTest, CaseInsensitiveHostname) { + ProxyAndTargetServers s; + auto cli = make_client("Example.COM", s); + cli->set_no_proxy({"EXAMPLE.com"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +TEST(NoProxyTest, TrailingDotIsNormalized) { + ProxyAndTargetServers s; + auto cli = make_client("example.com.", s); + cli->set_no_proxy({"example.com"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +// ---- Wildcard +// ---------------------------------------------------------------- + +TEST(NoProxyTest, WildcardBypassesEverything) { + ProxyAndTargetServers s; + auto cli = make_client("anything.invalid.test", s); + cli->set_no_proxy({"*"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +// ---- IP normalization +// -------------------------------------------------------- + +TEST(NoProxyTest, IPv4LiteralExactMatch) { + ProxyAndTargetServers s; + Client cli("127.0.0.1", s.target_port()); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"127.0.0.1"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +TEST(NoProxyTest, IPv6LiteralExactMatchAcrossEquivalentForms) { + // Different string forms of the same IPv6 address ("::1" and the + // expanded "0:0:0:0:0:0:0:1") must match because both go through + // inet_pton during normalization. + ProxyAndTargetServers s; + Client cli("0:0:0:0:0:0:0:1", s.target_port()); + cli.set_hostname_addr_map({{"0:0:0:0:0:0:0:1", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"::1"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +TEST(NoProxyTest, IPv4MappedIPv6IsNotCrossMatchedAgainstIPv4Entry) { + // Policy: keep address families separate. "::ffff:1.2.3.4" must NOT + // satisfy a NO_PROXY entry of "1.2.3.4". This avoids subtle bypass + // tricks via address-family conversion. + ProxyAndTargetServers s; + Client cli("::ffff:127.0.0.1", s.target_port()); + cli.set_hostname_addr_map({{"::ffff:127.0.0.1", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"127.0.0.1"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_GE(s.proxy_hits(), 1); + EXPECT_EQ(0, s.target_hits()); +} + +// ---- CIDR matching +// ----------------------------------------------------------- + +TEST(NoProxyTest, IPv4CidrMatch) { + ProxyAndTargetServers s; + Client cli("127.0.0.1", s.target_port()); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"127.0.0.0/8"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +TEST(NoProxyTest, IPv4CidrNonMatch) { + ProxyAndTargetServers s; + Client cli("127.0.0.1", s.target_port()); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"10.0.0.0/8"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_GE(s.proxy_hits(), 1); + EXPECT_EQ(0, s.target_hits()); +} + +TEST(NoProxyTest, IPv4CidrPrefixZeroMatchesAll) { + // Prefix 0 must not trigger the (1u << 32) shift UB. Result: every + // IPv4 target matches. + ProxyAndTargetServers s; + Client cli("127.0.0.1", s.target_port()); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"0.0.0.0/0"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +TEST(NoProxyTest, IPv4CidrSingleHostNoSlash) { + // Bare IP without a /prefix is treated as /32 (single host). + ProxyAndTargetServers s; + Client cli("127.0.0.1", s.target_port()); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"127.0.0.1"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +TEST(NoProxyTest, MalformedCidrPrefixIsDropped) { + // /33 on IPv4 is invalid and must be silently dropped during parsing, + // leaving no NO_PROXY effect. + ProxyAndTargetServers s; + Client cli("127.0.0.1", s.target_port()); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"127.0.0.0/33"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_GE(s.proxy_hits(), 1); + EXPECT_EQ(0, s.target_hits()); +} + +// ---- Proxy-Authorization handling +// -------------------------------------------- + +TEST(NoProxyTest, ProxyAuthorizationSuppressedWhenBypassed) { + ProxyAndTargetServers s; + auto cli = make_client("internal.corp", s); + cli->set_proxy_basic_auth("u", "p"); + cli->set_no_proxy({"internal.corp"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(1, s.target_hits()); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_FALSE(s.last_had_proxy_authz()) + << "Proxy-Authorization must not be sent direct to the target"; +} + +TEST(NoProxyTest, ProxyAuthorizationSentWhenNotBypassed) { + ProxyAndTargetServers s; + auto cli = make_client("public.example", s); + cli->set_proxy_basic_auth("u", "p"); + cli->set_no_proxy({"internal.corp"}); // does not match + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_GE(s.proxy_hits(), 1); + EXPECT_TRUE(s.last_had_proxy_authz()); +} + +// ---- Backward compatibility +// -------------------------------------------------- + +TEST(NoProxyTest, EmptyNoProxyKeepsProxyOn) { + // Default behavior unchanged when set_no_proxy is never called. + ProxyAndTargetServers s; + auto cli = make_client("anything.test", s); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_GE(s.proxy_hits(), 1); + EXPECT_EQ(0, s.target_hits()); +} + +// ---- Parsing edge cases +// ------------------------------------------------------ + +TEST(NoProxyTest, PortSpecificEntryRejected) { + // "host:port" is intentionally unsupported; must be silently dropped + // so it does not match anything. + ProxyAndTargetServers s; + auto cli = make_client("example.com", s); + cli->set_no_proxy({"example.com:8080"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_GE(s.proxy_hits(), 1); + EXPECT_EQ(0, s.target_hits()); +} + +TEST(NoProxyTest, EmptyAndWhitespaceEntriesDropped) { + // Empty/whitespace tokens must not match anything (especially not + // every host). + ProxyAndTargetServers s; + auto cli = make_client("anything.test", s); + cli->set_no_proxy({"", " ", "\t"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_GE(s.proxy_hits(), 1); + EXPECT_EQ(0, s.target_hits()); +} + +// ---- Cross-origin redirect honors NO_PROXY +// ----------------------------------- + +TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) { + // Analog of GHSA-6hrp-7fq9-3qv2: when a redirect targets a host in + // NO_PROXY, the follow-up request must go direct and must NOT carry + // Proxy-Authorization. Built without the ProxyAndTargetServers helper + // so the proxy mock can issue a 302 specifically for the /redir path. + + std::atomic proxy_hits{0}; + std::atomic target_hits{0}; + std::atomic proxy_saw_authz{false}; + std::atomic target_saw_authz{false}; + + Server proxy_mock; + Server target; + + // Proxy mock: redirect /redir to the target's loopback URL; everything + // else returns 200. + int target_port = target.bind_to_any_port("127.0.0.1"); + int proxy_port = proxy_mock.bind_to_any_port("127.0.0.1"); + + proxy_mock.Get(".*", [&](const Request &req, Response &res) { + proxy_hits++; + if (req.has_header("Proxy-Authorization")) { proxy_saw_authz = true; } + if (req.path.find("/redir") != std::string::npos) { + res.status = 302; + res.set_header("Location", "http://127.0.0.1:" + + std::to_string(target_port) + "/landed"); + return; + } + res.set_content("via-proxy", "text/plain"); + }); + + target.Get(".*", [&](const Request &req, Response &res) { + target_hits++; + if (req.has_header("Proxy-Authorization")) { target_saw_authz = true; } + res.set_content("direct", "text/plain"); + }); + + std::thread proxy_thread([&] { proxy_mock.listen_after_bind(); }); + std::thread target_thread([&] { target.listen_after_bind(); }); + auto cleanup = detail::scope_exit([&] { + proxy_mock.stop(); + target.stop(); + if (proxy_thread.joinable()) { proxy_thread.join(); } + if (target_thread.joinable()) { target_thread.join(); } + }); + proxy_mock.wait_until_ready(); + target.wait_until_ready(); + + // Initial request goes to a host that is NOT in NO_PROXY → uses the + // proxy. The proxy issues a 302 to 127.0.0.1, which IS in NO_PROXY → + // the redirect leg must go direct. + Client cli("public.example", target_port); + cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", proxy_port); + cli.set_proxy_basic_auth("u", "p"); + cli.set_no_proxy({"127.0.0.1"}); + cli.set_follow_location(true); + + auto res = cli.Get("/redir"); + ASSERT_TRUE(res); + EXPECT_GE(proxy_hits.load(), 1) << "first leg must hit the proxy"; + EXPECT_GE(target_hits.load(), 1) + << "redirect leg must reach the target directly"; + EXPECT_FALSE(target_saw_authz.load()) + << "Proxy-Authorization must not be sent on the bypassed redirect leg"; + // The first leg (going through the proxy) is allowed to carry + // Proxy-Authorization; we only assert the bypassed leg does not. +} + +// ---- set_proxy_from_env: httpoxy mitigation +// ---------------------------------- Skipped on Windows because setenv/unsetenv +// are POSIX-only. + +#ifndef _WIN32 + +TEST(NoProxyTest, SetProxyFromEnv_LowercaseHttpProxy_Applied) { + no_proxy_test::ScopedEnv h("http_proxy", "http://proxy.test:3128"); + no_proxy_test::ScopedEnv H("HTTP_PROXY", nullptr); + no_proxy_test::ScopedEnv n("no_proxy", nullptr); + no_proxy_test::ScopedEnv N("NO_PROXY", nullptr); + no_proxy_test::ScopedEnv s("https_proxy", nullptr); + no_proxy_test::ScopedEnv S("HTTPS_PROXY", nullptr); + + Client cli("example.com"); + EXPECT_TRUE(cli.set_proxy_from_env()); +} + +TEST(NoProxyTest, SetProxyFromEnv_UppercaseHTTPProxy_Ignored) { + // Httpoxy mitigation: HTTP_PROXY (uppercase) must NOT be honored, + // because in CGI environments it is set from the client-supplied + // "Proxy:" header. + no_proxy_test::ScopedEnv h("http_proxy", nullptr); + no_proxy_test::ScopedEnv H("HTTP_PROXY", "http://attacker.invalid:9999"); + no_proxy_test::ScopedEnv n("no_proxy", nullptr); + no_proxy_test::ScopedEnv N("NO_PROXY", nullptr); + no_proxy_test::ScopedEnv s("https_proxy", nullptr); + no_proxy_test::ScopedEnv S("HTTPS_PROXY", nullptr); + + Client cli("example.com"); + EXPECT_FALSE(cli.set_proxy_from_env()) + << "Uppercase HTTP_PROXY must be ignored (CVE-2016-5385)"; +} + +TEST(NoProxyTest, SetProxyFromEnv_NoProxyApplied) { + no_proxy_test::ScopedEnv h("http_proxy", nullptr); + no_proxy_test::ScopedEnv H("HTTP_PROXY", nullptr); + no_proxy_test::ScopedEnv s("https_proxy", nullptr); + no_proxy_test::ScopedEnv S("HTTPS_PROXY", nullptr); + no_proxy_test::ScopedEnv n("no_proxy", "example.com"); + no_proxy_test::ScopedEnv N("NO_PROXY", nullptr); + + Client cli("example.com"); + EXPECT_TRUE(cli.set_proxy_from_env()) + << "set_proxy_from_env returns true when only NO_PROXY is set"; +} + +TEST(NoProxyTest, SetProxyFromEnv_CRLFInProxyValueRejected) { + // CR/LF in env values must be rejected at parse time so they cannot + // inject extra header lines into a CONNECT request or + // Proxy-Authorization (cf. CVE-2026-21428, CRLF injection). + no_proxy_test::ScopedEnv h("http_proxy", "http://host:8080\r\nInjected: yes"); + no_proxy_test::ScopedEnv H("HTTP_PROXY", nullptr); + no_proxy_test::ScopedEnv n("no_proxy", nullptr); + no_proxy_test::ScopedEnv N("NO_PROXY", nullptr); + no_proxy_test::ScopedEnv s("https_proxy", nullptr); + no_proxy_test::ScopedEnv S("HTTPS_PROXY", nullptr); + + Client cli("example.com"); + EXPECT_FALSE(cli.set_proxy_from_env()); +} + +TEST(NoProxyTest, SetProxyFromEnv_EmptyEnvValueIgnored) { + no_proxy_test::ScopedEnv h("http_proxy", ""); + no_proxy_test::ScopedEnv H("HTTP_PROXY", nullptr); + no_proxy_test::ScopedEnv n("no_proxy", ""); + no_proxy_test::ScopedEnv N("NO_PROXY", nullptr); + no_proxy_test::ScopedEnv s("https_proxy", nullptr); + no_proxy_test::ScopedEnv S("HTTPS_PROXY", nullptr); + + Client cli("example.com"); + EXPECT_FALSE(cli.set_proxy_from_env()); +} + +#endif // !_WIN32 From 60cb6f5896d76d41d7aeb6f45eaadf7b9e8a14c6 Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 8 May 2026 23:21:47 +0900 Subject: [PATCH 07/22] Document set_no_proxy and set_proxy_from_env in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two subsections under "Proxy server support": - "Bypass the proxy for specific hosts (NO_PROXY)" — set_no_proxy, pattern syntax, dot-boundary rule, IP normalization, limitations (no port-specific entries, no v4-mapped v6 cross-match, replace semantics). - "Read proxy settings from the environment" — set_proxy_from_env, which variables are read, the lowercase-only http_proxy rule with an inline httpoxy / CVE-2016-5385 explanation, threading expectations. Documentation only. Closes the doc gap from #2446. --- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/README.md b/README.md index 1037ab2e36..730ee57d98 100644 --- a/README.md +++ b/README.md @@ -1181,6 +1181,76 @@ cli.set_proxy_bearer_token_auth("pass"); > [!NOTE] > OpenSSL is required for Digest Authentication. +#### Bypass the proxy for specific hosts (`NO_PROXY`) + +`set_no_proxy` configures a bypass list. Each pattern is one of: + +- `*` — bypass the proxy for every target +- a hostname suffix, e.g. `example.com` — matches `example.com` and any + subdomain (`foo.example.com`). A leading dot (`.example.com`) is + permitted but informational; both forms are equivalent. +- a single IP literal, e.g. `192.168.1.1`, `::1` +- a CIDR block, e.g. `10.0.0.0/8`, `fe80::/10` + +```cpp +cli.set_proxy("proxy.corp", 3128); +cli.set_no_proxy({"internal.corp", "10.0.0.0/8", "*.dev.local"}); +``` + +When a NO_PROXY entry matches the target host, the request is sent +directly and the corresponding `Proxy-Authorization` header is +suppressed. + +Hostname matching is **case-insensitive** and uses a dot-boundary rule, +so `evilexample.com` does **not** match an entry of `example.com`. IP +comparisons are normalized through `inet_pton`, so `127.0.0.1` cannot +be bypassed via alternate string forms (e.g. leading-zero octets or +decimal-form integers). Malformed entries are silently dropped. + +Limitations: + +- Port-specific entries (`example.com:8080`) are not supported. cpp-httplib's + other host-keyed APIs (e.g. `set_hostname_addr_map`) are also keyed on + hostname only; supporting host:port for NO_PROXY alone would be + inconsistent. +- IPv4-mapped IPv6 addresses (`::ffff:1.2.3.4`) are not cross-matched + against IPv4 NO_PROXY entries. +- `set_no_proxy` replaces any previously configured list; there is no + append API. + +#### Read proxy settings from the environment + +`set_proxy_from_env` configures the client from proxy-related +environment variables. + +```cpp +httplib::Client cli("https://api.example.com"); +cli.set_proxy_from_env(); +``` + +Variables read: + +- `https_proxy` / `HTTPS_PROXY` — used by HTTPS clients (`SSLClient`) +- `http_proxy` (lowercase only — see security note below) — used by HTTP clients +- `no_proxy` / `NO_PROXY` — comma-separated list of bypass patterns + +Returns `true` if at least one variable was found and applied. + +> [!IMPORTANT] +> The uppercase `HTTP_PROXY` is intentionally ignored to mitigate the +> "httpoxy" class of bugs ([CVE-2016-5385](https://httpoxy.org/)). In +> CGI / FastCGI environments the variable name collides with the +> `HTTP_*` namespace used to expose request headers, allowing a remote +> attacker to set the proxy URL via the `Proxy:` request header. +> cpp-httplib follows curl, Go, and Python `requests` in honoring only +> the lowercase `http_proxy`. `HTTPS_PROXY` and `NO_PROXY` are safe in +> either case because their names do not begin with `HTTP_`. + +> [!NOTE] +> `set_proxy_from_env` reads `getenv` synchronously. Call it once at +> startup before issuing any requests; concurrent `setenv` from other +> threads is undefined. + ### Range ```cpp From 8c20221ab8ae3d40a1b6b099089cfd3b32359e90 Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 8 May 2026 23:23:14 +0900 Subject: [PATCH 08/22] Document NO_PROXY and set_proxy_from_env in cookbook c16-proxy Replaces the now-incorrect Note at the bottom of c16-proxy ("cpp-httplib does not read HTTP_PROXY...") with the actual API. JA is the master per the project's translation workflow; the EN translation lands in the same PR. Both pages remain `status: "draft"` for normal review. Adds two sections: - Bypass the proxy for specific hosts (set_no_proxy): pattern syntax, dot-boundary rule, case-insensitivity, IP normalization via inet_pton, port-specific-entries unsupported, malformed entries dropped. - Read proxy settings from the environment (set_proxy_from_env): which variables are read, lowercase-only http_proxy with an inline httpoxy / CVE-2016-5385 explanation, threading caveat. --- docs-src/pages/en/cookbook/c16-proxy.md | 41 ++++++++++++++++++++++++- docs-src/pages/ja/cookbook/c16-proxy.md | 41 ++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/docs-src/pages/en/cookbook/c16-proxy.md b/docs-src/pages/en/cookbook/c16-proxy.md index 5b193b37f9..c6fe7c43e7 100644 --- a/docs-src/pages/en/cookbook/c16-proxy.md +++ b/docs-src/pages/en/cookbook/c16-proxy.md @@ -49,4 +49,43 @@ cli.set_bearer_token_auth("api-token"); // for the end server `Proxy-Authorization` is sent to the proxy, `Authorization` to the end server. -> **Note:** cpp-httplib does not read `HTTP_PROXY` or `HTTPS_PROXY` environment variables automatically. If you want to honor them, read them in your application and pass the values to `set_proxy()`. +## Bypass the proxy for specific hosts + +You often want internal endpoints to skip the proxy. Configure a bypass list with `set_no_proxy()`. + +```cpp +cli.set_proxy("proxy.internal", 8080); +cli.set_no_proxy({"internal.corp", "10.0.0.0/8", "*.dev.local"}); +``` + +Each entry is one of: + +- `*` — bypass the proxy for all hosts +- a hostname suffix (e.g. `example.com`) — matches `example.com` itself and any subdomain (`foo.example.com`). A leading dot is permitted but informational; both forms are equivalent. +- a single IP literal (e.g. `192.168.1.1`, `::1`) +- a CIDR block (e.g. `10.0.0.0/8`, `fe80::/10`) + +Hostname matching is case-insensitive and uses a dot-boundary rule, so an entry of `example.com` does **not** match `evilexample.com`. IP comparisons are normalized through `inet_pton`, so `127.0.0.1` cannot be bypassed via alternate string forms (e.g. `127.000.000.001`). When an entry matches, the `Proxy-Authorization` header is suppressed as well. + +Malformed entries are silently dropped. Port-specific entries such as `example.com:8080` are not supported (cpp-httplib's other host-keyed APIs are also keyed on hostname only). + +## Read proxy settings from the environment + +Call `set_proxy_from_env()` at startup to pick up proxy configuration from environment variables. + +```cpp +httplib::Client cli("https://api.example.com"); +cli.set_proxy_from_env(); +``` + +Variables read: + +- `https_proxy` / `HTTPS_PROXY` — used by HTTPS clients +- `http_proxy` (**lowercase only**, see below) — used by HTTP clients +- `no_proxy` / `NO_PROXY` — comma-separated bypass list + +Returns `true` if at least one variable was found and applied. + +> **Security Note:** The uppercase `HTTP_PROXY` is intentionally **not** read. In CGI/FastCGI environments, the `HTTP_*` namespace is used to expose HTTP request headers, which lets a remote attacker inject an arbitrary proxy URL via the `Proxy:` request header ([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/)). cpp-httplib follows curl, Go, and Python `requests` in honoring only the lowercase `http_proxy`. `HTTPS_PROXY` and `NO_PROXY` are safe in either case because their names do not begin with `HTTP_`. + +> **Note:** `set_proxy_from_env()` reads `getenv` synchronously; call it once at startup. Concurrent `setenv` from other threads while this function runs is undefined. diff --git a/docs-src/pages/ja/cookbook/c16-proxy.md b/docs-src/pages/ja/cookbook/c16-proxy.md index 296035ef17..a71ca28d68 100644 --- a/docs-src/pages/ja/cookbook/c16-proxy.md +++ b/docs-src/pages/ja/cookbook/c16-proxy.md @@ -49,4 +49,43 @@ cli.set_bearer_token_auth("api-token"); // エンドサーバー向け プロキシには`Proxy-Authorization`、エンドサーバーには`Authorization`ヘッダーが送られます。 -> **Note:** 環境変数の`HTTP_PROXY`や`HTTPS_PROXY`は自動的には読まれません。必要ならアプリケーション側で読み取って`set_proxy()`に渡してください。 +## 特定のホストだけプロキシをバイパスする + +社内エンドポイントなどはプロキシを経由させたくないことがあります。`set_no_proxy()`で除外リストを指定できます。 + +```cpp +cli.set_proxy("proxy.internal", 8080); +cli.set_no_proxy({"internal.corp", "10.0.0.0/8", "*.dev.local"}); +``` + +エントリは次のいずれかです。 + +- `*` — すべてのホストでバイパス +- ホスト名サフィックス(例: `example.com`)— `example.com`本体と任意のサブドメイン(`foo.example.com`)にマッチ。先頭にドットを付けても同じ意味です(`.example.com`)。 +- 単一のIPリテラル(例: `192.168.1.1`、`::1`) +- CIDRブロック(例: `10.0.0.0/8`、`fe80::/10`) + +ホスト名のマッチは大文字小文字を区別せず、ドット境界でしか一致しません。たとえば`example.com`というエントリは`evilexample.com`にはマッチしません。IPの比較は`inet_pton`で正規化されるので、`127.0.0.1`を`127.000.000.001`のような別表記でバイパスすることはできません。マッチした場合、`Proxy-Authorization`ヘッダーも自動的に外れます。 + +不正な書式のエントリは黙って捨てられます。`example.com:8080`のようなポート指定エントリはサポート外です(cpp-httplibの他のホストキーAPIもホスト名のみを扱う設計のため)。 + +## 環境変数からプロキシ設定を読み込む + +`set_proxy_from_env()`を呼ぶと、起動時の環境変数からプロキシ設定をまとめて取り込めます。 + +```cpp +httplib::Client cli("https://api.example.com"); +cli.set_proxy_from_env(); +``` + +読み込まれる変数: + +- `https_proxy` / `HTTPS_PROXY` — HTTPSクライアントが使用 +- `http_proxy`(**小文字のみ**、後述)— HTTPクライアントが使用 +- `no_proxy` / `NO_PROXY` — カンマ区切りのバイパスリスト + +少なくとも1つの変数が見つかって適用されたら`true`を返します。 + +> **Security Note:** 大文字の`HTTP_PROXY`は意図的に**読まれません**。CGI/FastCGI環境では`HTTP_*`という名前空間がHTTPリクエストヘッダーの公開に使われており、攻撃者が`Proxy:`リクエストヘッダーで任意のプロキシURLを差し込めてしまうためです([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/))。curl・Go・Python `requests`と同じく、cpp-httplibも小文字の`http_proxy`しか採用しません。`HTTPS_PROXY`や`NO_PROXY`は名前が`HTTP_`で始まらないので、どちらの大文字小文字でも安全です。 + +> **Note:** `set_proxy_from_env()`は同期的に`getenv`を呼ぶだけなので、起動時に1回呼ぶことを想定しています。他スレッドが同時に`setenv`しているケースは未定義です。 From 1d462ec366e668d8ebb5a957548c3190225255d5 Mon Sep 17 00:00:00 2001 From: yhirose Date: Sat, 9 May 2026 07:33:25 +0900 Subject: [PATCH 09/22] Simplify NO_PROXY implementation per review Apply seven post-implementation cleanups: - Move ProxyUrl, ProxyEnvSettings and most helper forward declarations below the BORDER. Only NoProxyKind/NoProxyEntry/NormalizedTarget stay above (they are used as ClientImpl members or by inline cache state). This shrinks the public header surface area considerably. - Drop ProxyUrl::scheme: the field was write-only after parsing. Track is_https as a local during parse_proxy_url and use it for the default-port branch directly. - Hoist the duplicate is_proxy_enabled_for_host(host_) gate in write_request: the previous form had two adjacent gates bracketing an unrelated end-server bearer-token block. Reordering puts the two proxy-auth blocks together under a single gate. - Drop the redundant trim_copy + empty-check inside parse_no_proxy_list: detail::split already trims each token and skips empties, so the inner work was dead code. - Cache normalize_target(host_) on the client. host_ is const, so the normalized form is invariant for the client's lifetime. The gate is called up to 7 times per request when NO_PROXY is configured; caching avoids repeating two heap allocations + two inet_pton calls per request. Cross-host calls (only setup_redirect_client passing next_host) still compute fresh. - Trim narrative comments in setup_redirect_client and set_proxy_from_env: replace WHAT-narration with single-line WHY statements. - Drop test comments that paraphrased their own test name. All 635 unit tests pass under both the regular and split builds. --- httplib.h | 174 ++++++++++++++++++++++----------------------------- test/test.cc | 5 -- 2 files changed, 74 insertions(+), 105 deletions(-) diff --git a/httplib.h b/httplib.h index ac2a87e82d..f32be3cd97 100644 --- a/httplib.h +++ b/httplib.h @@ -2024,16 +2024,9 @@ inline ssize_t read_body_content(Stream *stream, BodyReader &br, char *buf, class decompressor; -// Parsed representation of an HTTP(S) proxy URL. -struct ProxyUrl { - std::string scheme; // "http" or "https" - std::string host; // bracket-stripped for IPv6 literals - int port = -1; - std::string username; // empty if absent - std::string password; // empty if absent -}; - -// One parsed NO_PROXY list entry. +// Types referenced by ClientImpl members. NoProxyEntry is the element +// type of ClientImpl::no_proxy_entries_; NormalizedTarget is cached on +// the client for the lifetime of host_. enum class NoProxyKind { Wildcard, // "*" HostnameSuffix, // "example.com" or ".example.com" @@ -2049,8 +2042,6 @@ struct NoProxyEntry { int prefix_bits = 0; }; -// Pre-parsed form of the connection's target host, ready for matching -// against a NoProxyEntry list without re-running inet_pton on every entry. struct NormalizedTarget { std::string hostname; // lowercase; brackets and trailing dot removed bool is_ipv4 = false; @@ -2059,15 +2050,6 @@ struct NormalizedTarget { struct in6_addr v6 {}; }; -// Proxy-related environment variables, parsed. -struct ProxyEnvSettings { - bool have_http_proxy = false; - bool have_https_proxy = false; - ProxyUrl http_proxy; - ProxyUrl https_proxy; - std::vector no_proxy; -}; - } // namespace detail class ClientImpl { @@ -2434,6 +2416,11 @@ class ClientImpl { std::vector no_proxy_entries_; + // Cached normalization of host_ (which is const) so the per-request + // gate doesn't re-allocate / re-inet_pton on every call. + mutable detail::NormalizedTarget host_normalized_; + mutable bool host_normalized_valid_ = false; + mutable std::mutex logger_mutex_; Logger logger_; ErrorLogger error_logger_; @@ -3071,51 +3058,6 @@ struct FileStat { std::string make_host_and_port_string(const std::string &host, int port, bool is_ssl); -// Parses "http(s)://[user[:pass]@]host[:port][/...]" into out. Rejects -// any input containing CR, LF, NUL, or other control characters; rejects -// schemes other than http/https; rejects ports outside [1, 65535]; for -// IPv6 literals (in [...]) requires the address to parse via inet_pton. -// When the port is omitted, defaults to 80 (http) / 443 (https). -bool parse_proxy_url(const std::string &url, ProxyUrl &out); - -// Parses a single NO_PROXY token (already trimmed, non-empty). Returns -// false on any malformed input. Port-specific entries ("host:port") are -// rejected by design: cpp-httplib's other host-keyed APIs (e.g. -// set_hostname_addr_map) are also keyed on hostname only, so supporting -// port granularity for NO_PROXY alone would be inconsistent. -bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out); - -// Parses a comma-separated NO_PROXY value into a vector of entries. -// Malformed tokens are silently dropped (matching curl/Go behavior; -// failing loud on a single bad entry would break working deployments). -std::vector parse_no_proxy_list(const std::string &value); - -NormalizedTarget normalize_target(const std::string &host); - -// Reads proxy-related environment variables. Reads: -// https_proxy / HTTPS_PROXY → ProxyEnvSettings::https_proxy -// http_proxy (lowercase only — see security note in set_proxy_from_env) -// → ProxyEnvSettings::http_proxy -// no_proxy / NO_PROXY → ProxyEnvSettings::no_proxy -// Variables that fail to parse are treated as unset. -ProxyEnvSettings read_proxy_env(); - -// CIDR membership tests. -// prefix_bits in [0, 32] for IPv4, [0, 128] for IPv6. -// prefix_bits == 0 always returns true for the matching family. -bool ipv4_in_cidr(const struct in_addr &ip, const struct in_addr &net, - int prefix_bits); -bool ipv6_in_cidr(const struct in6_addr &ip, const struct in6_addr &net, - int prefix_bits); - -// Returns true if the (already normalized) target matches any NO_PROXY -// entry. Hostname suffix matching uses the dot-boundary rule so that -// "evilexample.com" does NOT match "example.com". IPv4 and IPv6 entries -// match only their own address family; "::ffff:1.2.3.4" is not -// cross-matched against IPv4 entries. -bool host_matches_no_proxy(const NormalizedTarget &target, - const std::vector &entries); - std::string trim_copy(const std::string &s); void divide( @@ -10643,6 +10585,36 @@ make_host_and_port_string_always_port(const std::string &host, int port) { return prepare_host_string(host) + ":" + std::to_string(port); } +// Implementation-only types and helpers for #2446. These do not need to +// be visible to ClientImpl's class definition because they appear only +// in helper bodies below. +struct ProxyUrl { + std::string host; + int port = -1; + std::string username; + std::string password; +}; + +struct ProxyEnvSettings { + bool have_http_proxy = false; + bool have_https_proxy = false; + ProxyUrl http_proxy; + ProxyUrl https_proxy; + std::vector no_proxy; +}; + +bool parse_proxy_url(const std::string &url, ProxyUrl &out); +bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out); +std::vector parse_no_proxy_list(const std::string &value); +NormalizedTarget normalize_target(const std::string &host); +ProxyEnvSettings read_proxy_env(); +bool ipv4_in_cidr(const struct in_addr &ip, const struct in_addr &net, + int prefix_bits); +bool ipv6_in_cidr(const struct in6_addr &ip, const struct in6_addr &net, + int prefix_bits); +bool host_matches_no_proxy(const NormalizedTarget &target, + const std::vector &entries); + inline bool parse_proxy_url(const std::string &url, ProxyUrl &out) { if (url.empty()) { return false; } @@ -10656,11 +10628,11 @@ inline bool parse_proxy_url(const std::string &url, ProxyUrl &out) { // Scheme: only http and https are supported. std::size_t scheme_end = 0; + bool is_https = false; if (url.compare(0, 7, "http://") == 0) { - out.scheme = "http"; scheme_end = 7; } else if (url.compare(0, 8, "https://") == 0) { - out.scheme = "https"; + is_https = true; scheme_end = 8; } else { return false; @@ -10725,7 +10697,7 @@ inline bool parse_proxy_url(const std::string &url, ProxyUrl &out) { out.host = std::move(host); if (port_str.empty()) { - out.port = (out.scheme == "https") ? 443 : 80; + out.port = is_https ? 443 : 80; } else { int port = 0; auto r = @@ -10841,12 +10813,11 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { inline std::vector parse_no_proxy_list(const std::string &value) { std::vector entries; if (value.empty()) { return entries; } + // detail::split already trims each token and skips empty ones. split(value.data(), value.data() + value.size(), ',', [&](const char *b, const char *e) { - auto token = trim_copy(std::string(b, e)); - if (token.empty()) { return; } NoProxyEntry entry; - if (parse_no_proxy_entry(token, entry)) { + if (parse_no_proxy_entry(std::string(b, e), entry)) { entries.push_back(std::move(entry)); } }); @@ -12775,6 +12746,17 @@ inline bool ClientImpl::is_proxy_enabled_for_host(const std::string &host) const { if (proxy_host_.empty() || proxy_port_ == -1) { return false; } if (no_proxy_entries_.empty()) { return true; } + // host_ is const, so its normalization is invariant for the lifetime + // of the client. Cache the common case (host == host_) so the gate + // stays O(no_proxy_entries_.size()) per call. Cross-host calls (only + // setup_redirect_client passing next_host) compute every time. + if (host == host_) { + if (!host_normalized_valid_) { + host_normalized_ = detail::normalize_target(host_); + host_normalized_valid_ = true; + } + return !detail::host_matches_no_proxy(host_normalized_, no_proxy_entries_); + } auto target = detail::normalize_target(host); return !detail::host_matches_no_proxy(target, no_proxy_entries_); } @@ -13598,14 +13580,12 @@ inline void ClientImpl::setup_redirect_client(ClientType &client, // host. This function is only called for cross-host redirects; same-host // redirects are handled directly in ClientImpl::redirect(). - // Always carry the NO_PROXY policy across redirects so the bypass list is - // re-evaluated against the redirect target rather than the original host. + // The bypass list must follow across redirects so it is re-evaluated + // against the redirect target. Without this, a redirect to a NO_PROXY + // host would still go through the proxy (and carry Proxy-Authorization). client.no_proxy_entries_ = no_proxy_entries_; - // Setup proxy configuration (CRITICAL ORDER - proxy must be set - // before proxy auth). Gating on is_proxy_enabled_for_host(next_host) - // ensures Proxy-Authorization is not forwarded when the redirect target - // is in NO_PROXY (analog of the cross-origin auth-leak class of bugs). + // Proxy host/port must be set BEFORE proxy auth. if (is_proxy_enabled_for_host(next_host)) { // First set proxy host and port client.set_proxy(proxy_host_, proxy_port_); @@ -13701,19 +13681,6 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req, } } - // Only inject Proxy-Authorization when the proxy is actually being used - // for this target. Otherwise NO_PROXY-matched requests would leak - // proxy credentials directly to the destination server. - if (is_proxy_enabled_for_host(host_)) { - if (!proxy_basic_auth_username_.empty() && - !proxy_basic_auth_password_.empty()) { - if (!req.has_header("Proxy-Authorization")) { - req.headers.insert(make_basic_authentication_header( - proxy_basic_auth_username_, proxy_basic_auth_password_, true)); - } - } - } - if (!bearer_token_auth_token_.empty()) { if (!req.has_header("Authorization")) { req.headers.insert(make_bearer_token_authentication_header( @@ -13721,12 +13688,20 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req, } } + // Proxy-Authorization is only sent when the proxy is actually used for + // this target — otherwise NO_PROXY-matched requests would leak proxy + // credentials directly to the destination server. if (is_proxy_enabled_for_host(host_)) { - if (!proxy_bearer_token_auth_token_.empty()) { - if (!req.has_header("Proxy-Authorization")) { - req.headers.insert(make_bearer_token_authentication_header( - proxy_bearer_token_auth_token_, true)); - } + if (!proxy_basic_auth_username_.empty() && + !proxy_basic_auth_password_.empty() && + !req.has_header("Proxy-Authorization")) { + req.headers.insert(make_basic_authentication_header( + proxy_basic_auth_username_, proxy_basic_auth_password_, true)); + } + if (!proxy_bearer_token_auth_token_.empty() && + !req.has_header("Proxy-Authorization")) { + req.headers.insert(make_bearer_token_authentication_header( + proxy_bearer_token_auth_token_, true)); } } @@ -15165,10 +15140,9 @@ inline bool ClientImpl::set_proxy_from_env() { auto env = detail::read_proxy_env(); bool applied = false; - // is_ssl() is virtual; SSLClient overrides to return true. So an - // SSLClient instance picks https_proxy and a plain ClientImpl picks - // http_proxy. There is intentionally no cross-scheme fallback — - // http_proxy and https_proxy describe different target traffic. + // No cross-scheme fallback: http_proxy and https_proxy describe + // different traffic, mixing them could send HTTPS-target credentials + // through a proxy the user only authorized for HTTP. const detail::ProxyUrl *picked = nullptr; if (is_ssl() && env.have_https_proxy) { picked = &env.https_proxy; diff --git a/test/test.cc b/test/test.cc index affeaf78d7..889b38a768 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18631,7 +18631,6 @@ TEST(NoProxyTest, ProxyAuthorizationSentWhenNotBypassed) { // -------------------------------------------------- TEST(NoProxyTest, EmptyNoProxyKeepsProxyOn) { - // Default behavior unchanged when set_no_proxy is never called. ProxyAndTargetServers s; auto cli = make_client("anything.test", s); @@ -18645,8 +18644,6 @@ TEST(NoProxyTest, EmptyNoProxyKeepsProxyOn) { // ------------------------------------------------------ TEST(NoProxyTest, PortSpecificEntryRejected) { - // "host:port" is intentionally unsupported; must be silently dropped - // so it does not match anything. ProxyAndTargetServers s; auto cli = make_client("example.com", s); cli->set_no_proxy({"example.com:8080"}); @@ -18658,8 +18655,6 @@ TEST(NoProxyTest, PortSpecificEntryRejected) { } TEST(NoProxyTest, EmptyAndWhitespaceEntriesDropped) { - // Empty/whitespace tokens must not match anything (especially not - // every host). ProxyAndTargetServers s; auto cli = make_client("anything.test", s); cli->set_no_proxy({"", " ", "\t"}); From 73115a222a443b6ab8c1588a2d34c486ffdcba86 Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 10 May 2026 16:44:11 +0900 Subject: [PATCH 10/22] Inline proxy URL parsing and env reading; drop intermediate structs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous design had two intermediate structs that existed only to ferry parsed values between helper functions and the consuming method: - detail::ProxyUrl: filled by parse_proxy_url, drained back into proxy_host_ / proxy_port_ / proxy_basic_auth_* by set_proxy_from_env. - detail::ProxyEnvSettings: bundle of two ProxyUrl + a NoProxyEntry vector returned by read_proxy_env, drained by set_proxy_from_env. Both bundles had exactly one producer and exactly one consumer. Drop them and let the parsing flow directly into ClientImpl state: - New private member ClientImpl::apply_proxy_url(url) parses a proxy URL and, on success, assigns the result to proxy_host_, proxy_port_, and proxy_basic_auth_*. Same validation as before (CRLF rejection, scheme allowlist, port range, IPv6 bracket validation), same commit- on-success ordering — the local variables are kept until every check has passed so a malformed URL leaves no partial state. - set_proxy_from_env now reads getenv() directly, dispatches between https_proxy / http_proxy via virtual is_ssl(), and applies via apply_proxy_url. NO_PROXY is parsed in place via parse_no_proxy_list. Net effect: - Two structs and two free helper functions removed (~150 lines of declaration + body deleted). - set_proxy_from_env body grows ~20 lines (still well under 50). - Per-request hot path is unchanged (NoProxyEntry / NormalizedTarget cache stays). Setup path is marginally faster (no intermediate string copies through ProxyUrl / ProxyEnvSettings). 635 unit tests pass under both the regular and split builds. --- httplib.h | 369 +++++++++++++++++++++--------------------------------- 1 file changed, 142 insertions(+), 227 deletions(-) diff --git a/httplib.h b/httplib.h index f32be3cd97..25cbf5c061 100644 --- a/httplib.h +++ b/httplib.h @@ -2024,9 +2024,8 @@ inline ssize_t read_body_content(Stream *stream, BodyReader &br, char *buf, class decompressor; -// Types referenced by ClientImpl members. NoProxyEntry is the element -// type of ClientImpl::no_proxy_entries_; NormalizedTarget is cached on -// the client for the lifetime of host_. +// NoProxyEntry / NormalizedTarget are referenced as ClientImpl member +// types, so they must be defined above the split.py BORDER. enum class NoProxyKind { Wildcard, // "*" HostnameSuffix, // "example.com" or ".example.com" @@ -2266,39 +2265,7 @@ class ClientImpl { void set_proxy_basic_auth(const std::string &username, const std::string &password); void set_proxy_bearer_token_auth(const std::string &token); - - // Configures the NO_PROXY bypass list. Each pattern is one of: - // "*" — bypass the proxy for every target - // "example.com" — match example.com and any subdomain (with the - // dot-boundary rule, so "evilexample.com" does - // NOT match) - // ".example.com" — equivalent (leading dot is informational) - // "10.0.0.0/8" — IPv4 CIDR block (or single IP, treated as /32) - // "fe80::/10" — IPv6 CIDR block (or single IP, treated as /128) - // Hostname matching is case-insensitive. IP comparisons are normalized - // through inet_pton so "127.0.0.1" cannot be bypassed via alternate - // string forms. Malformed entries are silently dropped. Calling this - // method replaces any previously configured list; there is no append. void set_no_proxy(const std::vector &patterns); - - // Configures the client from proxy-related environment variables: - // - HTTPS clients (SSLClient) read https_proxy / HTTPS_PROXY - // - HTTP clients read http_proxy (lowercase only) - // - Both also read no_proxy / NO_PROXY - // Returns true if at least one variable was found and applied. - // - // Security note: only the lowercase http_proxy is read. The uppercase - // form is intentionally ignored to mitigate the httpoxy class of bugs - // (CVE-2016-5385): in CGI/FastCGI environments, HTTP_PROXY collides - // with the HTTP_* namespace used to expose request headers, allowing - // a remote attacker to set the proxy URL via the Proxy: request - // header. cpp-httplib follows curl, Go and Python requests in only - // honoring the lowercase form. https_proxy/HTTPS_PROXY and - // no_proxy/NO_PROXY are safe in either case. - // - // Threading: this function reads getenv() synchronously; call it once - // at startup before issuing any requests. Concurrent setenv from other - // threads while this function runs is undefined. bool set_proxy_from_env(); void set_logger(Logger logger); @@ -2327,6 +2294,12 @@ class ClientImpl { bool is_proxy_enabled_for_host(const std::string &host) const; + // Parses "http(s)://[user[:pass]@]host[:port][/...]" and, on success, + // applies the result to proxy_host_ / proxy_port_ / proxy_basic_auth_*. + // Rejects control characters, non-http(s) schemes, and ports outside + // [1, 65535]. Defaults port to 80 / 443 from the scheme. + bool apply_proxy_url(const std::string &url); + // All of: // shutdown_ssl // shutdown_socket @@ -2416,8 +2389,7 @@ class ClientImpl { std::vector no_proxy_entries_; - // Cached normalization of host_ (which is const) so the per-request - // gate doesn't re-allocate / re-inet_pton on every call. + // Memoized normalize_target(host_); host_ is const, so this is invariant. mutable detail::NormalizedTarget host_normalized_; mutable bool host_normalized_valid_ = false; @@ -10585,29 +10557,10 @@ make_host_and_port_string_always_port(const std::string &host, int port) { return prepare_host_string(host) + ":" + std::to_string(port); } -// Implementation-only types and helpers for #2446. These do not need to -// be visible to ClientImpl's class definition because they appear only -// in helper bodies below. -struct ProxyUrl { - std::string host; - int port = -1; - std::string username; - std::string password; -}; - -struct ProxyEnvSettings { - bool have_http_proxy = false; - bool have_https_proxy = false; - ProxyUrl http_proxy; - ProxyUrl https_proxy; - std::vector no_proxy; -}; - -bool parse_proxy_url(const std::string &url, ProxyUrl &out); +// Implementation-only NO_PROXY helpers. bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out); std::vector parse_no_proxy_list(const std::string &value); NormalizedTarget normalize_target(const std::string &host); -ProxyEnvSettings read_proxy_env(); bool ipv4_in_cidr(const struct in_addr &ip, const struct in_addr &net, int prefix_bits); bool ipv6_in_cidr(const struct in6_addr &ip, const struct in6_addr &net, @@ -10615,103 +10568,6 @@ bool ipv6_in_cidr(const struct in6_addr &ip, const struct in6_addr &net, bool host_matches_no_proxy(const NormalizedTarget &target, const std::vector &entries); -inline bool parse_proxy_url(const std::string &url, ProxyUrl &out) { - if (url.empty()) { return false; } - - // Reject control characters anywhere in the input. CRLF/NUL would let a - // malicious env value inject extra header lines into a CONNECT request or - // a Proxy-Authorization header. - for (auto c : url) { - auto uc = static_cast(c); - if (uc < 0x20 || uc == 0x7F) { return false; } - } - - // Scheme: only http and https are supported. - std::size_t scheme_end = 0; - bool is_https = false; - if (url.compare(0, 7, "http://") == 0) { - scheme_end = 7; - } else if (url.compare(0, 8, "https://") == 0) { - is_https = true; - scheme_end = 8; - } else { - return false; - } - - // Authority terminates at the first '/', '?', or '#', or the end of input. - auto authority_end = url.find_first_of("/?#", scheme_end); - if (authority_end == std::string::npos) { authority_end = url.size(); } - auto authority = url.substr(scheme_end, authority_end - scheme_end); - if (authority.empty()) { return false; } - - // Split userinfo from host_port on the LAST '@' so passwords containing '@' - // are preserved. - std::string userinfo; - std::string host_port; - auto at_pos = authority.rfind('@'); - if (at_pos != std::string::npos) { - userinfo = authority.substr(0, at_pos); - host_port = authority.substr(at_pos + 1); - } else { - host_port = authority; - } - if (host_port.empty()) { return false; } - - if (!userinfo.empty()) { - auto colon = userinfo.find(':'); - if (colon == std::string::npos) { - out.username = userinfo; - } else { - out.username = userinfo.substr(0, colon); - out.password = userinfo.substr(colon + 1); - } - } - - // host_port: "[ipv6]:port", "[ipv6]", "host:port", or "host". - std::string host; - std::string port_str; - if (host_port.front() == '[') { - auto rb = host_port.find(']'); - if (rb == std::string::npos) { return false; } - host = host_port.substr(1, rb - 1); - if (host.empty()) { return false; } - struct in6_addr tmp; - if (inet_pton(AF_INET6, host.c_str(), &tmp) != 1) { return false; } - auto rest = host_port.substr(rb + 1); - if (!rest.empty()) { - if (rest.front() != ':') { return false; } - port_str = rest.substr(1); - if (port_str.empty()) { return false; } - } - } else { - auto colon = host_port.find(':'); - if (colon == std::string::npos) { - host = host_port; - } else { - host = host_port.substr(0, colon); - port_str = host_port.substr(colon + 1); - if (port_str.empty()) { return false; } - } - if (host.empty()) { return false; } - } - out.host = std::move(host); - - if (port_str.empty()) { - out.port = is_https ? 443 : 80; - } else { - int port = 0; - auto r = - from_chars(port_str.data(), port_str.data() + port_str.size(), port); - if (r.ec != std::errc{} || r.ptr != port_str.data() + port_str.size()) { - return false; - } - if (port < 1 || port > 65535) { return false; } - out.port = port; - } - - return true; -} - inline bool ipv4_in_cidr(const struct in_addr &ip, const struct in_addr &net, int prefix_bits) { if (prefix_bits < 0 || prefix_bits > 32) { return false; } @@ -10744,14 +10600,12 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { return true; } - // Split on '/' for optional CIDR prefix. auto slash = token.find('/'); std::string addr_part = (slash == std::string::npos) ? token : token.substr(0, slash); std::string prefix_part = (slash == std::string::npos) ? std::string() : token.substr(slash + 1); - // Try IPv4. struct in_addr v4; if (inet_pton(AF_INET, addr_part.c_str(), &v4) == 1) { int prefix = 32; @@ -10770,7 +10624,6 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { return true; } - // Try IPv6. struct in6_addr v6; if (inet_pton(AF_INET6, addr_part.c_str(), &v6) == 1) { int prefix = 128; @@ -10789,13 +10642,11 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { return true; } - // Not an IP. A '/' here means a prefix on a non-IP entry, which is invalid. + // A '/' on a non-IP token means a CIDR prefix without an address. Reject. if (slash != std::string::npos) { return false; } - - // ':' in a non-IP token is a port-specific entry, which we don't support. + // Port-specific entries (host:port) are not supported. if (token.find(':') != std::string::npos) { return false; } - // Hostname suffix. Lowercase, strip leading/trailing dots. std::string hostname = case_ignore::to_lower(token); while (!hostname.empty() && hostname.front() == '.') { hostname.erase(hostname.begin()); @@ -10828,12 +10679,13 @@ inline NormalizedTarget normalize_target(const std::string &host) { NormalizedTarget t; std::string h = host; - // Strip surrounding brackets if present (IPv6 literal in URL form). + // Strip "[ipv6]" brackets if the host arrived in URL form. if (h.size() >= 2 && h.front() == '[' && h.back() == ']') { h = h.substr(1, h.size() - 2); } - // Strip a single trailing dot (FQDN canonicalization). + // Strip a single trailing dot so "example.com." canonicalizes to + // "example.com". if (!h.empty() && h.back() == '.') { h.pop_back(); } t.hostname = case_ignore::to_lower(h); @@ -10865,12 +10717,10 @@ inline bool host_matches_no_proxy(const NormalizedTarget &target, } break; case NoProxyKind::HostnameSuffix: - // IP targets do not match hostname patterns. if (target.is_ipv4 || target.is_ipv6) { break; } - // Exact match. if (target.hostname == e.hostname_pattern) { return true; } - // Dot-boundary suffix match: target ends with "." + pattern. This is - // what prevents "evilexample.com" from matching "example.com". + // Dot-boundary suffix match: prevents "evilexample.com" from matching + // an entry of "example.com". if (target.hostname.size() > e.hostname_pattern.size() + 1) { auto offset = target.hostname.size() - e.hostname_pattern.size(); if (target.hostname[offset - 1] == '.' && @@ -10885,42 +10735,6 @@ inline bool host_matches_no_proxy(const NormalizedTarget &target, return false; } -inline ProxyEnvSettings read_proxy_env() { - ProxyEnvSettings out; - - auto try_url = [](const char *value, ProxyUrl &dst) -> bool { - if (!value || *value == '\0') { return false; } - return parse_proxy_url(value, dst); - }; - - // http_proxy: lowercase ONLY. The uppercase form is ignored to mitigate - // httpoxy (CVE-2016-5385): in CGI/FastCGI environments, HTTP_PROXY - // collides with the HTTP_* namespace used to expose request headers, - // letting a remote attacker control the proxy URL via the "Proxy:" - // header. - if (try_url(std::getenv("http_proxy"), out.http_proxy)) { - out.have_http_proxy = true; - } - - // https_proxy is safe in either case: there is no HTTPS_* CGI - // collision because the variable does not start with HTTP_. - if (try_url(std::getenv("https_proxy"), out.https_proxy)) { - out.have_https_proxy = true; - } else if (try_url(std::getenv("HTTPS_PROXY"), out.https_proxy)) { - out.have_https_proxy = true; - } - - const char *no_proxy_value = std::getenv("no_proxy"); - if (!no_proxy_value || *no_proxy_value == '\0') { - no_proxy_value = std::getenv("NO_PROXY"); - } - if (no_proxy_value && *no_proxy_value != '\0') { - out.no_proxy = parse_no_proxy_list(no_proxy_value); - } - - return out; -} - template inline bool check_and_write_headers(Stream &strm, Headers &headers, T header_writer, Error &error) { @@ -12746,10 +12560,8 @@ inline bool ClientImpl::is_proxy_enabled_for_host(const std::string &host) const { if (proxy_host_.empty() || proxy_port_ == -1) { return false; } if (no_proxy_entries_.empty()) { return true; } - // host_ is const, so its normalization is invariant for the lifetime - // of the client. Cache the common case (host == host_) so the gate - // stays O(no_proxy_entries_.size()) per call. Cross-host calls (only - // setup_redirect_client passing next_host) compute every time. + // host_ is const so its normalized form is invariant; cache it. The + // cross-host path (setup_redirect_client passing next_host) re-normalizes. if (host == host_) { if (!host_normalized_valid_) { host_normalized_ = detail::normalize_target(host_); @@ -15136,35 +14948,138 @@ inline void ClientImpl::set_no_proxy(const std::vector &patterns) { no_proxy_entries_ = std::move(parsed); } -inline bool ClientImpl::set_proxy_from_env() { - auto env = detail::read_proxy_env(); - bool applied = false; +inline bool ClientImpl::apply_proxy_url(const std::string &url) { + if (url.empty()) { return false; } - // No cross-scheme fallback: http_proxy and https_proxy describe - // different traffic, mixing them could send HTTPS-target credentials - // through a proxy the user only authorized for HTTP. - const detail::ProxyUrl *picked = nullptr; - if (is_ssl() && env.have_https_proxy) { - picked = &env.https_proxy; - } else if (!is_ssl() && env.have_http_proxy) { - picked = &env.http_proxy; + // CRLF / NUL would let a malicious env value inject extra header lines + // into a CONNECT request or a Proxy-Authorization header. + for (auto c : url) { + auto uc = static_cast(c); + if (uc < 0x20 || uc == 0x7F) { return false; } } - if (picked) { - proxy_host_ = picked->host; - proxy_port_ = picked->port; - if (!picked->username.empty()) { - proxy_basic_auth_username_ = picked->username; - proxy_basic_auth_password_ = picked->password; + std::size_t scheme_end = 0; + bool is_https = false; + if (url.compare(0, 7, "http://") == 0) { + scheme_end = 7; + } else if (url.compare(0, 8, "https://") == 0) { + is_https = true; + scheme_end = 8; + } else { + return false; + } + + auto authority_end = url.find_first_of("/?#", scheme_end); + if (authority_end == std::string::npos) { authority_end = url.size(); } + auto authority = url.substr(scheme_end, authority_end - scheme_end); + if (authority.empty()) { return false; } + + // Split on the LAST '@' so passwords containing '@' are preserved. + std::string user; + std::string pass; + std::string host_port; + auto at_pos = authority.rfind('@'); + if (at_pos != std::string::npos) { + auto userinfo = authority.substr(0, at_pos); + host_port = authority.substr(at_pos + 1); + auto colon = userinfo.find(':'); + if (colon == std::string::npos) { + user = std::move(userinfo); + } else { + user = userinfo.substr(0, colon); + pass = userinfo.substr(colon + 1); } - applied = true; + } else { + host_port = authority; + } + if (host_port.empty()) { return false; } + + // host_port forms: "[ipv6]:port", "[ipv6]", "host:port", "host". + std::string host; + std::string port_str; + if (host_port.front() == '[') { + auto rb = host_port.find(']'); + if (rb == std::string::npos) { return false; } + host = host_port.substr(1, rb - 1); + if (host.empty()) { return false; } + struct in6_addr tmp; + if (inet_pton(AF_INET6, host.c_str(), &tmp) != 1) { return false; } + auto rest = host_port.substr(rb + 1); + if (!rest.empty()) { + if (rest.front() != ':') { return false; } + port_str = rest.substr(1); + if (port_str.empty()) { return false; } + } + } else { + auto colon = host_port.find(':'); + if (colon == std::string::npos) { + host = host_port; + } else { + host = host_port.substr(0, colon); + port_str = host_port.substr(colon + 1); + if (port_str.empty()) { return false; } + } + if (host.empty()) { return false; } } - if (!env.no_proxy.empty()) { - no_proxy_entries_ = std::move(env.no_proxy); + int port; + if (port_str.empty()) { + port = is_https ? 443 : 80; + } else { + int parsed = 0; + auto r = detail::from_chars(port_str.data(), + port_str.data() + port_str.size(), parsed); + if (r.ec != std::errc{} || r.ptr != port_str.data() + port_str.size()) { + return false; + } + if (parsed < 1 || parsed > 65535) { return false; } + port = parsed; + } + + // Commit only after every check has passed. + proxy_host_ = std::move(host); + proxy_port_ = port; + if (!user.empty()) { + proxy_basic_auth_username_ = std::move(user); + proxy_basic_auth_password_ = std::move(pass); + } + return true; +} + +inline bool ClientImpl::set_proxy_from_env() { + bool applied = false; + + // No cross-scheme fallback: http_proxy and https_proxy describe different + // traffic, mixing them could send HTTPS-target credentials through a + // proxy the user only authorized for HTTP. + // + // For http_proxy, lowercase ONLY: the uppercase form is poisoned in + // CGI/FastCGI environments by the "Proxy:" request header (httpoxy / + // CVE-2016-5385). HTTPS_PROXY is safe in either case because the name + // does not start with HTTP_. + const char *url_env = nullptr; + if (is_ssl()) { + url_env = std::getenv("https_proxy"); + if (!url_env || *url_env == '\0') { url_env = std::getenv("HTTPS_PROXY"); } + } else { + url_env = std::getenv("http_proxy"); + } + if (url_env && *url_env != '\0' && apply_proxy_url(url_env)) { applied = true; } + const char *no_proxy_env = std::getenv("no_proxy"); + if (!no_proxy_env || *no_proxy_env == '\0') { + no_proxy_env = std::getenv("NO_PROXY"); + } + if (no_proxy_env && *no_proxy_env != '\0') { + auto entries = detail::parse_no_proxy_list(no_proxy_env); + if (!entries.empty()) { + no_proxy_entries_ = std::move(entries); + applied = true; + } + } + return applied; } From d0f1750626c45b893171bff91b7f16a2c059664e Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 10 May 2026 16:57:05 +0900 Subject: [PATCH 11/22] Trim doc comments to match the rest of httplib.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new code carried inline doc comments (15-line set_no_proxy block, 18-line set_proxy_from_env block, plus narrating comments inside parser bodies, plus section dividers in the test file) that were heavy compared to the rest of the codebase — neighboring setters like set_proxy / set_proxy_basic_auth carry no doc at all, the test file does not use sub-section dividers, and the README / cookbook already document the behavior in detail. Removed: - Public-API doc blocks on set_no_proxy and set_proxy_from_env. - Narrating comments inside parse_no_proxy_entry, normalize_target, apply_proxy_url, host_matches_no_proxy that were just describing the obvious code structure. - Multi-line BORDER-rationale meta comments. - In-test sub-section dividers ("// ---- Hostname suffix matching", etc.) and per-class doc comments on the test fixtures. - Test-side comments that paraphrased their own test name. - Redundant ordering comments inside setup_redirect_client. Kept: - Security WHY comments (CRLF rejection, dot-boundary suffix matching, httpoxy / CVE-2016-5385, GHSA-6hrp-7fq9-3qv2 analog, CVE-2026-21428). - Regression-target WHY comments (UB shift on prefix=0). - Non-obvious external knowledge (detail::split already trims). 635 unit tests still pass under both the regular and split builds. --- httplib.h | 12 ------------ test/test.cc | 49 ------------------------------------------------- 2 files changed, 61 deletions(-) diff --git a/httplib.h b/httplib.h index 25cbf5c061..053cf8dc65 100644 --- a/httplib.h +++ b/httplib.h @@ -2024,8 +2024,6 @@ inline ssize_t read_body_content(Stream *stream, BodyReader &br, char *buf, class decompressor; -// NoProxyEntry / NormalizedTarget are referenced as ClientImpl member -// types, so they must be defined above the split.py BORDER. enum class NoProxyKind { Wildcard, // "*" HostnameSuffix, // "example.com" or ".example.com" @@ -2293,11 +2291,6 @@ class ClientImpl { Response &res, bool &success, Error &error); bool is_proxy_enabled_for_host(const std::string &host) const; - - // Parses "http(s)://[user[:pass]@]host[:port][/...]" and, on success, - // applies the result to proxy_host_ / proxy_port_ / proxy_basic_auth_*. - // Rejects control characters, non-http(s) schemes, and ports outside - // [1, 65535]. Defaults port to 80 / 443 from the scheme. bool apply_proxy_url(const std::string &url); // All of: @@ -2389,7 +2382,6 @@ class ClientImpl { std::vector no_proxy_entries_; - // Memoized normalize_target(host_); host_ is const, so this is invariant. mutable detail::NormalizedTarget host_normalized_; mutable bool host_normalized_valid_ = false; @@ -10557,7 +10549,6 @@ make_host_and_port_string_always_port(const std::string &host, int port) { return prepare_host_string(host) + ":" + std::to_string(port); } -// Implementation-only NO_PROXY helpers. bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out); std::vector parse_no_proxy_list(const std::string &value); NormalizedTarget normalize_target(const std::string &host); @@ -10679,7 +10670,6 @@ inline NormalizedTarget normalize_target(const std::string &host) { NormalizedTarget t; std::string h = host; - // Strip "[ipv6]" brackets if the host arrived in URL form. if (h.size() >= 2 && h.front() == '[' && h.back() == ']') { h = h.substr(1, h.size() - 2); } @@ -13397,7 +13387,6 @@ inline void ClientImpl::setup_redirect_client(ClientType &client, // host would still go through the proxy (and carry Proxy-Authorization). client.no_proxy_entries_ = no_proxy_entries_; - // Proxy host/port must be set BEFORE proxy auth. if (is_proxy_enabled_for_host(next_host)) { // First set proxy host and port client.set_proxy(proxy_host_, proxy_port_); @@ -14994,7 +14983,6 @@ inline bool ClientImpl::apply_proxy_url(const std::string &url) { } if (host_port.empty()) { return false; } - // host_port forms: "[ipv6]:port", "[ipv6]", "host:port", "host". std::string host; std::string port_str; if (host_port.front() == '[') { diff --git a/test/test.cc b/test/test.cc index 889b38a768..37a3f45844 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18277,14 +18277,9 @@ TEST(KeepAliveTest, DeleteWithoutContentLengthDoesNotEatNextRequest) { EXPECT_EQ(2, delete_count.load()); } -// ============================================================================= -// NO_PROXY / set_proxy_from_env (#2446) -// ============================================================================= - namespace no_proxy_test { #ifndef _WIN32 -// RAII helper that saves, sets, and restores an environment variable. class ScopedEnv { public: ScopedEnv(const char *name, const char *value) : name_(name) { @@ -18312,9 +18307,6 @@ class ScopedEnv { }; #endif // !_WIN32 -// In-process proxy mock + direct target. Each request that arrives bumps -// the corresponding counter, so a test can assert "this request went via -// the proxy" or "this request bypassed the proxy". class ProxyAndTargetServers { public: ProxyAndTargetServers() { @@ -18370,8 +18362,6 @@ class ProxyAndTargetServers { std::atomic last_had_proxy_authz_{false}; }; -// Helper: build a client targeted at `host` with a hostname mapping to -// 127.0.0.1, the proxy pointed at the mock. inline std::unique_ptr make_client(const std::string &host, ProxyAndTargetServers &s) { auto cli = detail::make_unique(host, s.target_port()); @@ -18385,9 +18375,6 @@ inline std::unique_ptr make_client(const std::string &host, using no_proxy_test::make_client; using no_proxy_test::ProxyAndTargetServers; -// ---- Hostname suffix matching: dot-boundary rule -// ----------------------------- - TEST(NoProxyTest, ExactHostnameBypasses) { ProxyAndTargetServers s; auto cli = make_client("example.com", s); @@ -18469,9 +18456,6 @@ TEST(NoProxyTest, TrailingDotIsNormalized) { EXPECT_EQ(1, s.target_hits()); } -// ---- Wildcard -// ---------------------------------------------------------------- - TEST(NoProxyTest, WildcardBypassesEverything) { ProxyAndTargetServers s; auto cli = make_client("anything.invalid.test", s); @@ -18483,9 +18467,6 @@ TEST(NoProxyTest, WildcardBypassesEverything) { EXPECT_EQ(1, s.target_hits()); } -// ---- IP normalization -// -------------------------------------------------------- - TEST(NoProxyTest, IPv4LiteralExactMatch) { ProxyAndTargetServers s; Client cli("127.0.0.1", s.target_port()); @@ -18499,9 +18480,6 @@ TEST(NoProxyTest, IPv4LiteralExactMatch) { } TEST(NoProxyTest, IPv6LiteralExactMatchAcrossEquivalentForms) { - // Different string forms of the same IPv6 address ("::1" and the - // expanded "0:0:0:0:0:0:0:1") must match because both go through - // inet_pton during normalization. ProxyAndTargetServers s; Client cli("0:0:0:0:0:0:0:1", s.target_port()); cli.set_hostname_addr_map({{"0:0:0:0:0:0:0:1", "127.0.0.1"}}); @@ -18530,9 +18508,6 @@ TEST(NoProxyTest, IPv4MappedIPv6IsNotCrossMatchedAgainstIPv4Entry) { EXPECT_EQ(0, s.target_hits()); } -// ---- CIDR matching -// ----------------------------------------------------------- - TEST(NoProxyTest, IPv4CidrMatch) { ProxyAndTargetServers s; Client cli("127.0.0.1", s.target_port()); @@ -18572,7 +18547,6 @@ TEST(NoProxyTest, IPv4CidrPrefixZeroMatchesAll) { } TEST(NoProxyTest, IPv4CidrSingleHostNoSlash) { - // Bare IP without a /prefix is treated as /32 (single host). ProxyAndTargetServers s; Client cli("127.0.0.1", s.target_port()); cli.set_proxy("127.0.0.1", s.proxy_port()); @@ -18585,8 +18559,6 @@ TEST(NoProxyTest, IPv4CidrSingleHostNoSlash) { } TEST(NoProxyTest, MalformedCidrPrefixIsDropped) { - // /33 on IPv4 is invalid and must be silently dropped during parsing, - // leaving no NO_PROXY effect. ProxyAndTargetServers s; Client cli("127.0.0.1", s.target_port()); cli.set_proxy("127.0.0.1", s.proxy_port()); @@ -18598,9 +18570,6 @@ TEST(NoProxyTest, MalformedCidrPrefixIsDropped) { EXPECT_EQ(0, s.target_hits()); } -// ---- Proxy-Authorization handling -// -------------------------------------------- - TEST(NoProxyTest, ProxyAuthorizationSuppressedWhenBypassed) { ProxyAndTargetServers s; auto cli = make_client("internal.corp", s); @@ -18627,9 +18596,6 @@ TEST(NoProxyTest, ProxyAuthorizationSentWhenNotBypassed) { EXPECT_TRUE(s.last_had_proxy_authz()); } -// ---- Backward compatibility -// -------------------------------------------------- - TEST(NoProxyTest, EmptyNoProxyKeepsProxyOn) { ProxyAndTargetServers s; auto cli = make_client("anything.test", s); @@ -18640,9 +18606,6 @@ TEST(NoProxyTest, EmptyNoProxyKeepsProxyOn) { EXPECT_EQ(0, s.target_hits()); } -// ---- Parsing edge cases -// ------------------------------------------------------ - TEST(NoProxyTest, PortSpecificEntryRejected) { ProxyAndTargetServers s; auto cli = make_client("example.com", s); @@ -18665,9 +18628,6 @@ TEST(NoProxyTest, EmptyAndWhitespaceEntriesDropped) { EXPECT_EQ(0, s.target_hits()); } -// ---- Cross-origin redirect honors NO_PROXY -// ----------------------------------- - TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) { // Analog of GHSA-6hrp-7fq9-3qv2: when a redirect targets a host in // NO_PROXY, the follow-up request must go direct and must NOT carry @@ -18682,8 +18642,6 @@ TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) { Server proxy_mock; Server target; - // Proxy mock: redirect /redir to the target's loopback URL; everything - // else returns 200. int target_port = target.bind_to_any_port("127.0.0.1"); int proxy_port = proxy_mock.bind_to_any_port("127.0.0.1"); @@ -18716,9 +18674,6 @@ TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) { proxy_mock.wait_until_ready(); target.wait_until_ready(); - // Initial request goes to a host that is NOT in NO_PROXY → uses the - // proxy. The proxy issues a 302 to 127.0.0.1, which IS in NO_PROXY → - // the redirect leg must go direct. Client cli("public.example", target_port); cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}}); cli.set_proxy("127.0.0.1", proxy_port); @@ -18737,10 +18692,6 @@ TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) { // Proxy-Authorization; we only assert the bypassed leg does not. } -// ---- set_proxy_from_env: httpoxy mitigation -// ---------------------------------- Skipped on Windows because setenv/unsetenv -// are POSIX-only. - #ifndef _WIN32 TEST(NoProxyTest, SetProxyFromEnv_LowercaseHttpProxy_Applied) { From 12cf796d98d86847a9fe3b0d92068819c37d5395 Mon Sep 17 00:00:00 2001 From: yhirose Date: Thu, 14 May 2026 18:20:38 +0900 Subject: [PATCH 12/22] Add NO_PROXY tests covering edge cases found during PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three regression guards added during review of an alternate NO_PROXY implementation (PR #2449). All three pass on the current implementation and surface bugs in the alternate one: - BareIPv6LiteralMatchesIPv6Cidr: a host given as a bare IPv6 literal (no surrounding brackets) must still be recognized as IPv6 for CIDR matching. An implementation that only detects IPv6 when the host string starts with '[' would split the host at the first ':' and misclassify it as a hostname. - TrailingDotOnEntryIsNormalized: trailing dots must be canonicalized on BOTH sides — host and entry. An implementation that strips the host-side trailing dot only would fail to match host "example.com" against entry "example.com." because the substring lengths differ. - ValidEntryWithSurroundingWhitespaceStillMatches: an entry with leading/trailing whitespace must still match. An implementation that feeds raw tokens directly to inet_pton would reject valid CIDRs (" 10.0.0.0/8 ") because of the spaces. 635 unit tests pass. --- test/test.cc | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/test.cc b/test/test.cc index 37a3f45844..bdf4a1a477 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18456,6 +18456,21 @@ TEST(NoProxyTest, TrailingDotIsNormalized) { EXPECT_EQ(1, s.target_hits()); } +TEST(NoProxyTest, TrailingDotOnEntryIsNormalized) { + // Trailing dots must be normalized on BOTH sides — host and entry. + // An implementation that only strips the host-side trailing dot would + // fail to match host "example.com" against entry "example.com." because + // a literal substring search would compare 11 chars against 12. + ProxyAndTargetServers s; + auto cli = make_client("example.com", s); + cli->set_no_proxy({"example.com."}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + TEST(NoProxyTest, WildcardBypassesEverything) { ProxyAndTargetServers s; auto cli = make_client("anything.invalid.test", s); @@ -18492,6 +18507,23 @@ TEST(NoProxyTest, IPv6LiteralExactMatchAcrossEquivalentForms) { EXPECT_EQ(1, s.target_hits()); } +TEST(NoProxyTest, BareIPv6LiteralMatchesIPv6Cidr) { + // Regression guard: a bare IPv6 host literal (no surrounding brackets) + // must still be recognized as IPv6 for CIDR matching. Implementations + // that detect IPv6 only when the host begins with '[' would parse the + // host as a hostname and miss the match. + ProxyAndTargetServers s; + Client cli("fe80::1", s.target_port()); + cli.set_hostname_addr_map({{"fe80::1", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"fe80::/10"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + TEST(NoProxyTest, IPv4MappedIPv6IsNotCrossMatchedAgainstIPv4Entry) { // Policy: keep address families separate. "::ffff:1.2.3.4" must NOT // satisfy a NO_PROXY entry of "1.2.3.4". This avoids subtle bypass @@ -18628,6 +18660,22 @@ TEST(NoProxyTest, EmptyAndWhitespaceEntriesDropped) { EXPECT_EQ(0, s.target_hits()); } +TEST(NoProxyTest, ValidEntryWithSurroundingWhitespaceStillMatches) { + // An entry with leading/trailing whitespace must still match — env-style + // values are commonly pasted with stray spaces and an implementation + // that feeds the raw token directly to inet_pton would fail to match + // valid CIDRs because of the spaces. + ProxyAndTargetServers s; + Client cli("127.0.0.1", s.target_port()); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({" 127.0.0.0/8 "}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) { // Analog of GHSA-6hrp-7fq9-3qv2: when a redirect targets a host in // NO_PROXY, the follow-up request must go direct and must NOT carry From b66fe358688061365ad3bc82952e055eb10efe50 Mon Sep 17 00:00:00 2001 From: yhirose Date: Thu, 14 May 2026 18:29:54 +0900 Subject: [PATCH 13/22] Unify IPv4/IPv6 CIDR matching into a single byte-buffer helper Adopts the unified 16-byte address representation suggested by the alternate NO_PROXY implementation in PR #2449. Both v4 and v6 entries now share one storage type and one matcher; the v4/v6 distinction is only the address-family flag and the max prefix length. - detail::NoProxyEntry: replaces in_addr v4_net + in6_addr v6_net with a single IPBytes net (std::array). v4 occupies the first 4 bytes, v6 fills all 16. - detail::NormalizedTarget: replaces in_addr v4 + in6_addr v6 with a single IPBytes ip. - Replaces detail::ipv4_in_cidr and detail::ipv6_in_cidr with one detail::ip_in_cidr that takes the address, the network, the prefix length and the family's max bits (32 for v4, 128 for v6). The mask is constructed by the byte-fill approach from the previous v6 helper, which is straightforward to read and avoids the shift UB that the v4 helper had to special-case. - The NoProxyKind enum keeps IPv4Cidr / IPv6Cidr as separate values so the match dispatch stays explicit and IPv4 entries cannot accidentally cross-match an IPv6 target (the same address-family isolation the previous code had). Net change: -28 lines + -1 helper function. All 30 NoProxyTest cases plus 643 unit tests pass under both the regular and split builds. --- httplib.h | 48 +++++++++++++++++++++--------------------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/httplib.h b/httplib.h index 053cf8dc65..759cdcc34e 100644 --- a/httplib.h +++ b/httplib.h @@ -2031,11 +2031,14 @@ enum class NoProxyKind { IPv6Cidr, // "fe80::/10" (or single IP, treated as /128) }; +// Unified 16-byte buffer holding either a v4 (first 4 bytes) or v6 address. +// Lets one CIDR matcher cover both families. +using IPBytes = std::array; + struct NoProxyEntry { NoProxyKind kind = NoProxyKind::Wildcard; std::string hostname_pattern; // lowercased, leading/trailing dot stripped - struct in_addr v4_net {}; - struct in6_addr v6_net {}; + IPBytes net{}; int prefix_bits = 0; }; @@ -2043,8 +2046,7 @@ struct NormalizedTarget { std::string hostname; // lowercase; brackets and trailing dot removed bool is_ipv4 = false; bool is_ipv6 = false; - struct in_addr v4 {}; - struct in6_addr v6 {}; + IPBytes ip{}; }; } // namespace detail @@ -10552,35 +10554,23 @@ make_host_and_port_string_always_port(const std::string &host, int port) { bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out); std::vector parse_no_proxy_list(const std::string &value); NormalizedTarget normalize_target(const std::string &host); -bool ipv4_in_cidr(const struct in_addr &ip, const struct in_addr &net, - int prefix_bits); -bool ipv6_in_cidr(const struct in6_addr &ip, const struct in6_addr &net, - int prefix_bits); +bool ip_in_cidr(const IPBytes &ip, const IPBytes &net, int prefix_bits); bool host_matches_no_proxy(const NormalizedTarget &target, const std::vector &entries); -inline bool ipv4_in_cidr(const struct in_addr &ip, const struct in_addr &net, - int prefix_bits) { - if (prefix_bits < 0 || prefix_bits > 32) { return false; } - // Special-case prefix=0 to avoid undefined behavior of (1u << 32). - if (prefix_bits == 0) { return true; } - uint32_t mask = htonl(0xFFFFFFFFu << (32 - prefix_bits)); - return (ip.s_addr & mask) == (net.s_addr & mask); -} - -inline bool ipv6_in_cidr(const struct in6_addr &ip, const struct in6_addr &net, - int prefix_bits) { +inline bool ip_in_cidr(const IPBytes &ip, const IPBytes &net, int prefix_bits) { if (prefix_bits < 0 || prefix_bits > 128) { return false; } if (prefix_bits == 0) { return true; } int full_bytes = prefix_bits / 8; int rem_bits = prefix_bits % 8; - if (full_bytes > 0 && std::memcmp(ip.s6_addr, net.s6_addr, + if (full_bytes > 0 && std::memcmp(ip.data(), net.data(), static_cast(full_bytes)) != 0) { return false; } if (rem_bits == 0) { return true; } + auto i = static_cast(full_bytes); auto mask = static_cast(0xFFu << (8 - rem_bits)); - return (ip.s6_addr[full_bytes] & mask) == (net.s6_addr[full_bytes] & mask); + return (ip[i] & mask) == (net[i] & mask); } inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { @@ -10610,7 +10600,7 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { if (prefix < 0 || prefix > 32) { return false; } } out.kind = NoProxyKind::IPv4Cidr; - out.v4_net = v4; + std::memcpy(out.net.data(), &v4, sizeof(v4)); out.prefix_bits = prefix; return true; } @@ -10628,7 +10618,7 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { if (prefix < 0 || prefix > 128) { return false; } } out.kind = NoProxyKind::IPv6Cidr; - out.v6_net = v6; + std::memcpy(out.net.data(), &v6, sizeof(v6)); out.prefix_bits = prefix; return true; } @@ -10681,10 +10671,14 @@ inline NormalizedTarget normalize_target(const std::string &host) { t.hostname = case_ignore::to_lower(h); if (!t.hostname.empty()) { - if (inet_pton(AF_INET, t.hostname.c_str(), &t.v4) == 1) { + struct in_addr v4; + struct in6_addr v6; + if (inet_pton(AF_INET, t.hostname.c_str(), &v4) == 1) { t.is_ipv4 = true; - } else if (inet_pton(AF_INET6, t.hostname.c_str(), &t.v6) == 1) { + std::memcpy(t.ip.data(), &v4, sizeof(v4)); + } else if (inet_pton(AF_INET6, t.hostname.c_str(), &v6) == 1) { t.is_ipv6 = true; + std::memcpy(t.ip.data(), &v6, sizeof(v6)); } } return t; @@ -10697,12 +10691,12 @@ inline bool host_matches_no_proxy(const NormalizedTarget &target, switch (e.kind) { case NoProxyKind::Wildcard: return true; case NoProxyKind::IPv4Cidr: - if (target.is_ipv4 && ipv4_in_cidr(target.v4, e.v4_net, e.prefix_bits)) { + if (target.is_ipv4 && ip_in_cidr(target.ip, e.net, e.prefix_bits)) { return true; } break; case NoProxyKind::IPv6Cidr: - if (target.is_ipv6 && ipv6_in_cidr(target.v6, e.v6_net, e.prefix_bits)) { + if (target.is_ipv6 && ip_in_cidr(target.ip, e.net, e.prefix_bits)) { return true; } break; From f302e15228e94977ea9187411b6837c369263444 Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 24 May 2026 21:17:37 -0400 Subject: [PATCH 14/22] Drop set_proxy_from_env per #2446 discussion Per @unterwegi's feedback in #2446, environment variable handling conflicts with cpp-httplib's long-standing policy of explicit configuration (e.g. set_ca_cert_path requires explicit paths instead of reading SSL_CERT_FILE / SSL_CERT_DIR). The NO_PROXY matching logic is the genuinely tricky part worth keeping in the library; getenv parsing is trivial and is left to the caller. - Remove Client::set_proxy_from_env, ClientImpl::set_proxy_from_env, and ClientImpl::apply_proxy_url - Remove ScopedEnv test helper and env-driven NoProxyTest cases - Replace the "Read proxy settings from the environment" docs with a short snippet showing how to parse no_proxy and feed set_no_proxy() - Keep set_no_proxy() and all NO_PROXY pattern matching intact --- README.md | 46 ++++---- docs-src/pages/en/cookbook/c16-proxy.md | 26 ++--- docs-src/pages/ja/cookbook/c16-proxy.md | 24 ++--- httplib.h | 138 ------------------------ test/test.cc | 100 ----------------- 5 files changed, 40 insertions(+), 294 deletions(-) diff --git a/README.md b/README.md index 730ee57d98..6ac03fd7db 100644 --- a/README.md +++ b/README.md @@ -1218,38 +1218,30 @@ Limitations: - `set_no_proxy` replaces any previously configured list; there is no append API. -#### Read proxy settings from the environment - -`set_proxy_from_env` configures the client from proxy-related -environment variables. +cpp-httplib does **not** read `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` +itself — this is consistent with `set_ca_cert_path()` and the rest of +the configuration API. If you want that behavior, parse the variables +in your application and pass the bypass patterns to `set_no_proxy()`: ```cpp -httplib::Client cli("https://api.example.com"); -cli.set_proxy_from_env(); +if (auto *v = std::getenv("no_proxy"); v && *v) { + std::vector patterns; + std::stringstream ss(v); + for (std::string item; std::getline(ss, item, ',');) { + // trim whitespace as needed + if (!item.empty()) { patterns.push_back(std::move(item)); } + } + cli.set_no_proxy(patterns); +} ``` -Variables read: - -- `https_proxy` / `HTTPS_PROXY` — used by HTTPS clients (`SSLClient`) -- `http_proxy` (lowercase only — see security note below) — used by HTTP clients -- `no_proxy` / `NO_PROXY` — comma-separated list of bypass patterns - -Returns `true` if at least one variable was found and applied. - > [!IMPORTANT] -> The uppercase `HTTP_PROXY` is intentionally ignored to mitigate the -> "httpoxy" class of bugs ([CVE-2016-5385](https://httpoxy.org/)). In -> CGI / FastCGI environments the variable name collides with the -> `HTTP_*` namespace used to expose request headers, allowing a remote -> attacker to set the proxy URL via the `Proxy:` request header. -> cpp-httplib follows curl, Go, and Python `requests` in honoring only -> the lowercase `http_proxy`. `HTTPS_PROXY` and `NO_PROXY` are safe in -> either case because their names do not begin with `HTTP_`. - -> [!NOTE] -> `set_proxy_from_env` reads `getenv` synchronously. Call it once at -> startup before issuing any requests; concurrent `setenv` from other -> threads is undefined. +> If you also read `HTTP_PROXY` from the environment, prefer the +> lowercase `http_proxy` only. The uppercase form is poisoned in +> CGI / FastCGI environments by the `Proxy:` request header +> ([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/)). `HTTPS_PROXY` +> and `NO_PROXY` are safe in either case because their names do not +> begin with `HTTP_`. ### Range diff --git a/docs-src/pages/en/cookbook/c16-proxy.md b/docs-src/pages/en/cookbook/c16-proxy.md index c6fe7c43e7..c2628f425f 100644 --- a/docs-src/pages/en/cookbook/c16-proxy.md +++ b/docs-src/pages/en/cookbook/c16-proxy.md @@ -69,23 +69,19 @@ Hostname matching is case-insensitive and uses a dot-boundary rule, so an entry Malformed entries are silently dropped. Port-specific entries such as `example.com:8080` are not supported (cpp-httplib's other host-keyed APIs are also keyed on hostname only). -## Read proxy settings from the environment +## Reading proxy settings from the environment -Call `set_proxy_from_env()` at startup to pick up proxy configuration from environment variables. +cpp-httplib does not read `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` itself. The library's configuration API is explicit by design — `set_ca_cert_path()` is the same way. If you want that behavior, parse the variables in your application and feed them to `set_proxy()` and `set_no_proxy()`. ```cpp -httplib::Client cli("https://api.example.com"); -cli.set_proxy_from_env(); +if (auto *v = std::getenv("no_proxy"); v && *v) { + std::vector patterns; + std::stringstream ss(v); + for (std::string item; std::getline(ss, item, ',');) { + if (!item.empty()) { patterns.push_back(std::move(item)); } + } + cli.set_no_proxy(patterns); +} ``` -Variables read: - -- `https_proxy` / `HTTPS_PROXY` — used by HTTPS clients -- `http_proxy` (**lowercase only**, see below) — used by HTTP clients -- `no_proxy` / `NO_PROXY` — comma-separated bypass list - -Returns `true` if at least one variable was found and applied. - -> **Security Note:** The uppercase `HTTP_PROXY` is intentionally **not** read. In CGI/FastCGI environments, the `HTTP_*` namespace is used to expose HTTP request headers, which lets a remote attacker inject an arbitrary proxy URL via the `Proxy:` request header ([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/)). cpp-httplib follows curl, Go, and Python `requests` in honoring only the lowercase `http_proxy`. `HTTPS_PROXY` and `NO_PROXY` are safe in either case because their names do not begin with `HTTP_`. - -> **Note:** `set_proxy_from_env()` reads `getenv` synchronously; call it once at startup. Concurrent `setenv` from other threads while this function runs is undefined. +> **Security Note:** If you also read `HTTP_PROXY` from the environment, prefer the lowercase `http_proxy` only. The uppercase form is poisoned in CGI/FastCGI environments by the `Proxy:` request header ([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/)). `HTTPS_PROXY` and `NO_PROXY` are safe in either case because their names do not begin with `HTTP_`. diff --git a/docs-src/pages/ja/cookbook/c16-proxy.md b/docs-src/pages/ja/cookbook/c16-proxy.md index a71ca28d68..7043ba17c0 100644 --- a/docs-src/pages/ja/cookbook/c16-proxy.md +++ b/docs-src/pages/ja/cookbook/c16-proxy.md @@ -71,21 +71,17 @@ cli.set_no_proxy({"internal.corp", "10.0.0.0/8", "*.dev.local"}); ## 環境変数からプロキシ設定を読み込む -`set_proxy_from_env()`を呼ぶと、起動時の環境変数からプロキシ設定をまとめて取り込めます。 +cpp-httplib本体は`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`を読みません。設定APIを明示的に保つ方針で、`set_ca_cert_path()`なども同様です。必要なら、アプリ側で環境変数を読んで`set_proxy()`や`set_no_proxy()`に渡します。 ```cpp -httplib::Client cli("https://api.example.com"); -cli.set_proxy_from_env(); +if (auto *v = std::getenv("no_proxy"); v && *v) { + std::vector patterns; + std::stringstream ss(v); + for (std::string item; std::getline(ss, item, ',');) { + if (!item.empty()) { patterns.push_back(std::move(item)); } + } + cli.set_no_proxy(patterns); +} ``` -読み込まれる変数: - -- `https_proxy` / `HTTPS_PROXY` — HTTPSクライアントが使用 -- `http_proxy`(**小文字のみ**、後述)— HTTPクライアントが使用 -- `no_proxy` / `NO_PROXY` — カンマ区切りのバイパスリスト - -少なくとも1つの変数が見つかって適用されたら`true`を返します。 - -> **Security Note:** 大文字の`HTTP_PROXY`は意図的に**読まれません**。CGI/FastCGI環境では`HTTP_*`という名前空間がHTTPリクエストヘッダーの公開に使われており、攻撃者が`Proxy:`リクエストヘッダーで任意のプロキシURLを差し込めてしまうためです([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/))。curl・Go・Python `requests`と同じく、cpp-httplibも小文字の`http_proxy`しか採用しません。`HTTPS_PROXY`や`NO_PROXY`は名前が`HTTP_`で始まらないので、どちらの大文字小文字でも安全です。 - -> **Note:** `set_proxy_from_env()`は同期的に`getenv`を呼ぶだけなので、起動時に1回呼ぶことを想定しています。他スレッドが同時に`setenv`しているケースは未定義です。 +> **Security Note:** `HTTP_PROXY`をアプリ側で読む場合は、小文字の`http_proxy`だけを採用してください。大文字の方はCGI/FastCGI環境で`Proxy:`リクエストヘッダーから汚染される可能性があります([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/))。`HTTPS_PROXY`や`NO_PROXY`は名前が`HTTP_`で始まらないので、どちらの大文字小文字でも安全です。 diff --git a/httplib.h b/httplib.h index 759cdcc34e..7edb0bbbbf 100644 --- a/httplib.h +++ b/httplib.h @@ -2266,7 +2266,6 @@ class ClientImpl { const std::string &password); void set_proxy_bearer_token_auth(const std::string &token); void set_no_proxy(const std::vector &patterns); - bool set_proxy_from_env(); void set_logger(Logger logger); void set_error_logger(ErrorLogger error_logger); @@ -2293,7 +2292,6 @@ class ClientImpl { Response &res, bool &success, Error &error); bool is_proxy_enabled_for_host(const std::string &host) const; - bool apply_proxy_url(const std::string &url); // All of: // shutdown_ssl @@ -2649,7 +2647,6 @@ class Client { const std::string &password); void set_proxy_bearer_token_auth(const std::string &token); void set_no_proxy(const std::vector &patterns); - bool set_proxy_from_env(); void set_logger(Logger logger); void set_error_logger(ErrorLogger error_logger); @@ -14931,140 +14928,6 @@ inline void ClientImpl::set_no_proxy(const std::vector &patterns) { no_proxy_entries_ = std::move(parsed); } -inline bool ClientImpl::apply_proxy_url(const std::string &url) { - if (url.empty()) { return false; } - - // CRLF / NUL would let a malicious env value inject extra header lines - // into a CONNECT request or a Proxy-Authorization header. - for (auto c : url) { - auto uc = static_cast(c); - if (uc < 0x20 || uc == 0x7F) { return false; } - } - - std::size_t scheme_end = 0; - bool is_https = false; - if (url.compare(0, 7, "http://") == 0) { - scheme_end = 7; - } else if (url.compare(0, 8, "https://") == 0) { - is_https = true; - scheme_end = 8; - } else { - return false; - } - - auto authority_end = url.find_first_of("/?#", scheme_end); - if (authority_end == std::string::npos) { authority_end = url.size(); } - auto authority = url.substr(scheme_end, authority_end - scheme_end); - if (authority.empty()) { return false; } - - // Split on the LAST '@' so passwords containing '@' are preserved. - std::string user; - std::string pass; - std::string host_port; - auto at_pos = authority.rfind('@'); - if (at_pos != std::string::npos) { - auto userinfo = authority.substr(0, at_pos); - host_port = authority.substr(at_pos + 1); - auto colon = userinfo.find(':'); - if (colon == std::string::npos) { - user = std::move(userinfo); - } else { - user = userinfo.substr(0, colon); - pass = userinfo.substr(colon + 1); - } - } else { - host_port = authority; - } - if (host_port.empty()) { return false; } - - std::string host; - std::string port_str; - if (host_port.front() == '[') { - auto rb = host_port.find(']'); - if (rb == std::string::npos) { return false; } - host = host_port.substr(1, rb - 1); - if (host.empty()) { return false; } - struct in6_addr tmp; - if (inet_pton(AF_INET6, host.c_str(), &tmp) != 1) { return false; } - auto rest = host_port.substr(rb + 1); - if (!rest.empty()) { - if (rest.front() != ':') { return false; } - port_str = rest.substr(1); - if (port_str.empty()) { return false; } - } - } else { - auto colon = host_port.find(':'); - if (colon == std::string::npos) { - host = host_port; - } else { - host = host_port.substr(0, colon); - port_str = host_port.substr(colon + 1); - if (port_str.empty()) { return false; } - } - if (host.empty()) { return false; } - } - - int port; - if (port_str.empty()) { - port = is_https ? 443 : 80; - } else { - int parsed = 0; - auto r = detail::from_chars(port_str.data(), - port_str.data() + port_str.size(), parsed); - if (r.ec != std::errc{} || r.ptr != port_str.data() + port_str.size()) { - return false; - } - if (parsed < 1 || parsed > 65535) { return false; } - port = parsed; - } - - // Commit only after every check has passed. - proxy_host_ = std::move(host); - proxy_port_ = port; - if (!user.empty()) { - proxy_basic_auth_username_ = std::move(user); - proxy_basic_auth_password_ = std::move(pass); - } - return true; -} - -inline bool ClientImpl::set_proxy_from_env() { - bool applied = false; - - // No cross-scheme fallback: http_proxy and https_proxy describe different - // traffic, mixing them could send HTTPS-target credentials through a - // proxy the user only authorized for HTTP. - // - // For http_proxy, lowercase ONLY: the uppercase form is poisoned in - // CGI/FastCGI environments by the "Proxy:" request header (httpoxy / - // CVE-2016-5385). HTTPS_PROXY is safe in either case because the name - // does not start with HTTP_. - const char *url_env = nullptr; - if (is_ssl()) { - url_env = std::getenv("https_proxy"); - if (!url_env || *url_env == '\0') { url_env = std::getenv("HTTPS_PROXY"); } - } else { - url_env = std::getenv("http_proxy"); - } - if (url_env && *url_env != '\0' && apply_proxy_url(url_env)) { - applied = true; - } - - const char *no_proxy_env = std::getenv("no_proxy"); - if (!no_proxy_env || *no_proxy_env == '\0') { - no_proxy_env = std::getenv("NO_PROXY"); - } - if (no_proxy_env && *no_proxy_env != '\0') { - auto entries = detail::parse_no_proxy_list(no_proxy_env); - if (!entries.empty()) { - no_proxy_entries_ = std::move(entries); - applied = true; - } - } - - return applied; -} - #ifdef CPPHTTPLIB_SSL_ENABLED inline void ClientImpl::set_digest_auth(const std::string &username, const std::string &password) { @@ -15769,7 +15632,6 @@ inline void Client::set_proxy_bearer_token_auth(const std::string &token) { inline void Client::set_no_proxy(const std::vector &patterns) { cli_->set_no_proxy(patterns); } -inline bool Client::set_proxy_from_env() { return cli_->set_proxy_from_env(); } inline void Client::set_logger(Logger logger) { cli_->set_logger(std::move(logger)); diff --git a/test/test.cc b/test/test.cc index bdf4a1a477..650711bc97 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18279,34 +18279,6 @@ TEST(KeepAliveTest, DeleteWithoutContentLengthDoesNotEatNextRequest) { namespace no_proxy_test { -#ifndef _WIN32 -class ScopedEnv { -public: - ScopedEnv(const char *name, const char *value) : name_(name) { - auto p = std::getenv(name); - had_prev_ = (p != nullptr); - if (had_prev_) { prev_ = p; } - if (value) { - ::setenv(name, value, 1); - } else { - ::unsetenv(name); - } - } - ~ScopedEnv() { - if (had_prev_) { - ::setenv(name_.c_str(), prev_.c_str(), 1); - } else { - ::unsetenv(name_.c_str()); - } - } - -private: - std::string name_; - std::string prev_; - bool had_prev_ = false; -}; -#endif // !_WIN32 - class ProxyAndTargetServers { public: ProxyAndTargetServers() { @@ -18739,75 +18711,3 @@ TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) { // The first leg (going through the proxy) is allowed to carry // Proxy-Authorization; we only assert the bypassed leg does not. } - -#ifndef _WIN32 - -TEST(NoProxyTest, SetProxyFromEnv_LowercaseHttpProxy_Applied) { - no_proxy_test::ScopedEnv h("http_proxy", "http://proxy.test:3128"); - no_proxy_test::ScopedEnv H("HTTP_PROXY", nullptr); - no_proxy_test::ScopedEnv n("no_proxy", nullptr); - no_proxy_test::ScopedEnv N("NO_PROXY", nullptr); - no_proxy_test::ScopedEnv s("https_proxy", nullptr); - no_proxy_test::ScopedEnv S("HTTPS_PROXY", nullptr); - - Client cli("example.com"); - EXPECT_TRUE(cli.set_proxy_from_env()); -} - -TEST(NoProxyTest, SetProxyFromEnv_UppercaseHTTPProxy_Ignored) { - // Httpoxy mitigation: HTTP_PROXY (uppercase) must NOT be honored, - // because in CGI environments it is set from the client-supplied - // "Proxy:" header. - no_proxy_test::ScopedEnv h("http_proxy", nullptr); - no_proxy_test::ScopedEnv H("HTTP_PROXY", "http://attacker.invalid:9999"); - no_proxy_test::ScopedEnv n("no_proxy", nullptr); - no_proxy_test::ScopedEnv N("NO_PROXY", nullptr); - no_proxy_test::ScopedEnv s("https_proxy", nullptr); - no_proxy_test::ScopedEnv S("HTTPS_PROXY", nullptr); - - Client cli("example.com"); - EXPECT_FALSE(cli.set_proxy_from_env()) - << "Uppercase HTTP_PROXY must be ignored (CVE-2016-5385)"; -} - -TEST(NoProxyTest, SetProxyFromEnv_NoProxyApplied) { - no_proxy_test::ScopedEnv h("http_proxy", nullptr); - no_proxy_test::ScopedEnv H("HTTP_PROXY", nullptr); - no_proxy_test::ScopedEnv s("https_proxy", nullptr); - no_proxy_test::ScopedEnv S("HTTPS_PROXY", nullptr); - no_proxy_test::ScopedEnv n("no_proxy", "example.com"); - no_proxy_test::ScopedEnv N("NO_PROXY", nullptr); - - Client cli("example.com"); - EXPECT_TRUE(cli.set_proxy_from_env()) - << "set_proxy_from_env returns true when only NO_PROXY is set"; -} - -TEST(NoProxyTest, SetProxyFromEnv_CRLFInProxyValueRejected) { - // CR/LF in env values must be rejected at parse time so they cannot - // inject extra header lines into a CONNECT request or - // Proxy-Authorization (cf. CVE-2026-21428, CRLF injection). - no_proxy_test::ScopedEnv h("http_proxy", "http://host:8080\r\nInjected: yes"); - no_proxy_test::ScopedEnv H("HTTP_PROXY", nullptr); - no_proxy_test::ScopedEnv n("no_proxy", nullptr); - no_proxy_test::ScopedEnv N("NO_PROXY", nullptr); - no_proxy_test::ScopedEnv s("https_proxy", nullptr); - no_proxy_test::ScopedEnv S("HTTPS_PROXY", nullptr); - - Client cli("example.com"); - EXPECT_FALSE(cli.set_proxy_from_env()); -} - -TEST(NoProxyTest, SetProxyFromEnv_EmptyEnvValueIgnored) { - no_proxy_test::ScopedEnv h("http_proxy", ""); - no_proxy_test::ScopedEnv H("HTTP_PROXY", nullptr); - no_proxy_test::ScopedEnv n("no_proxy", ""); - no_proxy_test::ScopedEnv N("NO_PROXY", nullptr); - no_proxy_test::ScopedEnv s("https_proxy", nullptr); - no_proxy_test::ScopedEnv S("HTTPS_PROXY", nullptr); - - Client cli("example.com"); - EXPECT_FALSE(cli.set_proxy_from_env()); -} - -#endif // !_WIN32 From 7534d8f1c602112e62ffe71adb3fc04f6d624cae Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 24 May 2026 21:21:39 -0400 Subject: [PATCH 15/22] docs: blend NO_PROXY env-var note into c16-proxy cookbook style Match the granularity of the surrounding sections: imperative heading, inline paragraph instead of a heavyweight callout, and a simpler getenv snippet without the C++17 if-init. --- docs-src/pages/en/cookbook/c16-proxy.md | 10 +++++----- docs-src/pages/ja/cookbook/c16-proxy.md | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs-src/pages/en/cookbook/c16-proxy.md b/docs-src/pages/en/cookbook/c16-proxy.md index c2628f425f..f860eb03a1 100644 --- a/docs-src/pages/en/cookbook/c16-proxy.md +++ b/docs-src/pages/en/cookbook/c16-proxy.md @@ -69,19 +69,19 @@ Hostname matching is case-insensitive and uses a dot-boundary rule, so an entry Malformed entries are silently dropped. Port-specific entries such as `example.com:8080` are not supported (cpp-httplib's other host-keyed APIs are also keyed on hostname only). -## Reading proxy settings from the environment +## Read proxy settings from the environment -cpp-httplib does not read `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` itself. The library's configuration API is explicit by design — `set_ca_cert_path()` is the same way. If you want that behavior, parse the variables in your application and feed them to `set_proxy()` and `set_no_proxy()`. +cpp-httplib doesn't touch `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` on its own — the config API is always explicit, the same way `set_ca_cert_path()` is. If you'd like that behavior, read the variables in your application and feed them to `set_proxy()` and `set_no_proxy()`. ```cpp -if (auto *v = std::getenv("no_proxy"); v && *v) { +if (const char *v = std::getenv("no_proxy")) { std::vector patterns; std::stringstream ss(v); for (std::string item; std::getline(ss, item, ',');) { - if (!item.empty()) { patterns.push_back(std::move(item)); } + if (!item.empty()) { patterns.push_back(item); } } cli.set_no_proxy(patterns); } ``` -> **Security Note:** If you also read `HTTP_PROXY` from the environment, prefer the lowercase `http_proxy` only. The uppercase form is poisoned in CGI/FastCGI environments by the `Proxy:` request header ([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/)). `HTTPS_PROXY` and `NO_PROXY` are safe in either case because their names do not begin with `HTTP_`. +If you also read `HTTP_PROXY` yourself, honor the lowercase `http_proxy` only. The uppercase form is poisoned in CGI/FastCGI environments by the `Proxy:` request header ([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/)). `HTTPS_PROXY` and `NO_PROXY` are safe in either case because their names don't begin with `HTTP_`. diff --git a/docs-src/pages/ja/cookbook/c16-proxy.md b/docs-src/pages/ja/cookbook/c16-proxy.md index 7043ba17c0..21d9b92e23 100644 --- a/docs-src/pages/ja/cookbook/c16-proxy.md +++ b/docs-src/pages/ja/cookbook/c16-proxy.md @@ -71,17 +71,17 @@ cli.set_no_proxy({"internal.corp", "10.0.0.0/8", "*.dev.local"}); ## 環境変数からプロキシ設定を読み込む -cpp-httplib本体は`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`を読みません。設定APIを明示的に保つ方針で、`set_ca_cert_path()`なども同様です。必要なら、アプリ側で環境変数を読んで`set_proxy()`や`set_no_proxy()`に渡します。 +cpp-httplib本体は`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`を読みません。`set_ca_cert_path()`と同じで、設定APIは常に明示的にしています。環境変数を反映させたい場合は、アプリ側で読んで`set_proxy()`や`set_no_proxy()`に渡してください。 ```cpp -if (auto *v = std::getenv("no_proxy"); v && *v) { +if (const char *v = std::getenv("no_proxy")) { std::vector patterns; std::stringstream ss(v); for (std::string item; std::getline(ss, item, ',');) { - if (!item.empty()) { patterns.push_back(std::move(item)); } + if (!item.empty()) { patterns.push_back(item); } } cli.set_no_proxy(patterns); } ``` -> **Security Note:** `HTTP_PROXY`をアプリ側で読む場合は、小文字の`http_proxy`だけを採用してください。大文字の方はCGI/FastCGI環境で`Proxy:`リクエストヘッダーから汚染される可能性があります([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/))。`HTTPS_PROXY`や`NO_PROXY`は名前が`HTTP_`で始まらないので、どちらの大文字小文字でも安全です。 +`HTTP_PROXY`も自分で読むなら、小文字の`http_proxy`だけを採用してください。大文字の方はCGI/FastCGI環境で`Proxy:`リクエストヘッダーから汚染される可能性があります([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/))。`HTTPS_PROXY`と`NO_PROXY`は名前が`HTTP_`で始まらないので、どちらの大文字小文字でも安全です。 From 70295a9969fd903e25ebb22121d30698374a6ec7 Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 24 May 2026 22:08:15 -0400 Subject: [PATCH 16/22] Skip digest 407 retry when target is bypassed by NO_PROXY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this fix, a NO_PROXY-bypassed origin that returns 407 Proxy-Authentication-Required with a Digest challenge would trigger the same retry path the proxy uses, computing a Proxy-Authorization header from proxy_digest_auth_* and sending the user's proxy credentials directly to that (potentially hostile) origin. A 407 from a direct origin is semantically meaningless — RFC 9110 defines it strictly as a proxy response. Skip the retry when the current target is not actually going through the proxy and let the 407 propagate to the caller unchanged. Regression test BypassedTargetReturning407DoesNotLeakProxyDigest Credentials reproduces the leak without this gate. --- httplib.h | 6 ++++++ test/test.cc | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/httplib.h b/httplib.h index 7edb0bbbbf..d106e32a80 100644 --- a/httplib.h +++ b/httplib.h @@ -13205,6 +13205,12 @@ inline bool ClientImpl::handle_request(Stream &strm, Request &req, res.status == StatusCode::ProxyAuthenticationRequired_407) && req.authorization_count_ < 5) { auto is_proxy = res.status == StatusCode::ProxyAuthenticationRequired_407; + + // A 407 from a direct (NO_PROXY-bypassed) origin is meaningless and + // must not trigger a retry — that would let the origin extract the + // user's proxy digest credentials. + if (is_proxy && !is_proxy_enabled_for_host(host_)) { return ret; } + const auto &username = is_proxy ? proxy_digest_auth_username_ : digest_auth_username_; const auto &password = diff --git a/test/test.cc b/test/test.cc index 650711bc97..558f223d9b 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18711,3 +18711,50 @@ TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) { // The first leg (going through the proxy) is allowed to carry // Proxy-Authorization; we only assert the bypassed leg does not. } + +#ifdef CPPHTTPLIB_SSL_ENABLED +TEST(NoProxyTest, BypassedTargetReturning407DoesNotLeakProxyDigestCredentials) { + // A NO_PROXY-bypassed target that replies 407 + Digest challenge must not + // trigger the digest retry — otherwise the client would compute a + // Proxy-Authorization header from proxy_digest_auth_* and send it + // directly to the (potentially hostile) origin. + + std::atomic target_hits{0}; + std::atomic target_saw_proxy_authz{false}; + + Server target; + int target_port = target.bind_to_any_port("127.0.0.1"); + + target.Get(".*", [&](const Request &req, Response &res) { + target_hits++; + if (req.has_header("Proxy-Authorization")) { + target_saw_proxy_authz = true; + } + res.status = StatusCode::ProxyAuthenticationRequired_407; + res.set_header("Proxy-Authenticate", "Digest realm=\"evil\", qop=\"auth\", " + "nonce=\"abc\", algorithm=MD5"); + }); + + std::thread target_thread([&] { target.listen_after_bind(); }); + auto cleanup = detail::scope_exit([&] { + target.stop(); + if (target_thread.joinable()) { target_thread.join(); } + }); + target.wait_until_ready(); + + Client cli("evil.example", target_port); + cli.set_hostname_addr_map({{"evil.example", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", 1); // unreachable port — proxy must NOT be used + cli.set_proxy_digest_auth("proxy-user", "proxy-pass"); + cli.set_no_proxy({"evil.example"}); + + auto res = cli.Get("/x"); + ASSERT_TRUE(res); + EXPECT_EQ(StatusCode::ProxyAuthenticationRequired_407, res->status) + << "the 407 must be propagated to the caller, not silently swallowed"; + EXPECT_EQ(1, target_hits.load()) + << "the client must not retry; exactly one request to target"; + EXPECT_FALSE(target_saw_proxy_authz.load()) + << "proxy digest credentials must never reach a bypassed origin"; +} +#endif From ff9740f2ca141bd8abf27bedabd585170a0d940f Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 24 May 2026 22:29:52 -0400 Subject: [PATCH 17/22] Make set_no_proxy safe across redirects and keep-alive Two correctness bugs that the dynamic NO_PROXY API exposed: 1. Multi-hop redirect through a bypassed host lost the proxy. setup_redirect_client only copied proxy_host_/port and the proxy auth credentials when is_proxy_enabled_for_host(next_host) was true. After a chain like A (proxied) -> B (NO_PROXY-matched, direct) -> C, the redirect client built for B had no proxy configured, so the further B -> C hop went direct even when C should have been proxied. Copy the proxy configuration unconditionally and let is_proxy_enabled_for_host gate at send time. The next_host parameter is no longer needed and removed from the signature. 2. Keep-alive socket reuse with a stale bypass decision. set_proxy() / set_no_proxy() left the existing keep-alive socket open, so the next request reused a socket pointed at the previous endpoint (proxy vs origin) while write_request emitted the new request-line form (absolute vs relative URL). Add invalidate_keep_alive_socket() and call it from both setters; the helper handles the in-flight case by deferring the close. Regression tests MultiHopRedirectThroughBypassedHostKeepsProxy and KeepAliveSocketInvalidatedOnSetNoProxy reproduce each bug without the respective fix. --- httplib.h | 52 +++++++++++++++------ test/test.cc | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 13 deletions(-) diff --git a/httplib.h b/httplib.h index d106e32a80..ec164353ca 100644 --- a/httplib.h +++ b/httplib.h @@ -2310,6 +2310,12 @@ class ClientImpl { bool write_content_with_provider(Stream &strm, const Request &req, Error &error) const; + // Invalidate the keep-alive socket after a configuration change that + // affects which endpoint the next request should target (proxy/no_proxy). + // Safe to call regardless of in-flight state — falls back to deferred + // close via socket_should_be_closed_when_request_is_done_. + void invalidate_keep_alive_socket(); + void copy_settings(const ClientImpl &rhs); void output_log(const Request &req, const Response &res) const; @@ -2406,8 +2412,7 @@ class ClientImpl { const std::string &host, int port, Request &req, Response &res, const std::string &path, const std::string &location, Error &error); - template - void setup_redirect_client(ClientType &client, const std::string &next_host); + template void setup_redirect_client(ClientType &client); bool handle_request(Stream &strm, Request &req, Response &res, bool close_connection, Error &error); std::unique_ptr send_with_content_provider_and_receiver( @@ -13316,7 +13321,7 @@ inline bool ClientImpl::create_redirect_client( SSLClient redirect_client(host, port); // Setup basic client configuration first - setup_redirect_client(redirect_client, host); + setup_redirect_client(redirect_client); redirect_client.enable_server_certificate_verification( server_certificate_verification_); @@ -13350,7 +13355,7 @@ inline bool ClientImpl::create_redirect_client( ClientImpl redirect_client(host, port); // Setup client with robust configuration - setup_redirect_client(redirect_client, host); + setup_redirect_client(redirect_client); // Execute the redirect return detail::redirect(redirect_client, req, res, path, location, error); @@ -13360,8 +13365,7 @@ inline bool ClientImpl::create_redirect_client( // New method for robust client setup (based on basic_manual_redirect.cpp // logic) template -inline void ClientImpl::setup_redirect_client(ClientType &client, - const std::string &next_host) { +inline void ClientImpl::setup_redirect_client(ClientType &client) { // Copy basic settings first client.set_connection_timeout(connection_timeout_sec_); client.set_read_timeout(read_timeout_sec_, read_timeout_usec_); @@ -13379,16 +13383,17 @@ inline void ClientImpl::setup_redirect_client(ClientType &client, // host. This function is only called for cross-host redirects; same-host // redirects are handled directly in ClientImpl::redirect(). - // The bypass list must follow across redirects so it is re-evaluated - // against the redirect target. Without this, a redirect to a NO_PROXY - // host would still go through the proxy (and carry Proxy-Authorization). + // Copy the full proxy configuration (including the bypass list) + // unconditionally. The per-target proxy/bypass decision is re-evaluated at + // send time by is_proxy_enabled_for_host(host_). Skipping the proxy copy + // when next_host happens to be bypassed would permanently strand the proxy + // configuration: a subsequent redirect from the new client to a + // non-bypassed host would have no proxy_host_ to fall back on and would + // silently go direct. client.no_proxy_entries_ = no_proxy_entries_; - - if (is_proxy_enabled_for_host(next_host)) { - // First set proxy host and port + if (!proxy_host_.empty() && proxy_port_ != -1) { client.set_proxy(proxy_host_, proxy_port_); - // Then set proxy authentication (order matters!) if (!proxy_basic_auth_username_.empty()) { client.set_proxy_basic_auth(proxy_basic_auth_username_, proxy_basic_auth_password_); @@ -14820,6 +14825,20 @@ inline void ClientImpl::stop() { close_socket(socket_); } +inline void ClientImpl::invalidate_keep_alive_socket() { + std::lock_guard guard(socket_mutex_); + if (!socket_.is_open()) { return; } + if (socket_requests_in_flight_ > 0) { + // A request is currently using the socket — defer the close so the + // in-flight thread completes cleanly and the next request reconnects. + socket_should_be_closed_when_request_is_done_ = true; + return; + } + shutdown_ssl(socket_, true); + shutdown_socket(socket_); + close_socket(socket_); +} + inline std::string ClientImpl::host() const { return host_; } inline int ClientImpl::port() const { return port_; } @@ -14908,6 +14927,9 @@ inline void ClientImpl::set_interface(const std::string &intf) { inline void ClientImpl::set_proxy(const std::string &host, int port) { proxy_host_ = host; proxy_port_ = port; + // Any keep-alive socket may be pointed at the previous proxy (or at the + // origin if proxy was unset); drop it so the next request reconnects. + invalidate_keep_alive_socket(); } inline void ClientImpl::set_proxy_basic_auth(const std::string &username, @@ -14932,6 +14954,10 @@ inline void ClientImpl::set_no_proxy(const std::vector &patterns) { } } no_proxy_entries_ = std::move(parsed); + // The bypass decision may have flipped for the keep-alive target; drop the + // existing socket so the next request reconnects to the correct endpoint + // (proxy vs origin) and emits the matching request-line form. + invalidate_keep_alive_socket(); } #ifdef CPPHTTPLIB_SSL_ENABLED diff --git a/test/test.cc b/test/test.cc index 558f223d9b..f79aa2b822 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18758,3 +18758,132 @@ TEST(NoProxyTest, BypassedTargetReturning407DoesNotLeakProxyDigestCredentials) { << "proxy digest credentials must never reach a bypassed origin"; } #endif + +TEST(NoProxyTest, MultiHopRedirectThroughBypassedHostKeepsProxy) { + // A → (via proxy) → 302 → B (NO_PROXY-matched, direct) → 302 → C (must + // re-engage the proxy). With the bug, the redirect client built for B is + // created without proxy_host_ because is_proxy_enabled_for_host(B) is + // false; the subsequent B→C redirect then loses the proxy entirely and + // either hits DNS (and fails on an unresolvable name) or quietly goes + // direct to a host the user expected to be proxied. + + std::atomic proxy_hits{0}; + std::atomic bypass_hits{0}; + std::atomic proxy_saw_c_url{false}; + + Server proxy_mock; + Server bypass_server; + + int proxy_port = proxy_mock.bind_to_any_port("127.0.0.1"); + int bypass_port = bypass_server.bind_to_any_port("127.0.0.1"); + + proxy_mock.Get(".*", [&](const Request &req, Response &res) { + proxy_hits++; + if (req.path.find("/start") != std::string::npos) { + res.status = 302; + res.set_header("Location", "http://localhost:" + + std::to_string(bypass_port) + "/middle"); + return; + } + if (req.path.find("/end") != std::string::npos) { + proxy_saw_c_url = true; + res.set_content("c-via-proxy", "text/plain"); + return; + } + res.set_content("unexpected", "text/plain"); + }); + + bypass_server.Get(".*", [&](const Request & /*req*/, Response &res) { + bypass_hits++; + // Redirect to an unresolvable hostname. The final leg can ONLY succeed if + // the redirect client still has the proxy configured and goes via it + // (absolute-URL form to the proxy, no DNS lookup for public.example). + res.status = 302; + res.set_header("Location", "http://public.example:80/end"); + }); + + std::thread proxy_thread([&] { proxy_mock.listen_after_bind(); }); + std::thread bypass_thread([&] { bypass_server.listen_after_bind(); }); + auto cleanup = detail::scope_exit([&] { + proxy_mock.stop(); + bypass_server.stop(); + if (proxy_thread.joinable()) { proxy_thread.join(); } + if (bypass_thread.joinable()) { bypass_thread.join(); } + }); + proxy_mock.wait_until_ready(); + bypass_server.wait_until_ready(); + + Client cli("public.example", 80); + cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", proxy_port); + cli.set_no_proxy({"localhost"}); + cli.set_follow_location(true); + + auto res = cli.Get("/start"); + ASSERT_TRUE(res); + EXPECT_EQ(StatusCode::OK_200, res->status); + EXPECT_EQ("c-via-proxy", res->body) + << "final leg must have been served by the proxy, not lost to DNS"; + EXPECT_GE(proxy_hits.load(), 2) << "proxy must be hit twice (start + end)"; + EXPECT_GE(bypass_hits.load(), 1) << "bypassed middle leg must go direct"; + EXPECT_TRUE(proxy_saw_c_url.load()) + << "final leg must be the one served by the proxy"; +} + +TEST(NoProxyTest, KeepAliveSocketInvalidatedOnSetNoProxy) { + // Toggling NO_PROXY mid-session with keep-alive enabled must drop the + // existing socket. Without that, the next request reuses a socket pointed + // at the previous endpoint (proxy vs origin) and write_request emits the + // wrong request-line form (absolute vs relative URL). + + std::atomic proxy_hits{0}; + std::atomic target_hits{0}; + + Server proxy_mock; + Server target; + + int proxy_port = proxy_mock.bind_to_any_port("127.0.0.1"); + int target_port = target.bind_to_any_port("127.0.0.1"); + + proxy_mock.Get(".*", [&](const Request & /*req*/, Response &res) { + proxy_hits++; + res.set_content("via-proxy", "text/plain"); + }); + target.Get(".*", [&](const Request & /*req*/, Response &res) { + target_hits++; + res.set_content("direct", "text/plain"); + }); + + std::thread proxy_thread([&] { proxy_mock.listen_after_bind(); }); + std::thread target_thread([&] { target.listen_after_bind(); }); + auto cleanup = detail::scope_exit([&] { + proxy_mock.stop(); + target.stop(); + if (proxy_thread.joinable()) { proxy_thread.join(); } + if (target_thread.joinable()) { target_thread.join(); } + }); + proxy_mock.wait_until_ready(); + target.wait_until_ready(); + + Client cli("public.example", target_port); + cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", proxy_port); + cli.set_keep_alive(true); + + auto res1 = cli.Get("/a"); + ASSERT_TRUE(res1); + EXPECT_EQ("via-proxy", res1->body); + EXPECT_EQ(1, proxy_hits.load()); + EXPECT_EQ(0, target_hits.load()); + + // Flip to bypass-everything. The next request must reach target directly, + // not reuse the keep-alive socket that's connected to the proxy. + cli.set_no_proxy({"public.example"}); + + auto res2 = cli.Get("/b"); + ASSERT_TRUE(res2); + EXPECT_EQ("direct", res2->body) + << "second request must bypass the proxy and reach the target directly"; + EXPECT_EQ(1, proxy_hits.load()) << "proxy must not see the second request"; + EXPECT_EQ(1, target_hits.load()) << "target must see exactly one request"; +} From e2ae085ac6540a822ecb9cbc62260583a58b7aaf Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 24 May 2026 22:40:12 -0400 Subject: [PATCH 18/22] Tighten NO_PROXY entry parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small parser fixes surfaced during code review: - Accept bracketed IPv6 entries like "[::1]" and "[fe80::]/10". Users coming from URL syntax naturally write the bracketed form; previously it was silently rejected because inet_pton does not accept brackets and the subsequent ':' check tripped. - Reject malformed trailing-slash CIDRs like "127.0.0.1/" instead of silently treating them as /32 (or /128). A typoed entry quietly turning into a single-host bypass changes semantics with no diagnostic. - Delete detail::parse_no_proxy_list — leftover from the removed set_proxy_from_env path, no longer called from anywhere. New regression tests: BracketedIPv6EntryAccepted, BracketedIPv6CidrEntryAccepted, TrailingSlashCidrIsRejected. --- httplib.h | 60 +++++++++++++++++++++++++++------------------------- test/test.cc | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 29 deletions(-) diff --git a/httplib.h b/httplib.h index ec164353ca..a5d78e895b 100644 --- a/httplib.h +++ b/httplib.h @@ -10554,7 +10554,6 @@ make_host_and_port_string_always_port(const std::string &host, int port) { } bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out); -std::vector parse_no_proxy_list(const std::string &value); NormalizedTarget normalize_target(const std::string &host); bool ip_in_cidr(const IPBytes &ip, const IPBytes &net, int prefix_bits); bool host_matches_no_proxy(const NormalizedTarget &target, @@ -10589,22 +10588,35 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { std::string prefix_part = (slash == std::string::npos) ? std::string() : token.substr(slash + 1); - struct in_addr v4; - if (inet_pton(AF_INET, addr_part.c_str(), &v4) == 1) { - int prefix = 32; - if (!prefix_part.empty()) { - auto r = from_chars(prefix_part.data(), - prefix_part.data() + prefix_part.size(), prefix); - if (r.ec != std::errc{} || - r.ptr != prefix_part.data() + prefix_part.size()) { - return false; + // A bare slash or trailing-slash CIDR like "10.0.0.0/" is malformed; + // don't silently treat it as a /32 (or /128). + if (slash != std::string::npos && prefix_part.empty()) { return false; } + + // Accept the bracketed IPv6 form ("[::1]", "[fe80::]/10") as well as the + // bare form. Brackets have no meaning for IPv4, so skip the IPv4 attempt + // when brackets are present. + bool bracketed = addr_part.size() >= 2 && addr_part.front() == '[' && + addr_part.back() == ']'; + if (bracketed) { addr_part = addr_part.substr(1, addr_part.size() - 2); } + + if (!bracketed) { + struct in_addr v4; + if (inet_pton(AF_INET, addr_part.c_str(), &v4) == 1) { + int prefix = 32; + if (!prefix_part.empty()) { + auto r = from_chars(prefix_part.data(), + prefix_part.data() + prefix_part.size(), prefix); + if (r.ec != std::errc{} || + r.ptr != prefix_part.data() + prefix_part.size()) { + return false; + } + if (prefix < 0 || prefix > 32) { return false; } } - if (prefix < 0 || prefix > 32) { return false; } + out.kind = NoProxyKind::IPv4Cidr; + std::memcpy(out.net.data(), &v4, sizeof(v4)); + out.prefix_bits = prefix; + return true; } - out.kind = NoProxyKind::IPv4Cidr; - std::memcpy(out.net.data(), &v4, sizeof(v4)); - out.prefix_bits = prefix; - return true; } struct in6_addr v6; @@ -10625,6 +10637,10 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { return true; } + // Bracketed entries can only be IPv6. If the IPv6 parse above failed, + // the entry is malformed — don't fall through to the hostname branch. + if (bracketed) { return false; } + // A '/' on a non-IP token means a CIDR prefix without an address. Reject. if (slash != std::string::npos) { return false; } // Port-specific entries (host:port) are not supported. @@ -10644,20 +10660,6 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { return true; } -inline std::vector parse_no_proxy_list(const std::string &value) { - std::vector entries; - if (value.empty()) { return entries; } - // detail::split already trims each token and skips empty ones. - split(value.data(), value.data() + value.size(), ',', - [&](const char *b, const char *e) { - NoProxyEntry entry; - if (parse_no_proxy_entry(std::string(b, e), entry)) { - entries.push_back(std::move(entry)); - } - }); - return entries; -} - inline NormalizedTarget normalize_target(const std::string &host) { NormalizedTarget t; std::string h = host; diff --git a/test/test.cc b/test/test.cc index f79aa2b822..df3d251bb9 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18496,6 +18496,35 @@ TEST(NoProxyTest, BareIPv6LiteralMatchesIPv6Cidr) { EXPECT_EQ(1, s.target_hits()); } +TEST(NoProxyTest, BracketedIPv6EntryAccepted) { + // Users coming from URL syntax naturally write "[::1]"; the entry must + // be accepted, not silently dropped (which would otherwise route the + // request through the proxy). + ProxyAndTargetServers s; + Client cli("::1", s.target_port()); + cli.set_hostname_addr_map({{"::1", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"[::1]"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +TEST(NoProxyTest, BracketedIPv6CidrEntryAccepted) { + ProxyAndTargetServers s; + Client cli("fe80::1", s.target_port()); + cli.set_hostname_addr_map({{"fe80::1", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"[fe80::]/10"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + TEST(NoProxyTest, IPv4MappedIPv6IsNotCrossMatchedAgainstIPv4Entry) { // Policy: keep address families separate. "::ffff:1.2.3.4" must NOT // satisfy a NO_PROXY entry of "1.2.3.4". This avoids subtle bypass @@ -18574,6 +18603,21 @@ TEST(NoProxyTest, MalformedCidrPrefixIsDropped) { EXPECT_EQ(0, s.target_hits()); } +TEST(NoProxyTest, TrailingSlashCidrIsRejected) { + // "127.0.0.1/" (empty prefix after the slash) is malformed — must not + // be silently narrowed to /32, otherwise a typoed entry quietly turns + // into an unintended single-host bypass. + ProxyAndTargetServers s; + Client cli("127.0.0.1", s.target_port()); + cli.set_proxy("127.0.0.1", s.proxy_port()); + cli.set_no_proxy({"127.0.0.1/"}); + + auto res = cli.Get("/"); + ASSERT_TRUE(res); + EXPECT_GE(s.proxy_hits(), 1) << "trailing-slash CIDR must be dropped"; + EXPECT_EQ(0, s.target_hits()); +} + TEST(NoProxyTest, ProxyAuthorizationSuppressedWhenBypassed) { ProxyAndTargetServers s; auto cli = make_client("internal.corp", s); From e482dfac6d969c9a3c33843b915a5757e21529cd Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 24 May 2026 23:20:44 -0400 Subject: [PATCH 19/22] Refactor: introduce disconnect() and remove invalidate_keep_alive_socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the repeated `shutdown_ssl + shutdown_socket + close_socket` pattern with a single `disconnect(bool gracefully)` helper. Used by `stop()`, the send_() peer-closed and epilogue branches, and the close in process_request after a non-keep-alive response. Drop `invalidate_keep_alive_socket()` — its body collapses to a `lock + disconnect()` pair which is now inlined in `set_proxy()` and `set_no_proxy()` directly. Also simplify `setup_redirect_client`: drop the now-unused next_host parameter and the verbose comment block; the per-target proxy decision is re-evaluated at send time anyway. Net -47 lines in httplib.h. --- httplib.h | 83 +++++++++++++++---------------------------------------- 1 file changed, 23 insertions(+), 60 deletions(-) diff --git a/httplib.h b/httplib.h index a5d78e895b..74eed7f5eb 100644 --- a/httplib.h +++ b/httplib.h @@ -2297,12 +2297,13 @@ class ClientImpl { // shutdown_ssl // shutdown_socket // close_socket - // should ONLY be called when socket_mutex_ is locked. - // Also, shutdown_ssl and close_socket should also NOT be called concurrently - // with a DIFFERENT thread sending requests using that socket. + // disconnect + // should ONLY be called when socket_mutex_ is locked, and only when + // no other thread is using the socket. virtual void shutdown_ssl(Socket &socket, bool shutdown_gracefully); void shutdown_socket(Socket &socket) const; void close_socket(Socket &socket); + void disconnect(bool gracefully); bool process_request(Stream &strm, Request &req, Response &res, bool close_connection, Error &error); @@ -2310,12 +2311,6 @@ class ClientImpl { bool write_content_with_provider(Stream &strm, const Request &req, Error &error) const; - // Invalidate the keep-alive socket after a configuration change that - // affects which endpoint the next request should target (proxy/no_proxy). - // Safe to call regardless of in-flight state — falls back to deferred - // close via socket_should_be_closed_when_request_is_done_. - void invalidate_keep_alive_socket(); - void copy_settings(const ClientImpl &rhs); void output_log(const Request &req, const Response &res) const; @@ -12634,6 +12629,12 @@ inline void ClientImpl::close_socket(Socket &socket) { socket.sock = INVALID_SOCKET; } +inline void ClientImpl::disconnect(bool gracefully) { + shutdown_ssl(socket_, gracefully); + shutdown_socket(socket_); + close_socket(socket_); +} + inline bool ClientImpl::read_response_line(Stream &strm, const Request &req, Response &res, bool skip_100_continue) const { @@ -12705,14 +12706,8 @@ inline bool ClientImpl::send_(Request &req, Response &res, Error &error) { #endif if (!is_alive) { - // Attempt to avoid sigpipe by shutting down non-gracefully if it - // seems like the other side has already closed the connection Also, - // there cannot be any requests in flight from other threads since we - // locked request_mutex_, so safe to close everything immediately - const bool shutdown_gracefully = false; - shutdown_ssl(socket_, shutdown_gracefully); - shutdown_socket(socket_); - close_socket(socket_); + // Peer seems gone — non-graceful shutdown to avoid SIGPIPE. + disconnect(/*gracefully=*/false); } } @@ -12762,9 +12757,7 @@ inline bool ClientImpl::send_(Request &req, Response &res, Error &error) { if (socket_should_be_closed_when_request_is_done_ || close_connection || !ret) { - shutdown_ssl(socket_, true); - shutdown_socket(socket_); - close_socket(socket_); + disconnect(/*gracefully=*/true); } }); @@ -12877,11 +12870,7 @@ ClientImpl::open_stream(const std::string &method, const std::string &path, } } #endif - if (!is_alive) { - shutdown_ssl(socket_, false); - shutdown_socket(socket_); - close_socket(socket_); - } + if (!is_alive) { disconnect(/*gracefully=*/false); } } if (!is_alive) { @@ -13197,9 +13186,7 @@ inline bool ClientImpl::handle_request(Stream &strm, Request &req, // to call it from a different thread since it's a thread-safety issue // to do these things to the socket if another thread is using the socket. std::lock_guard guard(socket_mutex_); - shutdown_ssl(socket_, true); - shutdown_socket(socket_); - close_socket(socket_); + disconnect(/*gracefully=*/true); } if (300 < res.status && res.status < 400 && follow_location_) { @@ -13385,13 +13372,9 @@ inline void ClientImpl::setup_redirect_client(ClientType &client) { // host. This function is only called for cross-host redirects; same-host // redirects are handled directly in ClientImpl::redirect(). - // Copy the full proxy configuration (including the bypass list) - // unconditionally. The per-target proxy/bypass decision is re-evaluated at - // send time by is_proxy_enabled_for_host(host_). Skipping the proxy copy - // when next_host happens to be bypassed would permanently strand the proxy - // configuration: a subsequent redirect from the new client to a - // non-bypassed host would have no proxy_host_ to fall back on and would - // silently go direct. + // Copy the proxy configuration unconditionally; the per-target bypass is + // re-evaluated at send time, so a later hop to a non-bypassed host can + // still use the proxy. client.no_proxy_entries_ = no_proxy_entries_; if (!proxy_host_.empty() && proxy_port_ != -1) { client.set_proxy(proxy_host_, proxy_port_); @@ -14821,24 +14804,7 @@ inline void ClientImpl::stop() { return; } - // Otherwise, still holding the mutex, we can shut everything down ourselves - shutdown_ssl(socket_, true); - shutdown_socket(socket_); - close_socket(socket_); -} - -inline void ClientImpl::invalidate_keep_alive_socket() { - std::lock_guard guard(socket_mutex_); - if (!socket_.is_open()) { return; } - if (socket_requests_in_flight_ > 0) { - // A request is currently using the socket — defer the close so the - // in-flight thread completes cleanly and the next request reconnects. - socket_should_be_closed_when_request_is_done_ = true; - return; - } - shutdown_ssl(socket_, true); - shutdown_socket(socket_); - close_socket(socket_); + disconnect(/*gracefully=*/true); } inline std::string ClientImpl::host() const { return host_; } @@ -14929,9 +14895,8 @@ inline void ClientImpl::set_interface(const std::string &intf) { inline void ClientImpl::set_proxy(const std::string &host, int port) { proxy_host_ = host; proxy_port_ = port; - // Any keep-alive socket may be pointed at the previous proxy (or at the - // origin if proxy was unset); drop it so the next request reconnects. - invalidate_keep_alive_socket(); + std::lock_guard guard(socket_mutex_); + disconnect(/*gracefully=*/true); } inline void ClientImpl::set_proxy_basic_auth(const std::string &username, @@ -14956,10 +14921,8 @@ inline void ClientImpl::set_no_proxy(const std::vector &patterns) { } } no_proxy_entries_ = std::move(parsed); - // The bypass decision may have flipped for the keep-alive target; drop the - // existing socket so the next request reconnects to the correct endpoint - // (proxy vs origin) and emits the matching request-line form. - invalidate_keep_alive_socket(); + std::lock_guard guard(socket_mutex_); + disconnect(/*gracefully=*/true); } #ifdef CPPHTTPLIB_SSL_ENABLED From 629d46ab44c60101fd1f109aedcafbbec254d3c1 Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 24 May 2026 23:20:57 -0400 Subject: [PATCH 20/22] Fix MultiHopRedirect test on Windows; trim NoProxyTest comments The bypass leg redirected to "http://localhost:/...", but on Windows `localhost` resolves to ::1 first while the mock server is bound to 127.0.0.1, causing the redirect leg to time out. Use the literal 127.0.0.1 in the Location and switch the NO_PROXY entry to match, so the test exercises the same multi-hop path on every platform. Also trim the heavier inline comments and EXPECT messages I added on recent NoProxyTest cases so they match the surrounding test style. --- test/test.cc | 68 ++++++++++++++++------------------------------------ 1 file changed, 20 insertions(+), 48 deletions(-) diff --git a/test/test.cc b/test/test.cc index df3d251bb9..b9fa4fa483 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18497,9 +18497,6 @@ TEST(NoProxyTest, BareIPv6LiteralMatchesIPv6Cidr) { } TEST(NoProxyTest, BracketedIPv6EntryAccepted) { - // Users coming from URL syntax naturally write "[::1]"; the entry must - // be accepted, not silently dropped (which would otherwise route the - // request through the proxy). ProxyAndTargetServers s; Client cli("::1", s.target_port()); cli.set_hostname_addr_map({{"::1", "127.0.0.1"}}); @@ -18604,9 +18601,7 @@ TEST(NoProxyTest, MalformedCidrPrefixIsDropped) { } TEST(NoProxyTest, TrailingSlashCidrIsRejected) { - // "127.0.0.1/" (empty prefix after the slash) is malformed — must not - // be silently narrowed to /32, otherwise a typoed entry quietly turns - // into an unintended single-host bypass. + // Empty prefix after the slash must be rejected, not silently treated as /32. ProxyAndTargetServers s; Client cli("127.0.0.1", s.target_port()); cli.set_proxy("127.0.0.1", s.proxy_port()); @@ -18614,7 +18609,7 @@ TEST(NoProxyTest, TrailingSlashCidrIsRejected) { auto res = cli.Get("/"); ASSERT_TRUE(res); - EXPECT_GE(s.proxy_hits(), 1) << "trailing-slash CIDR must be dropped"; + EXPECT_GE(s.proxy_hits(), 1); EXPECT_EQ(0, s.target_hits()); } @@ -18758,11 +18753,8 @@ TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) { #ifdef CPPHTTPLIB_SSL_ENABLED TEST(NoProxyTest, BypassedTargetReturning407DoesNotLeakProxyDigestCredentials) { - // A NO_PROXY-bypassed target that replies 407 + Digest challenge must not - // trigger the digest retry — otherwise the client would compute a - // Proxy-Authorization header from proxy_digest_auth_* and send it - // directly to the (potentially hostile) origin. - + // Direct origin replying 407 must not trigger the digest retry; otherwise + // proxy creds would be sent to the (possibly hostile) origin. std::atomic target_hits{0}; std::atomic target_saw_proxy_authz{false}; @@ -18788,29 +18780,20 @@ TEST(NoProxyTest, BypassedTargetReturning407DoesNotLeakProxyDigestCredentials) { Client cli("evil.example", target_port); cli.set_hostname_addr_map({{"evil.example", "127.0.0.1"}}); - cli.set_proxy("127.0.0.1", 1); // unreachable port — proxy must NOT be used + cli.set_proxy("127.0.0.1", 1); cli.set_proxy_digest_auth("proxy-user", "proxy-pass"); cli.set_no_proxy({"evil.example"}); auto res = cli.Get("/x"); ASSERT_TRUE(res); - EXPECT_EQ(StatusCode::ProxyAuthenticationRequired_407, res->status) - << "the 407 must be propagated to the caller, not silently swallowed"; - EXPECT_EQ(1, target_hits.load()) - << "the client must not retry; exactly one request to target"; - EXPECT_FALSE(target_saw_proxy_authz.load()) - << "proxy digest credentials must never reach a bypassed origin"; + EXPECT_EQ(StatusCode::ProxyAuthenticationRequired_407, res->status); + EXPECT_EQ(1, target_hits.load()); + EXPECT_FALSE(target_saw_proxy_authz.load()); } #endif TEST(NoProxyTest, MultiHopRedirectThroughBypassedHostKeepsProxy) { - // A → (via proxy) → 302 → B (NO_PROXY-matched, direct) → 302 → C (must - // re-engage the proxy). With the bug, the redirect client built for B is - // created without proxy_host_ because is_proxy_enabled_for_host(B) is - // false; the subsequent B→C redirect then loses the proxy entirely and - // either hits DNS (and fails on an unresolvable name) or quietly goes - // direct to a host the user expected to be proxied. - + // A (via proxy) → B (NO_PROXY-matched, direct) → C must re-engage the proxy. std::atomic proxy_hits{0}; std::atomic bypass_hits{0}; std::atomic proxy_saw_c_url{false}; @@ -18825,7 +18808,7 @@ TEST(NoProxyTest, MultiHopRedirectThroughBypassedHostKeepsProxy) { proxy_hits++; if (req.path.find("/start") != std::string::npos) { res.status = 302; - res.set_header("Location", "http://localhost:" + + res.set_header("Location", "http://127.0.0.1:" + std::to_string(bypass_port) + "/middle"); return; } @@ -18839,9 +18822,6 @@ TEST(NoProxyTest, MultiHopRedirectThroughBypassedHostKeepsProxy) { bypass_server.Get(".*", [&](const Request & /*req*/, Response &res) { bypass_hits++; - // Redirect to an unresolvable hostname. The final leg can ONLY succeed if - // the redirect client still has the proxy configured and goes via it - // (absolute-URL form to the proxy, no DNS lookup for public.example). res.status = 302; res.set_header("Location", "http://public.example:80/end"); }); @@ -18860,26 +18840,21 @@ TEST(NoProxyTest, MultiHopRedirectThroughBypassedHostKeepsProxy) { Client cli("public.example", 80); cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}}); cli.set_proxy("127.0.0.1", proxy_port); - cli.set_no_proxy({"localhost"}); + cli.set_no_proxy({"127.0.0.1"}); cli.set_follow_location(true); auto res = cli.Get("/start"); ASSERT_TRUE(res); EXPECT_EQ(StatusCode::OK_200, res->status); - EXPECT_EQ("c-via-proxy", res->body) - << "final leg must have been served by the proxy, not lost to DNS"; - EXPECT_GE(proxy_hits.load(), 2) << "proxy must be hit twice (start + end)"; - EXPECT_GE(bypass_hits.load(), 1) << "bypassed middle leg must go direct"; - EXPECT_TRUE(proxy_saw_c_url.load()) - << "final leg must be the one served by the proxy"; + EXPECT_EQ("c-via-proxy", res->body); + EXPECT_GE(proxy_hits.load(), 2); + EXPECT_GE(bypass_hits.load(), 1); + EXPECT_TRUE(proxy_saw_c_url.load()); } TEST(NoProxyTest, KeepAliveSocketInvalidatedOnSetNoProxy) { - // Toggling NO_PROXY mid-session with keep-alive enabled must drop the - // existing socket. Without that, the next request reuses a socket pointed - // at the previous endpoint (proxy vs origin) and write_request emits the - // wrong request-line form (absolute vs relative URL). - + // Mid-session set_no_proxy must drop any keep-alive socket so the next + // request reconnects to the correct endpoint. std::atomic proxy_hits{0}; std::atomic target_hits{0}; @@ -18920,14 +18895,11 @@ TEST(NoProxyTest, KeepAliveSocketInvalidatedOnSetNoProxy) { EXPECT_EQ(1, proxy_hits.load()); EXPECT_EQ(0, target_hits.load()); - // Flip to bypass-everything. The next request must reach target directly, - // not reuse the keep-alive socket that's connected to the proxy. cli.set_no_proxy({"public.example"}); auto res2 = cli.Get("/b"); ASSERT_TRUE(res2); - EXPECT_EQ("direct", res2->body) - << "second request must bypass the proxy and reach the target directly"; - EXPECT_EQ(1, proxy_hits.load()) << "proxy must not see the second request"; - EXPECT_EQ(1, target_hits.load()) << "target must see exactly one request"; + EXPECT_EQ("direct", res2->body); + EXPECT_EQ(1, proxy_hits.load()); + EXPECT_EQ(1, target_hits.load()); } From 9901a4e1f9753456db619c3bd3200f908b29429c Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 24 May 2026 23:31:53 -0400 Subject: [PATCH 21/22] Consolidate NoProxyTest server boilerplate; drop hardcoded sentinel ports Add a small ScopedServer helper to no_proxy_test that wraps the bind/listen/thread/cleanup dance (~13 lines per server before). Use it to rewrite the four big tests (Redirect, BypassedTarget407, MultiHop, KeepAlive), shaving ~100 lines. Also drop the hardcoded port-1 / port-80 sentinels that violated the "new standalone tests MUST use bind_to_any_port" convention and risked collisions across gtest shards: re-use existing dynamic ports (target.port() / bypass_server.port()) instead. Verified pass under 4-shard parallel run. --- test/test.cc | 166 +++++++++++++++++++++------------------------------ 1 file changed, 68 insertions(+), 98 deletions(-) diff --git a/test/test.cc b/test/test.cc index b9fa4fa483..6f64a1c924 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18279,6 +18279,29 @@ TEST(KeepAliveTest, DeleteWithoutContentLengthDoesNotEatNextRequest) { namespace no_proxy_test { +// Server bound to 127.0.0.1:, listen thread spawned by listen(), +// auto-stopped + joined on scope exit. Register handlers via svr() BEFORE +// calling listen(). +class ScopedServer { +public: + ScopedServer() { port_ = svr_.bind_to_any_port("127.0.0.1"); } + ~ScopedServer() { + svr_.stop(); + if (th_.joinable()) { th_.join(); } + } + Server &svr() { return svr_; } + int port() const { return port_; } + void listen() { + th_ = std::thread([this] { svr_.listen_after_bind(); }); + svr_.wait_until_ready(); + } + +private: + Server svr_; + std::thread th_; + int port_ = 0; +}; + class ProxyAndTargetServers { public: ProxyAndTargetServers() { @@ -18688,67 +18711,40 @@ TEST(NoProxyTest, ValidEntryWithSurroundingWhitespaceStillMatches) { } TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) { - // Analog of GHSA-6hrp-7fq9-3qv2: when a redirect targets a host in - // NO_PROXY, the follow-up request must go direct and must NOT carry - // Proxy-Authorization. Built without the ProxyAndTargetServers helper - // so the proxy mock can issue a 302 specifically for the /redir path. - + // Analog of GHSA-6hrp-7fq9-3qv2: redirect to a NO_PROXY-matched host must + // go direct and must NOT carry Proxy-Authorization. std::atomic proxy_hits{0}; std::atomic target_hits{0}; - std::atomic proxy_saw_authz{false}; std::atomic target_saw_authz{false}; - Server proxy_mock; - Server target; + no_proxy_test::ScopedServer proxy_mock, target; - int target_port = target.bind_to_any_port("127.0.0.1"); - int proxy_port = proxy_mock.bind_to_any_port("127.0.0.1"); - - proxy_mock.Get(".*", [&](const Request &req, Response &res) { + proxy_mock.svr().Get(".*", [&](const Request &, Response &res) { proxy_hits++; - if (req.has_header("Proxy-Authorization")) { proxy_saw_authz = true; } - if (req.path.find("/redir") != std::string::npos) { - res.status = 302; - res.set_header("Location", "http://127.0.0.1:" + - std::to_string(target_port) + "/landed"); - return; - } - res.set_content("via-proxy", "text/plain"); + res.status = 302; + res.set_header("Location", "http://127.0.0.1:" + + std::to_string(target.port()) + "/landed"); }); - - target.Get(".*", [&](const Request &req, Response &res) { + target.svr().Get(".*", [&](const Request &req, Response &res) { target_hits++; if (req.has_header("Proxy-Authorization")) { target_saw_authz = true; } res.set_content("direct", "text/plain"); }); + proxy_mock.listen(); + target.listen(); - std::thread proxy_thread([&] { proxy_mock.listen_after_bind(); }); - std::thread target_thread([&] { target.listen_after_bind(); }); - auto cleanup = detail::scope_exit([&] { - proxy_mock.stop(); - target.stop(); - if (proxy_thread.joinable()) { proxy_thread.join(); } - if (target_thread.joinable()) { target_thread.join(); } - }); - proxy_mock.wait_until_ready(); - target.wait_until_ready(); - - Client cli("public.example", target_port); + Client cli("public.example", target.port()); cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}}); - cli.set_proxy("127.0.0.1", proxy_port); + cli.set_proxy("127.0.0.1", proxy_mock.port()); cli.set_proxy_basic_auth("u", "p"); cli.set_no_proxy({"127.0.0.1"}); cli.set_follow_location(true); auto res = cli.Get("/redir"); ASSERT_TRUE(res); - EXPECT_GE(proxy_hits.load(), 1) << "first leg must hit the proxy"; - EXPECT_GE(target_hits.load(), 1) - << "redirect leg must reach the target directly"; - EXPECT_FALSE(target_saw_authz.load()) - << "Proxy-Authorization must not be sent on the bypassed redirect leg"; - // The first leg (going through the proxy) is allowed to carry - // Proxy-Authorization; we only assert the bypassed leg does not. + EXPECT_GE(proxy_hits.load(), 1); + EXPECT_GE(target_hits.load(), 1); + EXPECT_FALSE(target_saw_authz.load()); } #ifdef CPPHTTPLIB_SSL_ENABLED @@ -18758,10 +18754,8 @@ TEST(NoProxyTest, BypassedTargetReturning407DoesNotLeakProxyDigestCredentials) { std::atomic target_hits{0}; std::atomic target_saw_proxy_authz{false}; - Server target; - int target_port = target.bind_to_any_port("127.0.0.1"); - - target.Get(".*", [&](const Request &req, Response &res) { + no_proxy_test::ScopedServer target; + target.svr().Get(".*", [&](const Request &req, Response &res) { target_hits++; if (req.has_header("Proxy-Authorization")) { target_saw_proxy_authz = true; @@ -18770,17 +18764,14 @@ TEST(NoProxyTest, BypassedTargetReturning407DoesNotLeakProxyDigestCredentials) { res.set_header("Proxy-Authenticate", "Digest realm=\"evil\", qop=\"auth\", " "nonce=\"abc\", algorithm=MD5"); }); + target.listen(); - std::thread target_thread([&] { target.listen_after_bind(); }); - auto cleanup = detail::scope_exit([&] { - target.stop(); - if (target_thread.joinable()) { target_thread.join(); } - }); - target.wait_until_ready(); - - Client cli("evil.example", target_port); + // The proxy address is set to the target's port: the bypass MUST kick in; + // if it doesn't, the test still routes "via proxy" to the same server and + // the Proxy-Authorization assertion below catches the leak. + Client cli("evil.example", target.port()); cli.set_hostname_addr_map({{"evil.example", "127.0.0.1"}}); - cli.set_proxy("127.0.0.1", 1); + cli.set_proxy("127.0.0.1", target.port()); cli.set_proxy_digest_auth("proxy-user", "proxy-pass"); cli.set_no_proxy({"evil.example"}); @@ -18798,18 +18789,15 @@ TEST(NoProxyTest, MultiHopRedirectThroughBypassedHostKeepsProxy) { std::atomic bypass_hits{0}; std::atomic proxy_saw_c_url{false}; - Server proxy_mock; - Server bypass_server; - - int proxy_port = proxy_mock.bind_to_any_port("127.0.0.1"); - int bypass_port = bypass_server.bind_to_any_port("127.0.0.1"); + no_proxy_test::ScopedServer proxy_mock, bypass_server; - proxy_mock.Get(".*", [&](const Request &req, Response &res) { + proxy_mock.svr().Get(".*", [&](const Request &req, Response &res) { proxy_hits++; if (req.path.find("/start") != std::string::npos) { res.status = 302; res.set_header("Location", "http://127.0.0.1:" + - std::to_string(bypass_port) + "/middle"); + std::to_string(bypass_server.port()) + + "/middle"); return; } if (req.path.find("/end") != std::string::npos) { @@ -18819,27 +18807,22 @@ TEST(NoProxyTest, MultiHopRedirectThroughBypassedHostKeepsProxy) { } res.set_content("unexpected", "text/plain"); }); - - bypass_server.Get(".*", [&](const Request & /*req*/, Response &res) { + // public.example's "advertised" port is arbitrary (the request never lands + // there — it goes through the proxy), but use a dynamic value to stay + // friendly with sharded parallel runs. + int public_port = bypass_server.port(); + bypass_server.svr().Get(".*", [&](const Request &, Response &res) { bypass_hits++; res.status = 302; - res.set_header("Location", "http://public.example:80/end"); - }); - - std::thread proxy_thread([&] { proxy_mock.listen_after_bind(); }); - std::thread bypass_thread([&] { bypass_server.listen_after_bind(); }); - auto cleanup = detail::scope_exit([&] { - proxy_mock.stop(); - bypass_server.stop(); - if (proxy_thread.joinable()) { proxy_thread.join(); } - if (bypass_thread.joinable()) { bypass_thread.join(); } + res.set_header("Location", "http://public.example:" + + std::to_string(public_port) + "/end"); }); - proxy_mock.wait_until_ready(); - bypass_server.wait_until_ready(); + proxy_mock.listen(); + bypass_server.listen(); - Client cli("public.example", 80); + Client cli("public.example", public_port); cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}}); - cli.set_proxy("127.0.0.1", proxy_port); + cli.set_proxy("127.0.0.1", proxy_mock.port()); cli.set_no_proxy({"127.0.0.1"}); cli.set_follow_location(true); @@ -18858,35 +18841,22 @@ TEST(NoProxyTest, KeepAliveSocketInvalidatedOnSetNoProxy) { std::atomic proxy_hits{0}; std::atomic target_hits{0}; - Server proxy_mock; - Server target; - - int proxy_port = proxy_mock.bind_to_any_port("127.0.0.1"); - int target_port = target.bind_to_any_port("127.0.0.1"); + no_proxy_test::ScopedServer proxy_mock, target; - proxy_mock.Get(".*", [&](const Request & /*req*/, Response &res) { + proxy_mock.svr().Get(".*", [&](const Request &, Response &res) { proxy_hits++; res.set_content("via-proxy", "text/plain"); }); - target.Get(".*", [&](const Request & /*req*/, Response &res) { + target.svr().Get(".*", [&](const Request &, Response &res) { target_hits++; res.set_content("direct", "text/plain"); }); + proxy_mock.listen(); + target.listen(); - std::thread proxy_thread([&] { proxy_mock.listen_after_bind(); }); - std::thread target_thread([&] { target.listen_after_bind(); }); - auto cleanup = detail::scope_exit([&] { - proxy_mock.stop(); - target.stop(); - if (proxy_thread.joinable()) { proxy_thread.join(); } - if (target_thread.joinable()) { target_thread.join(); } - }); - proxy_mock.wait_until_ready(); - target.wait_until_ready(); - - Client cli("public.example", target_port); + Client cli("public.example", target.port()); cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}}); - cli.set_proxy("127.0.0.1", proxy_port); + cli.set_proxy("127.0.0.1", proxy_mock.port()); cli.set_keep_alive(true); auto res1 = cli.Get("/a"); From 2ad877322b3528afd55a40df4e22f6d065afb384 Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 24 May 2026 23:45:49 -0400 Subject: [PATCH 22/22] Trim README NO_PROXY section to match surrounding granularity The block had ballooned to 62 lines while neighboring subsections (Authentication, Proxy server support, Range, Redirect) are 13-18 each. Collapse to a single code example + one-line behavior summary; point at the cookbook for the entry-form details, env-var parsing snippet, and httpoxy note that used to live inline. --- README.md | 59 ++++--------------------------------------------------- 1 file changed, 4 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 6ac03fd7db..d4bbb2e5c2 100644 --- a/README.md +++ b/README.md @@ -1183,65 +1183,14 @@ cli.set_proxy_bearer_token_auth("pass"); #### Bypass the proxy for specific hosts (`NO_PROXY`) -`set_no_proxy` configures a bypass list. Each pattern is one of: - -- `*` — bypass the proxy for every target -- a hostname suffix, e.g. `example.com` — matches `example.com` and any - subdomain (`foo.example.com`). A leading dot (`.example.com`) is - permitted but informational; both forms are equivalent. -- a single IP literal, e.g. `192.168.1.1`, `::1` -- a CIDR block, e.g. `10.0.0.0/8`, `fe80::/10` - ```cpp -cli.set_proxy("proxy.corp", 3128); cli.set_no_proxy({"internal.corp", "10.0.0.0/8", "*.dev.local"}); ``` -When a NO_PROXY entry matches the target host, the request is sent -directly and the corresponding `Proxy-Authorization` header is -suppressed. - -Hostname matching is **case-insensitive** and uses a dot-boundary rule, -so `evilexample.com` does **not** match an entry of `example.com`. IP -comparisons are normalized through `inet_pton`, so `127.0.0.1` cannot -be bypassed via alternate string forms (e.g. leading-zero octets or -decimal-form integers). Malformed entries are silently dropped. - -Limitations: - -- Port-specific entries (`example.com:8080`) are not supported. cpp-httplib's - other host-keyed APIs (e.g. `set_hostname_addr_map`) are also keyed on - hostname only; supporting host:port for NO_PROXY alone would be - inconsistent. -- IPv4-mapped IPv6 addresses (`::ffff:1.2.3.4`) are not cross-matched - against IPv4 NO_PROXY entries. -- `set_no_proxy` replaces any previously configured list; there is no - append API. - -cpp-httplib does **not** read `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` -itself — this is consistent with `set_ca_cert_path()` and the rest of -the configuration API. If you want that behavior, parse the variables -in your application and pass the bypass patterns to `set_no_proxy()`: - -```cpp -if (auto *v = std::getenv("no_proxy"); v && *v) { - std::vector patterns; - std::stringstream ss(v); - for (std::string item; std::getline(ss, item, ',');) { - // trim whitespace as needed - if (!item.empty()) { patterns.push_back(std::move(item)); } - } - cli.set_no_proxy(patterns); -} -``` - -> [!IMPORTANT] -> If you also read `HTTP_PROXY` from the environment, prefer the -> lowercase `http_proxy` only. The uppercase form is poisoned in -> CGI / FastCGI environments by the `Proxy:` request header -> ([CVE-2016-5385 / "httpoxy"](https://httpoxy.org/)). `HTTPS_PROXY` -> and `NO_PROXY` are safe in either case because their names do not -> begin with `HTTP_`. +Each pattern is `*`, a hostname suffix, an IP literal, or a CIDR block. +Hostname matching is case-insensitive with a dot-boundary rule. See the +[NO_PROXY cookbook](https://yhirose.github.io/cpp-httplib/en/cookbook/c16-proxy) +for details and for reading the variable from the environment. ### Range