From 8e424be3fc86ee2bc9fae1399e59614830f77a19 Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 8 May 2026 22:07:50 +0900 Subject: [PATCH 1/5] 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 80d34d99cb..e2f8683829 100644 --- a/httplib.h +++ b/httplib.h @@ -2255,6 +2255,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 @@ -12324,8 +12326,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_, @@ -12936,7 +12947,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) + @@ -13144,7 +13155,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_); @@ -13565,7 +13576,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_; @@ -15608,7 +15619,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; @@ -15721,7 +15732,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 1518e767ac6ac9b96361711b6593ea465bc81ab1 Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 8 May 2026 23:20:44 +0900 Subject: [PATCH 2/5] 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 9d6fb62a6b..e4a7df022c 100644 --- a/test/test.cc +++ b/test/test.cc @@ -18157,3 +18157,544 @@ TEST(RequestSmugglingTest, ContentLengthAndTransferEncodingRejected) { response.substr(0, response.find("\r\n"))); } } + +// ============================================================================= +// 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 f2702037029b42ac46923dcd1f346fc14ae5307b Mon Sep 17 00:00:00 2001 From: Ingo Unterweger Date: Mon, 11 May 2026 11:23:58 +0200 Subject: [PATCH 3/5] Add scaffolding for set_no_proxy and set_proxy_from_env --- httplib.h | 58 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/httplib.h b/httplib.h index e2f8683829..7b1082a830 100644 --- a/httplib.h +++ b/httplib.h @@ -2230,6 +2230,8 @@ 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); + bool set_proxy_from_env(); void set_logger(Logger logger); void set_error_logger(ErrorLogger error_logger); @@ -2343,6 +2345,7 @@ class ClientImpl { std::string proxy_basic_auth_username_; 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_; @@ -2365,7 +2368,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); + 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( @@ -2604,6 +2607,8 @@ 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); + bool set_proxy_from_env(); void set_logger(Logger logger); void set_error_logger(ErrorLogger error_logger); @@ -12311,6 +12316,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_; @@ -13091,7 +13097,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_); @@ -13125,7 +13131,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); @@ -13135,7 +13141,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) { +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_); @@ -13153,9 +13159,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(). - // Setup proxy configuration (CRITICAL ORDER - proxy must be set - // before proxy auth) - if (is_proxy_enabled_for_host(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_; + + if (is_proxy_enabled_for_host(next_host)) { // First set proxy host and port client.set_proxy(proxy_host_, proxy_port_); @@ -13250,14 +13259,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( @@ -13265,8 +13266,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)); } @@ -14689,6 +14700,15 @@ 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) { + (void)patterns; +} + +inline bool ClientImpl::set_proxy_from_env() { + bool applied = false; + return applied; +} + #ifdef CPPHTTPLIB_SSL_ENABLED inline void ClientImpl::set_digest_auth(const std::string &username, const std::string &password) { @@ -15390,6 +15410,10 @@ 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 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 aad8e9a56cabb47f3f53ca83d87ca39b4947ecd7 Mon Sep 17 00:00:00 2001 From: Ingo Unterweger Date: Wed, 13 May 2026 08:11:49 +0200 Subject: [PATCH 4/5] Reuse apply_proxy_url from upstream PR and add implementation for set_proxy_from_env --- httplib.h | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/httplib.h b/httplib.h index 7b1082a830..c6febbd444 100644 --- a/httplib.h +++ b/httplib.h @@ -2258,6 +2258,7 @@ 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 @@ -14704,8 +14705,129 @@ inline void ClientImpl::set_no_proxy(const std::vector &patterns) { (void)patterns; } +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; + + const char *url_env = nullptr; + if (is_ssl()) { + 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') { + std::vector entries; + detail::split(no_proxy_env, no_proxy_env + strlen(no_proxy_env), ',', + [&](const char *b, const char *e) { + entries.push_back(std::string(b, e)); + }); + + if (!entries.empty()) { + no_proxy_entries_ = std::move(entries); + applied = true; + } + } return applied; } From 8682e41da98d9039a485c40595a4ea8e924edc0e Mon Sep 17 00:00:00 2001 From: Ingo Unterweger Date: Wed, 13 May 2026 09:06:30 +0200 Subject: [PATCH 5/5] Implement no_proxy matching with CIDR and subdomain support Provides an alternative implementation for is_proxy_enabled_for_host() that handles wildcard (*), exact host, subdomain, and IPv4/IPv6 CIDR matching. In contrast to PR #2448, the no_proxy entries and the host URL are not normalized beforehand, parsing and checking only happens lazily as needed. --- httplib.h | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 135 insertions(+), 6 deletions(-) diff --git a/httplib.h b/httplib.h index c6febbd444..8838a0dc56 100644 --- a/httplib.h +++ b/httplib.h @@ -10506,6 +10506,135 @@ make_host_and_port_string_always_port(const std::string &host, int port) { return prepare_host_string(host) + ":" + std::to_string(port); } +inline std::pair extract_host(const std::string& host, bool check_ipv6) { + // strip schema + size_t n = host.find("://"); + n = (n == std::string::npos) ? 0 : (n + (sizeof("://") - 1)); + std::string v = host.substr(n); + + // when needed, check if the url contains an ipv6 address + if(check_ipv6 && v[0] == '[') { + size_t endBracket = v.find(']'); + if(endBracket != std::string::npos) { + v = v.substr(1, endBracket - 1); + return {std::string(v), true}; + } + } + + // find end of host string + n = v.find(':'); + if(n == std::string::npos) { + n = v.find('/'); + if(n == std::string::npos) { + n = v.size(); + } + } + + // strip trailing dots + n -= v[n - 1] == '.' ? 1 : 0; + return {std::string(v.substr(0, n)), false}; +} + +using IPv6 = std::array; +static_assert(sizeof(IPv6) == sizeof(in6_addr), "IPv6 type size mismatch"); + +inline bool convert_to_ip(const std::string& host, IPv6& ip, bool& is_ipv6) { + auto r = extract_host(host, true); + is_ipv6 = r.second; + ip = IPv6{}; + if(inet_pton(is_ipv6 ? AF_INET6 : AF_INET, r.first.c_str(), &ip) == 0) { + return false; // Not an IP + } + return true; +} + +inline bool convert_to_cidr(const std::string& entry, IPv6& ip, long& subnet_bits, bool is_ipv6) { + std::string stripped; + size_t n = entry.find("/"); + if(n == std::string::npos) { + stripped = entry; + } else if((n + 1) >= entry.size()) { + return false; // slash found but at end is an error + } else { + stripped = entry.substr(0, n); + } + ip = IPv6{}; + if(inet_pton(is_ipv6 ? AF_INET6 : AF_INET, stripped.c_str(), &ip) == 0) { + return false; // Not an IP + } + + long def = is_ipv6 ? 128 : 32; + subnet_bits = def; // default is to check all bits + if(n == std::string::npos) { + return true; + } + auto r = from_chars(entry.data() + n + 1, entry.data() + entry.size(), subnet_bits); + if (r.ec != std::errc{} || r.ptr != entry.data() + entry.size()) { + return false; // corrupt number means no CIDR + } + // check for out ouf bounds subnet index + return subnet_bits >= 0 && subnet_bits <= def; +} + +inline IPv6 create_subnet_mask(long subnet_bits) { + IPv6 u{}; + size_t i = 0; + size_t shift_counter = 0; + while(subnet_bits > 0) { + u[i] >>= 1; + u[i] |= 0x80; + subnet_bits--; + shift_counter++; + if(shift_counter == 8) { + i++; + shift_counter = 0; + } + } + return u; +} + +inline void apply_mask(IPv6& ip, const IPv6& mask) { + for(size_t i = 0; i < ip.size(); i++) { + ip[i] = ip[i] & mask[i]; + } +} + +inline bool host_matches_no_proxy_entry(const std::string& host, const std::string& entry) { + if(entry.size() == 0) { return false; } + if(entry == "*") { return true; } + + IPv6 ip{}; + bool is_ipv6 = false; + if(convert_to_ip(host, ip, is_ipv6)) { + // host is an IP, try to convert the entry to an IP and/or CIDR as well + IPv6 cidr{}; + long subnet_bits = 0; + if(!convert_to_cidr(entry, cidr, subnet_bits, is_ipv6)) { + return false; // no match when filter is not valid CIDR + } + IPv6 mask = create_subnet_mask(subnet_bits); + apply_mask(ip, mask); + apply_mask(cidr, mask); + return ip == cidr; + } + + // host is not an IP, try to match on subdomain + auto r = extract_host(host, false); + std::string bare_host = case_ignore::to_lower(r.first); + std::string stripped_entry = case_ignore::to_lower(entry[0] == '.' ? entry.substr(1) : entry); + size_t n = bare_host.find(stripped_entry); + // no match at all + if(n == std::string::npos) { + return false; + } + // entry is not matching up to the end, so no subdomain + if((n + stripped_entry.size()) != bare_host.size()) { + return false; + } + // is full match or matches on subdomain + return n == 0 || bare_host[n - 1] == '.'; +} + template inline bool check_and_write_headers(Stream &strm, Headers &headers, T header_writer, Error &error) { @@ -12335,11 +12464,11 @@ 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; } + for(const auto& entry : no_proxy_entries_) { + if(detail::host_matches_no_proxy_entry(host, entry)){ return false; } + } + return true; } inline socket_t ClientImpl::create_client_socket(Error &error) const { @@ -14702,7 +14831,7 @@ inline void ClientImpl::set_proxy_bearer_token_auth(const std::string &token) { } inline void ClientImpl::set_no_proxy(const std::vector &patterns) { - (void)patterns; + no_proxy_entries_ = patterns; } inline bool ClientImpl::apply_proxy_url(const std::string &url) {