From f17d3149797e996a92de81578a7238e315e86a0f Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 18 Oct 2026 20:32:27 -0400 Subject: [PATCH] Send the client's credentials and proxy target on a stream request open_stream(), and with it stream::Get() and SSEClient, wrote its own request head and left out what a buffered request gets: the Authorization from set_basic_auth() or set_bearer_token_auth(), and through a plain HTTP proxy the absolute-form target (RFC 9112 3.2.2) and Proxy-Authorization. A proxy refused such a request, and an origin that needs credentials answered 401. Move the two decisions out of write_request() and handle_request() into add_credential_headers() and proxy_target_prefix(), and use them in open_stream() too, after connecting as a buffered request does. A Digest challenge or a redirect is still handed to the caller. --- README-sse.md | 2 ++ README-stream.md | 4 +++ httplib.h | 61 ++++++++++++++++++++++++--------------- test/test.cc | 71 +++++++++++++++++++++++++++++++++++++++++++--- test/test_proxy.cc | 13 +++++++++ 5 files changed, 124 insertions(+), 27 deletions(-) diff --git a/README-sse.md b/README-sse.md index b477285f..8f799755 100644 --- a/README-sse.md +++ b/README-sse.md @@ -161,6 +161,8 @@ httplib::sse::SSEClient sse(cli, "/events", headers); sse.start(); ``` +The `Client`'s `set_basic_auth()` and `set_bearer_token_auth()` apply too; an `Authorization` header passed like this takes precedence. + ### Refreshing Auth Token on Reconnect ```cpp diff --git a/README-stream.md b/README-stream.md index 46691dd2..3da722ed 100644 --- a/README-stream.md +++ b/README-stream.md @@ -353,6 +353,10 @@ auto res = cli.Get("/api/data"); // Connection can be reused `open_stream()` and the `stream::` helpers apply the client's `set_payload_max_length()` (100MB by default, `CPPHTTPLIB_PAYLOAD_MAX_LENGTH`) to the body, both as received and after decompression. Past the limit, `read()` and `next()` stop, and the read error is `Error::ExceedMaxPayloadSize`. To download something larger, raise the limit, or call `cli.set_payload_max_length(0)` for no limit. +### Authentication and Proxies + +A stream request carries the same credentials as `client.Get()`: `set_basic_auth()` and `set_bearer_token_auth()` for the origin, and the proxy's own through `set_proxy()`, over HTTP or through a CONNECT tunnel. It does not answer a Digest challenge or follow a redirect: the `401` or `3xx` response is handed to you. + ## Related - [Issue #2269](https://github.com/yhirose/cpp-httplib/issues/2269) - Original feature request diff --git a/httplib.h b/httplib.h index 4d173257..e69aa635 100644 --- a/httplib.h +++ b/httplib.h @@ -3130,6 +3130,8 @@ private: void transfer_socket_ownership_to_handle(StreamHandle &handle); StreamHandle open_stream_(Request &req); + void add_credential_headers(Request &req); + std::string proxy_target_prefix() const; #ifdef CPPHTTPLIB_SSL_ENABLED public: @@ -15889,12 +15891,16 @@ inline ClientImpl::StreamHandle ClientImpl::open_stream_(Request &req) { auto &strm = *handle.stream_; + // After connecting, as for a buffered request + add_credential_headers(req); + // Build the request line and headers in memory first, like write_request() // does, so that a rejected header leaves nothing on the wire. { detail::BufferStream bstrm; - if (detail::write_request_line(bstrm, req.method, req.path) < 0) { + if (detail::write_request_line(bstrm, req.method, + proxy_target_prefix() + req.path) < 0) { handle.error = Error::Write; handle.response.reset(); return handle; @@ -16165,6 +16171,12 @@ ClientImpl::transfer_socket_ownership_to_handle(StreamHandle &handle) { socket_.sock = INVALID_SOCKET; } +// A plain HTTP proxy reads the target in absolute-form (RFC 9112 3.2.2) +inline std::string ClientImpl::proxy_target_prefix() const { + if (is_ssl() || !is_proxy_enabled_for_host(host_)) { return std::string(); } + return "http://" + detail::make_host_and_port_string(host_, port_, false); +} + inline bool ClientImpl::handle_request(Stream &strm, Request &req, Response &res, bool close_connection, Error &error) { @@ -16177,11 +16189,10 @@ inline bool ClientImpl::handle_request(Stream &strm, Request &req, bool ret; - if (!is_ssl() && is_proxy_enabled_for_host(host_)) { + auto prefix = proxy_target_prefix(); + if (!prefix.empty()) { auto req2 = req; - req2.path = "http://" + - detail::make_host_and_port_string(host_, port_, false) + - req.path; + req2.path = prefix + req.path; ret = process_request(strm, req2, res, close_connection, error); req = std::move(req2); req.path = req_save.path; @@ -16495,6 +16506,27 @@ inline std::string ClientImpl::make_request_target(const Request &req) const { return target; } +inline void ClientImpl::add_credential_headers(Request &req) { + // A CONNECT request is read by the proxy; everything sent through the tunnel + // it opens is read by the origin. Each credential goes only to its own hop. + auto is_connect = req.method == "CONNECT"; + + if (!is_connect) { + detail::add_authorization_header(req, basic_auth_username_, + basic_auth_password_, + bearer_token_auth_token_, false); + } + + // Proxy-Authorization is only sent when the proxy reads this message — + // otherwise NO_PROXY-matched requests, and requests inside a TLS tunnel, + // would leak proxy credentials to the destination server. + if (is_proxy_enabled_for_host(host_) && (!is_ssl() || is_connect)) { + detail::add_authorization_header(req, proxy_basic_auth_username_, + proxy_basic_auth_password_, + proxy_bearer_token_auth_token_, true); + } +} + inline bool ClientImpl::write_request(Stream &strm, Request &req, bool close_connection, Error &error, bool skip_body, bool &rejected_locally) { @@ -16529,24 +16561,7 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req, } } - // A CONNECT request is read by the proxy; everything sent through the tunnel - // it opens is read by the origin. Each credential goes only to its own hop. - auto is_connect = req.method == "CONNECT"; - - if (!is_connect) { - detail::add_authorization_header(req, basic_auth_username_, - basic_auth_password_, - bearer_token_auth_token_, false); - } - - // Proxy-Authorization is only sent when the proxy reads this message — - // otherwise NO_PROXY-matched requests, and requests inside a TLS tunnel, - // would leak proxy credentials to the destination server. - if (is_proxy_enabled_for_host(host_) && (!is_ssl() || is_connect)) { - detail::add_authorization_header(req, proxy_basic_auth_username_, - proxy_basic_auth_password_, - proxy_bearer_token_auth_token_, true); - } + add_credential_headers(req); // Request line and headers { diff --git a/test/test.cc b/test/test.cc index 95ae70bc..2f25bf16 100644 --- a/test/test.cc +++ b/test/test.cc @@ -29808,6 +29808,53 @@ inline std::unique_ptr make_client(const std::string &host, using no_proxy_test::make_client; using no_proxy_test::ProxyAndTargetServers; +// A stream request carries the credentials a buffered request does +TEST(StreamRequestHeadTest, SendsTheClientsCredentials) { + no_proxy_test::ScopedServer origin; + origin.svr().Get("/x", [](const Request &req, Response &res) { + res.set_content(req.get_header_value("Authorization"), "text/plain"); + }); + origin.listen(); + + Client cli("127.0.0.1", origin.port()); + cli.set_basic_auth("user", "pass"); + auto handle = cli.open_stream("GET", "/x"); + ASSERT_TRUE(handle.is_valid()); + EXPECT_EQ("Basic dXNlcjpwYXNz", read_all(handle)); + + Client bearer("127.0.0.1", origin.port()); + bearer.set_bearer_token_auth("tok"); + auto handle2 = bearer.open_stream("GET", "/x"); + ASSERT_TRUE(handle2.is_valid()); + EXPECT_EQ("Bearer tok", read_all(handle2)); +} + +// A plain HTTP proxy reads the target in absolute-form (RFC 9112 3.2.2) and +// takes its own credentials, as for a buffered request +TEST(StreamRequestHeadTest, GoesThroughAPlainHttpProxy) { + std::string target, authz, proxy_authz; + no_proxy_test::ScopedServer proxy; + proxy.svr().Get(".*", [&](const Request &req, Response &res) { + target = req.target; + authz = req.get_header_value("Authorization"); + proxy_authz = req.get_header_value("Proxy-Authorization"); + res.set_content("ok", "text/plain"); + }); + proxy.listen(); + + Client cli("origin.example", 8080); + cli.set_proxy("127.0.0.1", proxy.port()); + cli.set_proxy_basic_auth("puser", "ppass"); + cli.set_basic_auth("user", "pass"); + auto handle = cli.open_stream("GET", "/x"); + ASSERT_TRUE(handle.is_valid()); + EXPECT_EQ("ok", read_all(handle)); + + EXPECT_EQ("http://origin.example:8080/x", target); + EXPECT_EQ("Basic dXNlcjpwYXNz", authz); + EXPECT_EQ("Basic cHVzZXI6cHBhc3M=", proxy_authz); +} + TEST(NoProxyTest, ExactHostnameBypasses) { ProxyAndTargetServers s; auto cli = make_client("example.com", s); @@ -30529,7 +30576,8 @@ private: void CredentialsStayWithTheirHop( const std::function &set_credentials, const std::string &scheme, - const std::vector &origin_headers = {"Authorization"}) { + const std::vector &origin_headers = {"Authorization"}, + bool stream = false) { std::atomic origin_hits{0}; std::atomic origin_saw_proxy_authz{false}; std::atomic origin_saw_headers{false}; @@ -30554,9 +30602,15 @@ void CredentialsStayWithTheirHop( cli.set_proxy("127.0.0.1", proxy.port()); set_credentials(cli); - auto res = cli.Get("/x"); - ASSERT_TRUE(res) << "Error: " << to_string(res.error()); - EXPECT_EQ(StatusCode::OK_200, res->status); + if (stream) { + auto handle = cli.open_stream("GET", "/x"); + ASSERT_TRUE(handle.is_valid()) << "Error: " << to_string(handle.error); + EXPECT_EQ(StatusCode::OK_200, handle.response->status); + } else { + auto res = cli.Get("/x"); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::OK_200, res->status); + } EXPECT_EQ(1, origin_hits.load()); EXPECT_EQ(1, proxy.connect_hits()); @@ -30584,6 +30638,15 @@ TEST(ProxyTunnelTest, BasicCredentialsStayWithTheirHop) { "Basic"); } +TEST(ProxyTunnelTest, StreamCredentialsStayWithTheirHop) { + proxy_tunnel_test::CredentialsStayWithTheirHop( + [](SSLClient &cli) { + cli.set_proxy_basic_auth("proxy-user", "proxy-pass"); + cli.set_basic_auth("origin-user", "origin-pass"); + }, + "Basic", {"Authorization"}, true); +} + TEST(ProxyTunnelTest, BearerCredentialsStayWithTheirHop) { proxy_tunnel_test::CredentialsStayWithTheirHop( [](SSLClient &cli) { diff --git a/test/test_proxy.cc b/test/test_proxy.cc index ffc9c849..8e2b39c9 100644 --- a/test/test_proxy.cc +++ b/test/test_proxy.cc @@ -361,6 +361,19 @@ TEST(KeepAliveTest, SSLWithDigest) { // ---------------------------------------------------------------------------- +// The proxy needs the absolute-form target and its credentials, the origin +// its own +TEST(ProxyTest, NoSSLOpenStream) { + Client cli("httpbin"); + cli.set_proxy("localhost", 3128); + cli.set_proxy_basic_auth("hello", "world"); + cli.set_basic_auth("hello", "world"); + + auto handle = cli.open_stream("GET", "/basic-auth/hello/world"); + ASSERT_TRUE(handle.response); + EXPECT_EQ(StatusCode::OK_200, handle.response->status); +} + #ifdef CPPHTTPLIB_SSL_ENABLED TEST(ProxyTest, SSLOpenStream) { SSLClient cli("httpbin");