From 2b66aa65e133e95bca07a2264e97a603273a6c4f Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 16 Oct 2026 12:02:26 -0400 Subject: [PATCH] Keep a client's limits and TLS checks across redirects A cross-host redirect is followed by a new client, which did not get the payload limit, the max timeout, the connection timeout's microseconds or the hostname address map. Over HTTPS it also lost the session verifier, the server certificate verifier and a store given to set_ca_cert_store(), and fell back to the default CAs. Copy them over. The store belongs to the original TLS context, so pass on the CA certificates it holds as PEM. After the redirect, the original client also answered a 401 or 407 from the new host with its own digest credentials and sent that to its own host, with the new host's path. Return the redirected response as it is, since the client that followed it has already answered what it could. --- httplib.h | 76 +++++++++++---- test/test.cc | 263 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 245 insertions(+), 94 deletions(-) diff --git a/httplib.h b/httplib.h index fdfbe0f8..7c8ce867 100644 --- a/httplib.h +++ b/httplib.h @@ -3455,11 +3455,12 @@ private: std::function session_verifier_; + // Like ca_cert_store_set_, keeps what ctx_ cannot report back: the verifier + // set_server_certificate_verifier() installed there. + tls::VerifyCallback server_certificate_verifier_; + #ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE bool enable_windows_cert_verification_ = true; - // Like ca_cert_store_set_, tracks what ctx_ cannot report back: whether - // set_server_certificate_verifier() installed a verifier. - bool server_certificate_verifier_set_ = false; #endif friend class ClientImpl; @@ -11026,6 +11027,26 @@ inline bool load_client_ca_config(tls::ctx_t ctx, return ret; } +inline std::string ca_certs_to_pem(tls::ctx_t ctx) { + std::vector certs; + tls::get_ca_certs(ctx, certs); + + std::string pem; + for (auto cert : certs) { + std::vector der; + if (tls::get_cert_der(cert, der)) { + auto b64 = base64_encode(std::string(der.begin(), der.end())); + pem += "-----BEGIN CERTIFICATE-----\n"; + for (size_t i = 0; i < b64.size(); i += 64) { + pem += b64.substr(i, 64) + "\n"; + } + pem += "-----END CERTIFICATE-----\n"; + } + tls::free_cert(cert); + } + return pem; +} + // The parts of session setup that only SSLClient needs, plus the handful // WebSocketClient also exposes; everything else takes the defaults, which is // what keeps the two clients on one implementation. @@ -15818,7 +15839,10 @@ inline bool ClientImpl::handle_request(Stream &strm, Request &req, if (300 < res.status && res.status < 400 && follow_location_) { req = std::move(req_save); - ret = redirect(req, res, error); + // The client that followed the redirect has answered any challenge it + // could. Retrying below would sign another host's challenge and send it + // here. + return redirect(req, res, error); } #ifdef CPPHTTPLIB_SSL_ENABLED @@ -15951,12 +15975,27 @@ inline bool ClientImpl::create_redirect_client( redirect_client.system_ca_mode_ = system_ca_mode_; // Transfer CA certificate to redirect client - if (!ca_cert_pem_.empty()) { - redirect_client.load_ca_cert_store(ca_cert_pem_.c_str(), - ca_cert_pem_.size()); - } - if (!ca_cert_file_path_.empty()) { - redirect_client.set_ca_cert_path(ca_cert_file_path_, ca_cert_dir_path_); + redirect_client.load_ca_cert_store(ca_cert_pem_.c_str(), + ca_cert_pem_.size()); + redirect_client.set_ca_cert_path(ca_cert_file_path_, ca_cert_dir_path_); + + if (is_ssl()) { + auto &self = static_cast(*this); + // A store given to set_ca_cert_store() lives only in this client's + // context, so pass on the CA certificates the context holds. + if (self.ca_cert_store_set_) { + auto pem = detail::ca_certs_to_pem(self.ctx_); + redirect_client.load_ca_cert_store(pem.data(), pem.size()); + } + redirect_client.set_session_verifier(self.session_verifier_); + if (self.server_certificate_verifier_) { + redirect_client.set_server_certificate_verifier( + self.server_certificate_verifier_); + } +#ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE + redirect_client.enable_windows_cert_verification_ = + self.enable_windows_cert_verification_; +#endif } // Client certificates are set through constructor for SSLClient @@ -15989,9 +16028,14 @@ inline bool ClientImpl::create_redirect_client( template inline void ClientImpl::setup_redirect_client(ClientType &client) { // Copy basic settings first - client.set_connection_timeout(connection_timeout_sec_); + client.set_connection_timeout(connection_timeout_sec_, + connection_timeout_usec_); client.set_read_timeout(read_timeout_sec_, read_timeout_usec_); client.set_write_timeout(write_timeout_sec_, write_timeout_usec_); + client.set_max_timeout(max_timeout_msec_); + if (has_payload_max_length_) { + client.set_payload_max_length(payload_max_length_); + } client.set_keep_alive(keep_alive_); client.set_follow_location( true); // Enable redirects to handle multi-step redirects @@ -16029,6 +16073,7 @@ inline void ClientImpl::setup_redirect_client(ClientType &client) { } // Copy network and socket settings + client.addr_map_ = addr_map_; client.set_address_family(address_family_); client.set_tcp_nodelay(tcp_nodelay_); client.set_ipv6_v6only(ipv6_v6only_); @@ -18764,10 +18809,8 @@ inline void SSLClient::set_ca_cert_store(tls::ca_store_t ca_cert_store) { inline void SSLClient::set_server_certificate_verifier(tls::VerifyCallback verifier) { if (!ctx_) { return; } -#ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE - server_certificate_verifier_set_ = static_cast(verifier); -#endif - tls::set_verify_callback(ctx_, verifier); + server_certificate_verifier_ = verifier; + tls::set_verify_callback(ctx_, std::move(verifier)); } inline void SSLClient::set_session_verifier( @@ -18830,7 +18873,8 @@ inline bool SSLClient::initialize_ssl(Socket &socket, Error &error) { ca_cert_dir_path_.empty() && ca_cert_pem_.empty() && !ca_cert_store_set_; // Only a verifier set through set_server_certificate_verifier() is seen // here, not one installed with tls::set_verify_callback() directly. - options.server_certificate_verifier_set = server_certificate_verifier_set_; + options.server_certificate_verifier_set = + static_cast(server_certificate_verifier_); #endif tls::session_t session = nullptr; diff --git a/test/test.cc b/test/test.cc index 2cc821ae..eda4835e 100644 --- a/test/test.cc +++ b/test/test.cc @@ -3801,51 +3801,59 @@ TEST(RedirectToDifferentPort, Redirect) { EXPECT_EQ("Hello World!", res->body); } -static void -TestDoNotForwardCredentialsOnRedirect(std::function set_auth) { - Server svr1; - std::string captured_authorization; - svr1.Get("/target", [&](const Request &req, Response &res) { - captured_authorization = req.get_header_value("Authorization"); - res.set_content("OK", "text/plain"); +// GET /redir on one server redirects to /target on another port, so the +// client follows it with a new client. +static Result +GetThroughRedirectToDifferentPort(Server::Handler target, + const std::function &setup, + const Headers &headers = Headers()) { + Server target_svr; + target_svr.Get("/target", target); + + int target_port = 0; + auto target_thread = std::thread([&]() { + target_port = target_svr.bind_to_any_port(HOST); + target_svr.listen_after_bind(); }); - int svr1_port = 0; - auto thread1 = std::thread([&]() { - svr1_port = svr1.bind_to_any_port(HOST); - svr1.listen_after_bind(); - }); - - Server svr2; - svr2.Get("/redir", [&](const Request & /*req*/, Response &res) { + Server origin_svr; + origin_svr.Get("/redir", [&](const Request &, Response &res) { res.set_redirect( - "http://localhost:" + std::to_string(svr1_port) + "/target", 302); + "http://localhost:" + std::to_string(target_port) + "/target", 302); }); - int svr2_port = 0; - auto thread2 = std::thread([&]() { - svr2_port = svr2.bind_to_any_port(HOST); - svr2.listen_after_bind(); + int origin_port = 0; + auto origin_thread = std::thread([&]() { + origin_port = origin_svr.bind_to_any_port(HOST); + origin_svr.listen_after_bind(); }); auto se = detail::scope_exit([&] { - svr2.stop(); - thread2.join(); - svr1.stop(); - thread1.join(); - ASSERT_FALSE(svr2.is_running()); - ASSERT_FALSE(svr1.is_running()); + origin_svr.stop(); + origin_thread.join(); + target_svr.stop(); + target_thread.join(); }); - svr1.wait_until_ready(); - svr2.wait_until_ready(); + target_svr.wait_until_ready(); + origin_svr.wait_until_ready(); - Client cli("localhost", svr2_port); + Client cli("localhost", origin_port); cli.set_follow_location(true); - set_auth(cli); + setup(cli); + return cli.Get("/redir", headers); +} - auto res = cli.Get("/redir"); - ASSERT_TRUE(res) << "Error: " << to_string(res.error()); - EXPECT_EQ(StatusCode::OK_200, res->status); +static void +TestDoNotForwardCredentialsOnRedirect(std::function set_auth) { + std::string captured_authorization; + auto result = GetThroughRedirectToDifferentPort( + [&](const Request &req, Response &res) { + captured_authorization = req.get_header_value("Authorization"); + res.set_content("OK", "text/plain"); + }, + set_auth); + ASSERT_TRUE(result) << "Error: " << to_string(result.error()); + EXPECT_EQ(StatusCode::OK_200, result->status); // RFC 9110: credentials MUST NOT be forwarded to a different host EXPECT_TRUE(captured_authorization.empty()); } @@ -3861,52 +3869,15 @@ TEST(RedirectToDifferentPort, DoNotForwardCredentialsBearerToken) { } TEST(RedirectToDifferentPort, DoNotForwardCookie) { - Server svr1; std::string captured_cookie; - bool target_hit = false; - svr1.Get("/target", [&](const Request &req, Response &res) { - captured_cookie = req.get_header_value("Cookie"); - target_hit = true; - res.set_content("OK", "text/plain"); - }); - - int svr1_port = 0; - auto thread1 = std::thread([&]() { - svr1_port = svr1.bind_to_any_port(HOST); - svr1.listen_after_bind(); - }); - - Server svr2; - svr2.Get("/redir", [&](const Request & /*req*/, Response &res) { - res.set_redirect( - "http://localhost:" + std::to_string(svr1_port) + "/target", 302); - }); - - int svr2_port = 0; - auto thread2 = std::thread([&]() { - svr2_port = svr2.bind_to_any_port(HOST); - svr2.listen_after_bind(); - }); - auto se = detail::scope_exit([&] { - svr2.stop(); - thread2.join(); - svr1.stop(); - thread1.join(); - ASSERT_FALSE(svr2.is_running()); - ASSERT_FALSE(svr1.is_running()); - }); - - svr1.wait_until_ready(); - svr2.wait_until_ready(); - - Client cli("localhost", svr2_port); - cli.set_follow_location(true); - - Headers headers = {{"Cookie", "session_id=SECRET; auth=PRIVATE"}}; - auto res = cli.Get("/redir", headers); - ASSERT_TRUE(res) << "Error: " << to_string(res.error()); - EXPECT_EQ(StatusCode::OK_200, res->status); - EXPECT_TRUE(target_hit); + auto result = GetThroughRedirectToDifferentPort( + [&](const Request &req, Response &res) { + captured_cookie = req.get_header_value("Cookie"); + res.set_content("OK", "text/plain"); + }, + [](Client &) {}, {{"Cookie", "session_id=SECRET; auth=PRIVATE"}}); + ASSERT_TRUE(result) << "Error: " << to_string(result.error()); + EXPECT_EQ(StatusCode::OK_200, result->status); // Cookie MUST NOT be forwarded to a different host (GHSA-22mf-w2v3-r2jv) EXPECT_TRUE(captured_cookie.empty()); } @@ -3944,6 +3915,64 @@ TEST(RedirectToSamePort, ForwardCookie) { EXPECT_EQ("session_id=SECRET", captured_cookie); } +TEST(RedirectToDifferentPort, ForwardDefaultHeadersButNotCredentials) { + std::string authorization; + std::string trace_id; + auto result = GetThroughRedirectToDifferentPort( + [&](const Request &req, Response &res) { + authorization = req.get_header_value("Authorization"); + trace_id = req.get_header_value("X-Trace-Id"); + res.set_content("OK", "text/plain"); + }, + [](Client &cli) { + cli.set_default_headers( + {{"Authorization", "Bearer SECRET"}, {"X-Trace-Id", "42"}}); + }); + ASSERT_TRUE(result) << "Error: " << to_string(result.error()); + EXPECT_EQ(StatusCode::OK_200, result->status); + // Like a header given with the request, a default one goes along unless it + // carries credentials (RFC 9110 15.4). + EXPECT_TRUE(authorization.empty()); + EXPECT_EQ("42", trace_id); +} + +TEST(RedirectToDifferentPort, KeepPayloadMaxLength) { + auto result = GetThroughRedirectToDifferentPort( + [](const Request &, Response &res) { + res.set_content(std::string(2048, 'x'), "text/plain"); + }, + [](Client &cli) { cli.set_payload_max_length(1024); }); + EXPECT_FALSE(result); +} + +TEST(RedirectToDifferentPort, KeepMaxTimeout) { + auto result = GetThroughRedirectToDifferentPort( + [](const Request &req, Response &res) { + // Answer after a second, or once the client gives up. + for (auto i = 0; i < 100 && !req.is_connection_closed(); i++) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + res.set_content("late", "text/plain"); + }, + [](Client &cli) { cli.set_max_timeout(std::chrono::milliseconds(200)); }); + ASSERT_FALSE(result); + EXPECT_EQ(Error::Read, result.error()); +} + +#ifdef CPPHTTPLIB_SSL_ENABLED +TEST(RedirectToDifferentPort, DoNotAnswerChallengeFromRedirectTarget) { + auto result = GetThroughRedirectToDifferentPort( + [](const Request &, Response &res) { + res.status = StatusCode::Unauthorized_401; + res.set_header("WWW-Authenticate", + "Digest realm=\"r\", nonce=\"n\", qop=\"auth\""); + }, + [](Client &cli) { cli.set_digest_auth("user", "pass"); }); + ASSERT_TRUE(result) << "Error: " << to_string(result.error()); + EXPECT_EQ(StatusCode::Unauthorized_401, result->status); +} +#endif + TEST(RedirectToDifferentPort, OverflowPortNumber) { Server svr; svr.Get("/redir", [&](const Request & /*req*/, Response &res) { @@ -16349,6 +16378,84 @@ TEST(SSLClientRedirectTest, CertFile) { ASSERT_TRUE(res) << "Error: " << to_string(res.error()); ASSERT_EQ(StatusCode::OK_200, res->status); } + +// Redirects from a server with SERVER_CERT2_FILE to one with target_cert on +// another port, so the client follows it with a new client. +static Result GetThroughTlsRedirectToDifferentPort( + const std::function &setup, + const char *target_cert = SERVER_CERT2_FILE) { + auto ssl_port = PORT + 1; + + SSLServer ssl_svr1(SERVER_CERT2_FILE, SERVER_PRIVATE_KEY_FILE); + ssl_svr1.Get("/index", [&](const Request &, Response &res) { + res.set_redirect("https://127.0.0.1:" + std::to_string(ssl_port) + + "/index"); + }); + + SSLServer ssl_svr2(target_cert, SERVER_PRIVATE_KEY_FILE); + ssl_svr2.Get("/index", [&](const Request &, Response &res) { + res.set_content("test", "text/plain"); + }); + + thread t = thread([&]() { ssl_svr1.listen("127.0.0.1", PORT); }); + thread t2 = thread([&]() { ssl_svr2.listen("127.0.0.1", ssl_port); }); + auto se = detail::scope_exit([&] { + ssl_svr2.stop(); + ssl_svr1.stop(); + t2.join(); + t.join(); + }); + + ssl_svr1.wait_until_ready(); + ssl_svr2.wait_until_ready(); + + SSLClient cli("127.0.0.1", PORT); + cli.set_follow_location(true); + cli.set_connection_timeout(30); + setup(cli); + return cli.Get("/index"); +} + +TEST(SSLClientRedirectTest, KeepSessionVerifier) { + auto calls = 0; + auto res = GetThroughTlsRedirectToDifferentPort([&](SSLClient &cli) { + cli.enable_server_certificate_verification(false); + // Accept the first server only. + cli.set_session_verifier([&](tls::session_t) { + return ++calls == 1 ? SSLVerifierResponse::NoDecisionMade + : SSLVerifierResponse::CertificateRejected; + }); + }); + EXPECT_FALSE(res); + EXPECT_EQ(2, calls); +} + +TEST(SSLClientRedirectTest, KeepServerCertificateVerifier) { + std::string pinned; + auto res = GetThroughTlsRedirectToDifferentPort( + [&](SSLClient &cli) { + cli.enable_server_certificate_verification(false); + // Pin the first certificate seen. + cli.set_server_certificate_verifier([&](const tls::VerifyContext &ctx) { + if (pinned.empty()) { pinned = ctx.serial(); } + return ctx.serial() == pinned; + }); + }, + // A certificate other than the first server's. + SERVER_CERT_FILE); + EXPECT_FALSE(res); + EXPECT_FALSE(pinned.empty()); +} + +TEST(SSLClientRedirectTest, KeepCaCertStore) { + auto res = GetThroughTlsRedirectToDifferentPort([](SSLClient &cli) { + std::string cert; + read_file(SERVER_CERT2_FILE, cert); + cli.set_ca_cert_store(tls::create_ca_store(cert.c_str(), cert.size())); + }); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::OK_200, res->status); +} #endif #ifdef CPPHTTPLIB_SSL_ENABLED