diff --git a/README.md b/README.md index 1037ab2e36..d4bbb2e5c2 100644 --- a/README.md +++ b/README.md @@ -1181,6 +1181,17 @@ cli.set_proxy_bearer_token_auth("pass"); > [!NOTE] > OpenSSL is required for Digest Authentication. +#### Bypass the proxy for specific hosts (`NO_PROXY`) + +```cpp +cli.set_no_proxy({"internal.corp", "10.0.0.0/8", "*.dev.local"}); +``` + +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 ```cpp diff --git a/docs-src/pages/en/cookbook/c16-proxy.md b/docs-src/pages/en/cookbook/c16-proxy.md index 5b193b37f9..f860eb03a1 100644 --- a/docs-src/pages/en/cookbook/c16-proxy.md +++ b/docs-src/pages/en/cookbook/c16-proxy.md @@ -49,4 +49,39 @@ 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 + +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 (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(item); } + } + cli.set_no_proxy(patterns); +} +``` + +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 296035ef17..21d9b92e23 100644 --- a/docs-src/pages/ja/cookbook/c16-proxy.md +++ b/docs-src/pages/ja/cookbook/c16-proxy.md @@ -49,4 +49,39 @@ 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もホスト名のみを扱う設計のため)。 + +## 環境変数からプロキシ設定を読み込む + +cpp-httplib本体は`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`を読みません。`set_ca_cert_path()`と同じで、設定APIは常に明示的にしています。環境変数を反映させたい場合は、アプリ側で読んで`set_proxy()`や`set_no_proxy()`に渡してください。 + +```cpp +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(item); } + } + cli.set_no_proxy(patterns); +} +``` + +`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 d54534c951..74eed7f5eb 100644 --- a/httplib.h +++ b/httplib.h @@ -2024,6 +2024,31 @@ inline ssize_t read_body_content(Stream *stream, BodyReader &br, char *buf, class decompressor; +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) +}; + +// 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 + IPBytes net{}; + int prefix_bits = 0; +}; + +struct NormalizedTarget { + std::string hostname; // lowercase; brackets and trailing dot removed + bool is_ipv4 = false; + bool is_ipv6 = false; + IPBytes ip{}; +}; + } // namespace detail class ClientImpl { @@ -2240,6 +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); + void set_no_proxy(const std::vector &patterns); void set_logger(Logger logger); void set_error_logger(ErrorLogger error_logger); @@ -2265,16 +2291,19 @@ 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 // 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); @@ -2352,6 +2381,11 @@ class ClientImpl { std::string proxy_basic_auth_password_; std::string proxy_bearer_token_auth_token_; + std::vector no_proxy_entries_; + + mutable detail::NormalizedTarget host_normalized_; + mutable bool host_normalized_valid_ = false; + mutable std::mutex logger_mutex_; Logger logger_; ErrorLogger error_logger_; @@ -2612,6 +2646,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); @@ -10513,6 +10548,176 @@ make_host_and_port_string_always_port(const std::string &host, int port) { return prepare_host_string(host) + ":" + std::to_string(port); } +bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out); +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, + const std::vector &entries); + +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.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[i] & mask) == (net[i] & 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; + } + + 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); + + // 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; } + } + out.kind = NoProxyKind::IPv4Cidr; + std::memcpy(out.net.data(), &v4, sizeof(v4)); + out.prefix_bits = prefix; + return true; + } + } + + 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; + std::memcpy(out.net.data(), &v6, sizeof(v6)); + out.prefix_bits = prefix; + 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. + if (token.find(':') != std::string::npos) { return false; } + + 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 NormalizedTarget normalize_target(const std::string &host) { + NormalizedTarget t; + std::string h = host; + + if (h.size() >= 2 && h.front() == '[' && h.back() == ']') { + h = h.substr(1, h.size() - 2); + } + + // 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); + + if (!t.hostname.empty()) { + struct in_addr v4; + struct in6_addr v6; + if (inet_pton(AF_INET, t.hostname.c_str(), &v4) == 1) { + t.is_ipv4 = true; + 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; +} + +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 && ip_in_cidr(target.ip, e.net, e.prefix_bits)) { + return true; + } + break; + case NoProxyKind::IPv6Cidr: + if (target.is_ipv6 && ip_in_cidr(target.ip, e.net, e.prefix_bits)) { + return true; + } + break; + case NoProxyKind::HostnameSuffix: + if (target.is_ipv4 || target.is_ipv6) { break; } + if (target.hostname == e.hostname_pattern) { return true; } + // 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] == '.' && + 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) { @@ -12318,6 +12523,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_; @@ -12333,8 +12539,25 @@ inline void ClientImpl::copy_settings(const ClientImpl &rhs) { #endif } +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 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_); + 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_); +} + 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_, @@ -12406,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 { @@ -12477,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); } } @@ -12534,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); } }); @@ -12649,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) { @@ -12945,7 +13162,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) + @@ -12969,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_) { @@ -12984,6 +13199,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 = @@ -13151,13 +13372,13 @@ 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(). - // Setup proxy configuration (CRITICAL ORDER - proxy must be set - // before proxy auth) + // 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) { - // First set proxy host and port 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_); @@ -13248,14 +13469,6 @@ 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)); - } - } - if (!bearer_token_auth_token_.empty()) { if (!req.has_header("Authorization")) { req.headers.insert(make_bearer_token_authentication_header( @@ -13263,8 +13476,18 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req, } } - if (!proxy_bearer_token_auth_token_.empty()) { - if (!req.has_header("Proxy-Authorization")) { + // 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_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)); } @@ -13574,7 +13797,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_; @@ -14581,10 +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_); + disconnect(/*gracefully=*/true); } inline std::string ClientImpl::host() const { return host_; } @@ -14675,6 +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; + std::lock_guard guard(socket_mutex_); + disconnect(/*gracefully=*/true); } inline void ClientImpl::set_proxy_basic_auth(const std::string &username, @@ -14687,6 +14909,22 @@ 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); + std::lock_guard guard(socket_mutex_); + disconnect(/*gracefully=*/true); +} + #ifdef CPPHTTPLIB_SSL_ENABLED inline void ClientImpl::set_digest_auth(const std::string &username, const std::string &password) { @@ -15388,6 +15626,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)); @@ -15617,7 +15858,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 +15971,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); diff --git a/test/test.cc b/test/test.cc index e9683222b7..6f64a1c924 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18276,3 +18276,600 @@ TEST(KeepAliveTest, DeleteWithoutContentLengthDoesNotEatNextRequest) { EXPECT_EQ(2, delete_count.load()); } + +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() { + 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}; +}; + +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; + +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()); +} + +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); + cli->set_no_proxy({"*"}); + + auto res = cli->Get("/"); + ASSERT_TRUE(res); + EXPECT_EQ(0, s.proxy_hits()); + EXPECT_EQ(1, s.target_hits()); +} + +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) { + 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, 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, BracketedIPv6EntryAccepted) { + 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 + // 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()); +} + +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) { + 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) { + 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()); +} + +TEST(NoProxyTest, TrailingSlashCidrIsRejected) { + // 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()); + 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()); +} + +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()); +} + +TEST(NoProxyTest, EmptyNoProxyKeepsProxyOn) { + 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()); +} + +TEST(NoProxyTest, PortSpecificEntryRejected) { + 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) { + 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()); +} + +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: 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 target_saw_authz{false}; + + no_proxy_test::ScopedServer proxy_mock, target; + + proxy_mock.svr().Get(".*", [&](const Request &, Response &res) { + proxy_hits++; + res.status = 302; + res.set_header("Location", "http://127.0.0.1:" + + std::to_string(target.port()) + "/landed"); + }); + 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(); + + 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_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); + EXPECT_GE(target_hits.load(), 1); + EXPECT_FALSE(target_saw_authz.load()); +} + +#ifdef CPPHTTPLIB_SSL_ENABLED +TEST(NoProxyTest, BypassedTargetReturning407DoesNotLeakProxyDigestCredentials) { + // 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}; + + 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; + } + res.status = StatusCode::ProxyAuthenticationRequired_407; + res.set_header("Proxy-Authenticate", "Digest realm=\"evil\", qop=\"auth\", " + "nonce=\"abc\", algorithm=MD5"); + }); + target.listen(); + + // 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", target.port()); + 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); + EXPECT_EQ(1, target_hits.load()); + EXPECT_FALSE(target_saw_proxy_authz.load()); +} +#endif + +TEST(NoProxyTest, MultiHopRedirectThroughBypassedHostKeepsProxy) { + // 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}; + + no_proxy_test::ScopedServer proxy_mock, bypass_server; + + 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_server.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"); + }); + // 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:" + + std::to_string(public_port) + "/end"); + }); + proxy_mock.listen(); + bypass_server.listen(); + + 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_mock.port()); + 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); + EXPECT_GE(proxy_hits.load(), 2); + EXPECT_GE(bypass_hits.load(), 1); + EXPECT_TRUE(proxy_saw_c_url.load()); +} + +TEST(NoProxyTest, KeepAliveSocketInvalidatedOnSetNoProxy) { + // 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}; + + no_proxy_test::ScopedServer proxy_mock, target; + + proxy_mock.svr().Get(".*", [&](const Request &, Response &res) { + proxy_hits++; + res.set_content("via-proxy", "text/plain"); + }); + target.svr().Get(".*", [&](const Request &, Response &res) { + target_hits++; + res.set_content("direct", "text/plain"); + }); + proxy_mock.listen(); + target.listen(); + + 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_mock.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()); + + cli.set_no_proxy({"public.example"}); + + auto res2 = cli.Get("/b"); + ASSERT_TRUE(res2); + EXPECT_EQ("direct", res2->body); + EXPECT_EQ(1, proxy_hits.load()); + EXPECT_EQ(1, target_hits.load()); +}