From 9045327223687a53299d5fd80b1c4d5b6536b52e Mon Sep 17 00:00:00 2001 From: "hyuk.kim" Date: Mon, 26 Oct 2026 00:38:35 +0900 Subject: [PATCH 01/10] proxy: Apply hostname addr_map to the CONNECT target Through a proxy it is the proxy, not the client, that opens the connection to the origin. SSLClient and WebSocketClient built the CONNECT target from host_ alone, so an address set with set_hostname_addr_map() took effect on direct connections only and was ignored through a proxy. When host_ is a name the proxy cannot resolve (e.g. an SNI-only name), the tunnel fails; when it resolves elsewhere, the tunnel reaches a different origin than a direct connection would. Add detail::make_connect_request_target(), which builds the CONNECT target from apply_addr_map(), and use it for SSLClient's CONNECT, its Digest retry, and WebSocketClient's CONNECT. The tunnelled request keeps host_ for its Host header and SNI, matching a direct connection. --- httplib.h | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/httplib.h b/httplib.h index f529734cfd..892c5675f7 100644 --- a/httplib.h +++ b/httplib.h @@ -13013,6 +13013,20 @@ make_host_and_port_string_always_port(const std::string &host, int port) { return prepare_host_string(host) + ":" + std::to_string(port); } +// Request target of a CONNECT for `host`. Through a proxy it is the proxy, not +// this client, that opens the connection to the origin, so the addr_map +// override has to be carried in the request target. The tunnelled request +// keeps `host` for its Host header and SNI, as a direct connection does. +inline std::string +make_connect_request_target(const std::map &addr_map, + const std::string &host, int port) { + std::string connect_host; + std::string ip; + apply_addr_map(addr_map, host, connect_host, ip); + return make_host_and_port_string_always_port(ip.empty() ? connect_host : ip, + port); +} + // Value for the Host header a client sends when the caller supplied none. // Only the value: callers decide where in their header list it goes. inline std::string make_default_host_header_value(const std::string &host, @@ -19128,7 +19142,7 @@ inline bool SSLClient::connect_with_proxy( Request req2; req2.method = "CONNECT"; req2.path = - detail::make_host_and_port_string_always_port(host_, port_); + detail::make_connect_request_target(addr_map_, host_, port_); if (max_timeout_msec_ > 0) { req2.start_time_ = std::chrono::steady_clock::now(); } @@ -19167,8 +19181,8 @@ inline bool SSLClient::connect_with_proxy( start_time, [&](Stream &strm) { Request req3; req3.method = "CONNECT"; - req3.path = detail::make_host_and_port_string_always_port( - host_, port_); + req3.path = detail::make_connect_request_target(addr_map_, + host_, port_); req3.headers.insert(detail::make_digest_authentication_header( req3, make_request_target(req3), auth, 1, detail::random_string(10), proxy_digest_auth_username_, @@ -24016,7 +24030,7 @@ inline bool WebSocketClient::connect_with_proxy(Response &proxy_res, Error &error) { Request req; req.method = "CONNECT"; - req.path = detail::make_host_and_port_string_always_port(host_, port_); + req.path = detail::make_connect_request_target(addr_map_, host_, port_); req.headers.emplace("Host", req.path); detail::add_default_user_agent_header(req); detail::add_authorization_header(req, proxy_basic_auth_username_, From e05e9a9dddd2856143fbfc5e26459929cafbeda1 Mon Sep 17 00:00:00 2001 From: "hyuk.kim" Date: Mon, 26 Oct 2026 00:38:55 +0900 Subject: [PATCH 02/10] test: Cover addr_map on the CONNECT target - make_connect_request_target(): IPv4, IPv6 and hostname mappings, and the empty and absent mapping cases. - SSLClient: the CONNECT target is the mapped address, while SNI and Host inside the tunnel stay the hostname. - SSLClient: the CONNECT retried after a Digest 407 uses the mapped address too. - WebSocketClient: the CONNECT target and its Host are the mapped address. --- test/test.cc | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/test/test.cc b/test/test.cc index e53aa80923..67457c3342 100644 --- a/test/test.cc +++ b/test/test.cc @@ -21215,6 +21215,36 @@ TEST(MakeHostAndPortStringTest, VariousPatterns) { detail::make_host_and_port_string("example.com", 65536, false)); } +namespace httplib { +namespace detail { +std::string +make_connect_request_target(const std::map &addr_map, + const std::string &host, int port); +} // namespace detail +} // namespace httplib + +TEST(MakeConnectRequestTargetTest, FollowsHostnameAddrMap) { + std::map addr_map = { + {"ipv4.example", "192.0.2.10"}, + {"ipv6.example", "2001:db8::1"}, + {"name.example", "internal.example.lan"}, + {"empty.example", ""}}; + + EXPECT_EQ("192.0.2.10:443", + detail::make_connect_request_target(addr_map, "ipv4.example", 443)); + EXPECT_EQ("[2001:db8::1]:443", + detail::make_connect_request_target(addr_map, "ipv6.example", 443)); + EXPECT_EQ("internal.example.lan:8443", detail::make_connect_request_target( + addr_map, "name.example", 8443)); + + // An empty or absent mapping keeps the hostname as the target + EXPECT_EQ("empty.example:443", detail::make_connect_request_target( + addr_map, "empty.example", 443)); + EXPECT_EQ("other.example:443", detail::make_connect_request_target( + addr_map, "other.example", 443)); + EXPECT_EQ("[::1]:443", detail::make_connect_request_target({}, "::1", 443)); +} + #ifdef CPPHTTPLIB_SSL_ENABLED TEST(SSLClientHostHeaderTest, Issue2301_Online) { httplib::SSLClient cli("roblox.com", 443); @@ -31295,6 +31325,79 @@ TEST(ProxyTunnelTest, OriginReturning407InsideTunnelDoesNotLeakProxyDigest) { EXPECT_FALSE(origin_saw_proxy_authz.load()); EXPECT_EQ(1, proxy.connect_hits()); } + +TEST(ProxyTunnelTest, ConnectTargetFollowsHostnameAddrMap) { + // The proxy, not the client, connects to the origin, so the mapped address + // must be the CONNECT target. Inside the tunnel the hostname keeps supplying + // SNI and Host, as it does on a direct connection. + std::string origin_sni; + std::string origin_host; + + proxy_tunnel_test::ScopedSSLServer origin; + origin.svr().Get(".*", [&](const Request &req, Response &res) { + origin_sni = req.sni(); + origin_host = req.get_header_value("Host"); + res.set_content("ok", "text/plain"); + }); + origin.listen(); + + proxy_tunnel_test::ScopedConnectProxy proxy(origin.port()); + ASSERT_NE(0, proxy.port()); + + SSLClient cli("origin.example", origin.port()); + cli.enable_server_certificate_verification(false); + cli.set_hostname_addr_map({{"origin.example", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", proxy.port()); + + auto res = cli.Get("/x"); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::OK_200, res->status); + + auto port = std::to_string(origin.port()); + EXPECT_EQ(1, proxy.connect_hits()); + EXPECT_EQ(0u, proxy.connect_request().find("CONNECT 127.0.0.1:" + port + + " HTTP/1.1\r\n")); + EXPECT_EQ("origin.example", origin_sni); + EXPECT_EQ("origin.example:" + port, origin_host); +} + +TEST(ProxyTunnelTest, DigestRetryConnectTargetFollowsHostnameAddrMap) { + proxy_tunnel_test::ScopedSSLServer origin; + origin.svr().Get(".*", [&](const Request &, Response &res) { + res.set_content("ok", "text/plain"); + }); + origin.listen(); + + proxy_tunnel_test::ScopedConnectProxy proxy( + origin.port(), [](const std::string &head) -> std::string { + if (head.find( + "\r\nProxy-Authorization: Digest username=\"proxy-user\"") != + std::string::npos) { + return proxy_tunnel_test::kTunnelEstablished; + } + return "HTTP/1.1 407 Proxy Authentication Required\r\n" + "Proxy-Authenticate: Digest realm=\"proxy\", qop=\"auth\", " + "nonce=\"abc\", algorithm=MD5\r\n" + "Content-Length: 0\r\n\r\n"; + }); + ASSERT_NE(0, proxy.port()); + + SSLClient cli("origin.example", origin.port()); + cli.enable_server_certificate_verification(false); + cli.set_hostname_addr_map({{"origin.example", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", proxy.port()); + cli.set_proxy_digest_auth("proxy-user", "proxy-pass"); + + auto res = cli.Get("/x"); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::OK_200, res->status); + + // The retried CONNECT is the one the proxy saw last. + EXPECT_EQ(2, proxy.connect_hits()); + EXPECT_EQ(0u, proxy.connect_request().find( + "CONNECT 127.0.0.1:" + std::to_string(origin.port()) + + " HTTP/1.1\r\n")); +} #endif namespace proxy_tunnel_test { @@ -31486,6 +31589,30 @@ TEST(WebSocketProxyTest, ProxySettingAppliesOnReconnect) { EXPECT_EQ(2, proxy.connect_hits()); } +TEST(WebSocketProxyTest, ConnectTargetFollowsHostnameAddrMap) { + proxy_tunnel_test::ScopedWebSocketOrigin origin; + proxy_tunnel_test::ScopedConnectProxy proxy(origin.port()); + ASSERT_NE(0, proxy.port()); + + auto port = std::to_string(origin.port()); + ws::WebSocketClient client("ws://origin.example:" + port + "/ws"); + client.set_hostname_addr_map({{"origin.example", "127.0.0.1"}}); + client.set_proxy("127.0.0.1", proxy.port()); + auto res = client.connect(); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + + std::string msg; + ASSERT_TRUE(client.read(msg)); + client.close(); + + EXPECT_EQ(1, proxy.connect_hits()); + auto target = "127.0.0.1:" + port; + auto connect_req = proxy.connect_request(); + EXPECT_EQ(0u, connect_req.find("CONNECT " + target + " HTTP/1.1\r\n")); + EXPECT_NE(std::string::npos, + connect_req.find("\r\nHost: " + target + "\r\n")); +} + #ifdef CPPHTTPLIB_SSL_ENABLED TEST(WebSocketProxyTest, DigestRetriesConnectOnFreshSocket) { proxy_tunnel_test::ScopedWebSocketOrigin origin; From 86db9131dcf3a7b0721211340429b890bc3a0401 Mon Sep 17 00:00:00 2001 From: "hyuk.kim" Date: Mon, 26 Oct 2026 00:39:03 +0900 Subject: [PATCH 03/10] docs: Describe set_hostname_addr_map through a CONNECT proxy --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index a03fa3000d..0e8f214469 100644 --- a/README.md +++ b/README.md @@ -1498,6 +1498,12 @@ cli.set_hostname_addr_map({{"example.com", "internal.example.lan"}}); An empty value is ignored, leaving the original hostname as the connection target. +When the connection is tunnelled through a proxy with `CONNECT` (HTTPS and +WebSocket), the socket connects to the proxy instead, and the mapped address +becomes the `CONNECT` target, so the proxy reaches the same address a direct +connection would. Inside the tunnel the hostname still supplies the `Host` +header and the SNI. + The same method is available on `httplib::ws::WebSocketClient`. ### Automatic Path Encoding From ed282a7cf98deb58aa16fbca8c2cee9b413a5aa9 Mon Sep 17 00:00:00 2001 From: "hyuk.kim" Date: Mon, 26 Oct 2026 23:57:41 +0900 Subject: [PATCH 04/10] Add set_connect_to_map() and set_resolve_map() set_hostname_addr_map() works like curl's --resolve: it applies to direct connections only, and through a proxy the proxy resolves the original hostname. Applying it to the CONNECT target, as the previous commits did, changes what existing users send through a proxy, so keep it as it was. Add set_connect_to_map(), which works like curl's --connect-to: it retargets the direct connection and the CONNECT through a proxy, while the SNI, the tunnelled Host header and certificate verification keep the original hostname. As in curl, it applies before the addr map on a direct connection, so the addr map can still resolve the rewritten name. Add set_resolve_map() as an alias of set_hostname_addr_map() so the pair reads like the curl options. Both setters are on ClientImpl, Client and WebSocketClient, and the connect-to map is copied to redirect clients like the addr map. --- httplib.h | 77 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/httplib.h b/httplib.h index 892c5675f7..e4d891860a 100644 --- a/httplib.h +++ b/httplib.h @@ -2922,6 +2922,8 @@ class ClientImpl { socket_t socket() const; void set_hostname_addr_map(std::map addr_map); + void set_resolve_map(std::map addr_map); + void set_connect_to_map(std::map connect_to_map); void set_default_headers(Headers headers); @@ -3053,6 +3055,10 @@ class ClientImpl { // hostname; only the connection target changes, never the identity. std::map addr_map_; + // Like curl's --connect-to: retargets both a direct connection (before + // addr_map_ applies) and the CONNECT through a proxy. The identity stays. + std::map connect_to_map_; + // Default headers Headers default_headers_; @@ -3343,6 +3349,8 @@ class Client { socket_t socket() const; void set_hostname_addr_map(std::map addr_map); + void set_resolve_map(std::map addr_map); + void set_connect_to_map(std::map connect_to_map); void set_default_headers(Headers headers); @@ -4739,6 +4747,8 @@ class WebSocketClient { void set_interface(const std::string &intf); void set_hostname_addr_map(std::map addr_map); + void set_resolve_map(std::map addr_map); + void set_connect_to_map(std::map connect_to_map); void set_proxy(const std::string &host, int port); void set_proxy_basic_auth(const std::string &username, @@ -4810,6 +4820,10 @@ class WebSocketClient { // hostname; only the connection target changes, never the identity. std::map addr_map_; + // Like curl's --connect-to: retargets both a direct connection (before + // addr_map_ applies) and the CONNECT through a proxy. The identity stays. + std::map connect_to_map_; + std::string proxy_host_; int proxy_port_ = -1; std::string proxy_basic_auth_username_; @@ -10721,6 +10735,20 @@ inline void apply_addr_map(const std::map &addr_map, } } +// Direct connection target for `host`, as curl applies --connect-to before +// --resolve: the connect-to map rewrites the name first, then the addr map +// applies to the result. Without a connect-to entry this is apply_addr_map(). +inline void apply_connect_to_and_addr_map( + const std::map &connect_to_map, + const std::map &addr_map, const std::string &host, + std::string &connect_host, std::string &ip) { + apply_addr_map(connect_to_map, host, connect_host, ip); + if (!ip.empty()) { return; } + + auto target = connect_host; + apply_addr_map(addr_map, target, connect_host, ip); +} + } // namespace detail /* @@ -13014,7 +13042,7 @@ make_host_and_port_string_always_port(const std::string &host, int port) { } // Request target of a CONNECT for `host`. Through a proxy it is the proxy, not -// this client, that opens the connection to the origin, so the addr_map +// this client, that opens the connection to the origin, so a connect-to // override has to be carried in the request target. The tunnelled request // keeps `host` for its Host header and SNI, as a direct connection does. inline std::string @@ -15578,7 +15606,8 @@ inline socket_t ClientImpl::create_client_socket(Error &error) const { // Check is custom IP or hostname specified for host_ std::string connect_host; std::string ip; - detail::apply_addr_map(addr_map_, host_, connect_host, ip); + detail::apply_connect_to_and_addr_map(connect_to_map_, addr_map_, host_, + connect_host, ip); return detail::create_client_socket( connect_host, ip, port_, address_family_, tcp_nodelay_, ipv6_v6only_, @@ -16542,6 +16571,7 @@ inline void ClientImpl::setup_redirect_client(ClientType &client) { // Copy network and socket settings client.addr_map_ = addr_map_; + client.connect_to_map_ = connect_to_map_; client.set_address_family(address_family_); client.set_tcp_nodelay(tcp_nodelay_); client.set_ipv6_v6only(ipv6_v6only_); @@ -18058,6 +18088,16 @@ ClientImpl::set_hostname_addr_map(std::map addr_map) { addr_map_ = std::move(addr_map); } +inline void +ClientImpl::set_resolve_map(std::map addr_map) { + set_hostname_addr_map(std::move(addr_map)); +} + +inline void ClientImpl::set_connect_to_map( + std::map connect_to_map) { + connect_to_map_ = std::move(connect_to_map); +} + inline void ClientImpl::set_default_headers(Headers headers) { default_headers_ = std::move(headers); } @@ -18769,6 +18809,16 @@ Client::set_hostname_addr_map(std::map addr_map) { cli_->set_hostname_addr_map(std::move(addr_map)); } +inline void +Client::set_resolve_map(std::map addr_map) { + cli_->set_resolve_map(std::move(addr_map)); +} + +inline void +Client::set_connect_to_map(std::map connect_to_map) { + cli_->set_connect_to_map(std::move(connect_to_map)); +} + inline void Client::set_default_headers(Headers headers) { cli_->set_default_headers(std::move(headers)); } @@ -19141,8 +19191,8 @@ inline bool SSLClient::connect_with_proxy( start_time, [&](Stream &strm) { Request req2; req2.method = "CONNECT"; - req2.path = - detail::make_connect_request_target(addr_map_, host_, port_); + req2.path = detail::make_connect_request_target(connect_to_map_, + host_, port_); if (max_timeout_msec_ > 0) { req2.start_time_ = std::chrono::steady_clock::now(); } @@ -19181,8 +19231,8 @@ inline bool SSLClient::connect_with_proxy( start_time, [&](Stream &strm) { Request req3; req3.method = "CONNECT"; - req3.path = detail::make_connect_request_target(addr_map_, - host_, port_); + req3.path = detail::make_connect_request_target( + connect_to_map_, host_, port_); req3.headers.insert(detail::make_digest_authentication_header( req3, make_request_target(req3), auth, 1, detail::random_string(10), proxy_digest_auth_username_, @@ -23981,6 +24031,16 @@ inline void WebSocketClient::set_hostname_addr_map( addr_map_ = std::move(addr_map); } +inline void +WebSocketClient::set_resolve_map(std::map addr_map) { + set_hostname_addr_map(std::move(addr_map)); +} + +inline void WebSocketClient::set_connect_to_map( + std::map connect_to_map) { + connect_to_map_ = std::move(connect_to_map); +} + inline bool WebSocketClient::is_proxy_enabled() const { if (proxy_host_.empty() || proxy_port_ == -1) { return false; } if (no_proxy_entries_.empty()) { return true; } @@ -23996,7 +24056,8 @@ inline bool WebSocketClient::create_and_connect_socket(Error &error) { connect_host = proxy_host_; port = proxy_port_; } else { - detail::apply_addr_map(addr_map_, host_, connect_host, ip); + detail::apply_connect_to_and_addr_map(connect_to_map_, addr_map_, host_, + connect_host, ip); } sock_ = detail::create_client_socket( @@ -24030,7 +24091,7 @@ inline bool WebSocketClient::connect_with_proxy(Response &proxy_res, Error &error) { Request req; req.method = "CONNECT"; - req.path = detail::make_connect_request_target(addr_map_, host_, port_); + req.path = detail::make_connect_request_target(connect_to_map_, host_, port_); req.headers.emplace("Host", req.path); detail::add_default_user_agent_header(req); detail::add_authorization_header(req, proxy_basic_auth_username_, From c16049cfad9eb3ce4a91958fe85372d4c7dcc2d3 Mon Sep 17 00:00:00 2001 From: "hyuk.kim" Date: Mon, 26 Oct 2026 23:57:55 +0900 Subject: [PATCH 05/10] proxy: Match the SSLClient CONNECT Host to a connect-to target SSLClient builds the CONNECT Host header from host_, so with a connect-to entry the request read `CONNECT 127.0.0.1:443` with `Host: origin.example`. RFC 9112 3.2 expects the Host to match the request target, as curl and WebSocketClient already send it. When a connect-to entry retargets the CONNECT, set its Host to the same authority, for the first CONNECT and for the Digest retry. Without an entry the CONNECT request is unchanged. --- httplib.h | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/httplib.h b/httplib.h index e4d891860a..b6b45f6662 100644 --- a/httplib.h +++ b/httplib.h @@ -19184,6 +19184,12 @@ inline bool SSLClient::connect_with_proxy( std::chrono::time_point start_time, Response &res, bool &success, Error &error) { success = true; + // A connect-to entry retargets the CONNECT, and its Host must then name the + // same authority. Without one the request stays exactly as it was. + auto connect_target = + detail::make_connect_request_target(connect_to_map_, host_, port_); + auto retargeted = connect_target != + detail::make_host_and_port_string_always_port(host_, port_); Response proxy_res; if (!detail::process_client_socket( socket.sock, read_timeout_sec_, read_timeout_usec_, @@ -19191,8 +19197,8 @@ inline bool SSLClient::connect_with_proxy( start_time, [&](Stream &strm) { Request req2; req2.method = "CONNECT"; - req2.path = detail::make_connect_request_target(connect_to_map_, - host_, port_); + req2.path = connect_target; + if (retargeted) { req2.set_header("Host", connect_target); } if (max_timeout_msec_ > 0) { req2.start_time_ = std::chrono::steady_clock::now(); } @@ -19231,8 +19237,8 @@ inline bool SSLClient::connect_with_proxy( start_time, [&](Stream &strm) { Request req3; req3.method = "CONNECT"; - req3.path = detail::make_connect_request_target( - connect_to_map_, host_, port_); + req3.path = connect_target; + if (retargeted) { req3.set_header("Host", connect_target); } req3.headers.insert(detail::make_digest_authentication_header( req3, make_request_target(req3), auth, 1, detail::random_string(10), proxy_digest_auth_username_, From 1ea5c67b5a9034ca01c4fc1c34d6e046d74c4ece Mon Sep 17 00:00:00 2001 From: "hyuk.kim" Date: Mon, 26 Oct 2026 23:58:05 +0900 Subject: [PATCH 06/10] test: Cover set_connect_to_map() and pin set_hostname_addr_map() - The CONNECT-target tests now use set_connect_to_map(), and also check that the CONNECT Host matches the target, for SSLClient (including the Digest retry) and WebSocketClient. - set_hostname_addr_map() alone keeps the original hostname as the CONNECT target and Host, for SSLClient and WebSocketClient. - On a direct connection, set_connect_to_map() changes the connection target and keeps the Host header, and set_resolve_map() applies to the name it rewrites to. --- test/test.cc | 146 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 132 insertions(+), 14 deletions(-) diff --git a/test/test.cc b/test/test.cc index 67457c3342..fff3b17fd9 100644 --- a/test/test.cc +++ b/test/test.cc @@ -3821,6 +3821,71 @@ TEST(SpecifyServerIPAddressTest, EmptyAddrMapValueIsIgnored) { EXPECT_FALSE(res) << "empty mapping must not redirect to loopback"; } +TEST(ConnectToMapTest, RetargetsDirectConnection) { + // Like curl's --connect-to, the map changes where the client connects, and + // the identity stays host_. "target.invalid" (RFC 6761) is never resolved. + auto host = "target.invalid"; + + Server svr; + std::string received_host; + svr.Get("/hi", [&](const Request &req, Response &res) { + received_host = req.get_header_value("Host"); + res.set_content("Hello World!", "text/plain"); + }); + + auto port = svr.bind_to_any_port("127.0.0.1"); + auto thread = std::thread([&]() { svr.listen_after_bind(); }); + + auto se = detail::scope_exit([&] { + svr.stop(); + thread.join(); + ASSERT_FALSE(svr.is_running()); + }); + + svr.wait_until_ready(); + + Client cli(host, port); + cli.set_connect_to_map({{host, "127.0.0.1"}}); + + auto res = cli.Get("/hi"); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::OK_200, res->status); + EXPECT_EQ(std::string(host) + ":" + std::to_string(port), received_host); +} + +TEST(ConnectToMapTest, AppliesBeforeResolveMap) { + // As curl applies --connect-to before --resolve, the resolve map applies to + // the rewritten name. Neither ".invalid" name is ever resolved. + auto host = "target.invalid"; + + Server svr; + std::string received_host; + svr.Get("/hi", [&](const Request &req, Response &res) { + received_host = req.get_header_value("Host"); + res.set_content("Hello World!", "text/plain"); + }); + + auto port = svr.bind_to_any_port("127.0.0.1"); + auto thread = std::thread([&]() { svr.listen_after_bind(); }); + + auto se = detail::scope_exit([&] { + svr.stop(); + thread.join(); + ASSERT_FALSE(svr.is_running()); + }); + + svr.wait_until_ready(); + + Client cli(host, port); + cli.set_connect_to_map({{host, "mid.invalid"}}); + cli.set_resolve_map({{"mid.invalid", "127.0.0.1"}}); + + auto res = cli.Get("/hi"); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::OK_200, res->status); + EXPECT_EQ(std::string(host) + ":" + std::to_string(port), received_host); +} + TEST(AbsoluteRedirectTest, Redirect_Online) { auto host = "httpbingo.org"; auto path = std::string{"/absolute-redirect/3"}; @@ -31326,10 +31391,10 @@ TEST(ProxyTunnelTest, OriginReturning407InsideTunnelDoesNotLeakProxyDigest) { EXPECT_EQ(1, proxy.connect_hits()); } -TEST(ProxyTunnelTest, ConnectTargetFollowsHostnameAddrMap) { - // The proxy, not the client, connects to the origin, so the mapped address - // must be the CONNECT target. Inside the tunnel the hostname keeps supplying - // SNI and Host, as it does on a direct connection. +TEST(ProxyTunnelTest, ConnectTargetFollowsConnectToMap) { + // Like curl's --connect-to, the mapped address is the CONNECT target and its + // Host. Inside the tunnel the hostname keeps supplying SNI and Host, as it + // does on a direct connection. std::string origin_sni; std::string origin_host; @@ -31346,7 +31411,7 @@ TEST(ProxyTunnelTest, ConnectTargetFollowsHostnameAddrMap) { SSLClient cli("origin.example", origin.port()); cli.enable_server_certificate_verification(false); - cli.set_hostname_addr_map({{"origin.example", "127.0.0.1"}}); + cli.set_connect_to_map({{"origin.example", "127.0.0.1"}}); cli.set_proxy("127.0.0.1", proxy.port()); auto res = cli.Get("/x"); @@ -31354,14 +31419,17 @@ TEST(ProxyTunnelTest, ConnectTargetFollowsHostnameAddrMap) { EXPECT_EQ(StatusCode::OK_200, res->status); auto port = std::to_string(origin.port()); + auto target = "127.0.0.1:" + port; + auto connect_req = proxy.connect_request(); EXPECT_EQ(1, proxy.connect_hits()); - EXPECT_EQ(0u, proxy.connect_request().find("CONNECT 127.0.0.1:" + port + - " HTTP/1.1\r\n")); + EXPECT_EQ(0u, connect_req.find("CONNECT " + target + " HTTP/1.1\r\n")); + EXPECT_NE(std::string::npos, + connect_req.find("\r\nHost: " + target + "\r\n")); EXPECT_EQ("origin.example", origin_sni); EXPECT_EQ("origin.example:" + port, origin_host); } -TEST(ProxyTunnelTest, DigestRetryConnectTargetFollowsHostnameAddrMap) { +TEST(ProxyTunnelTest, DigestRetryConnectTargetFollowsConnectToMap) { proxy_tunnel_test::ScopedSSLServer origin; origin.svr().Get(".*", [&](const Request &, Response &res) { res.set_content("ok", "text/plain"); @@ -31384,7 +31452,7 @@ TEST(ProxyTunnelTest, DigestRetryConnectTargetFollowsHostnameAddrMap) { SSLClient cli("origin.example", origin.port()); cli.enable_server_certificate_verification(false); - cli.set_hostname_addr_map({{"origin.example", "127.0.0.1"}}); + cli.set_connect_to_map({{"origin.example", "127.0.0.1"}}); cli.set_proxy("127.0.0.1", proxy.port()); cli.set_proxy_digest_auth("proxy-user", "proxy-pass"); @@ -31393,10 +31461,40 @@ TEST(ProxyTunnelTest, DigestRetryConnectTargetFollowsHostnameAddrMap) { EXPECT_EQ(StatusCode::OK_200, res->status); // The retried CONNECT is the one the proxy saw last. + auto target = "127.0.0.1:" + std::to_string(origin.port()); + auto connect_req = proxy.connect_request(); EXPECT_EQ(2, proxy.connect_hits()); - EXPECT_EQ(0u, proxy.connect_request().find( - "CONNECT 127.0.0.1:" + std::to_string(origin.port()) + - " HTTP/1.1\r\n")); + EXPECT_EQ(0u, connect_req.find("CONNECT " + target + " HTTP/1.1\r\n")); + EXPECT_NE(std::string::npos, + connect_req.find("\r\nHost: " + target + "\r\n")); +} + +TEST(ProxyTunnelTest, HostnameAddrMapLeavesConnectTarget) { + // set_hostname_addr_map() works like curl's --resolve: through a proxy it is + // not used, and the proxy resolves the original hostname itself. + proxy_tunnel_test::ScopedSSLServer origin; + origin.svr().Get(".*", [&](const Request &, Response &res) { + res.set_content("ok", "text/plain"); + }); + origin.listen(); + + proxy_tunnel_test::ScopedConnectProxy proxy(origin.port()); + ASSERT_NE(0, proxy.port()); + + SSLClient cli("origin.example", origin.port()); + cli.enable_server_certificate_verification(false); + cli.set_hostname_addr_map({{"origin.example", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", proxy.port()); + + auto res = cli.Get("/x"); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ(StatusCode::OK_200, res->status); + + auto target = "origin.example:" + std::to_string(origin.port()); + auto connect_req = proxy.connect_request(); + EXPECT_EQ(0u, connect_req.find("CONNECT " + target + " HTTP/1.1\r\n")); + EXPECT_NE(std::string::npos, + connect_req.find("\r\nHost: " + target + "\r\n")); } #endif @@ -31589,14 +31687,14 @@ TEST(WebSocketProxyTest, ProxySettingAppliesOnReconnect) { EXPECT_EQ(2, proxy.connect_hits()); } -TEST(WebSocketProxyTest, ConnectTargetFollowsHostnameAddrMap) { +TEST(WebSocketProxyTest, ConnectTargetFollowsConnectToMap) { proxy_tunnel_test::ScopedWebSocketOrigin origin; proxy_tunnel_test::ScopedConnectProxy proxy(origin.port()); ASSERT_NE(0, proxy.port()); auto port = std::to_string(origin.port()); ws::WebSocketClient client("ws://origin.example:" + port + "/ws"); - client.set_hostname_addr_map({{"origin.example", "127.0.0.1"}}); + client.set_connect_to_map({{"origin.example", "127.0.0.1"}}); client.set_proxy("127.0.0.1", proxy.port()); auto res = client.connect(); ASSERT_TRUE(res) << "Error: " << to_string(res.error()); @@ -31613,6 +31711,26 @@ TEST(WebSocketProxyTest, ConnectTargetFollowsHostnameAddrMap) { connect_req.find("\r\nHost: " + target + "\r\n")); } +TEST(WebSocketProxyTest, HostnameAddrMapLeavesConnectTarget) { + proxy_tunnel_test::ScopedWebSocketOrigin origin; + proxy_tunnel_test::ScopedConnectProxy proxy(origin.port()); + ASSERT_NE(0, proxy.port()); + + auto port = std::to_string(origin.port()); + ws::WebSocketClient client("ws://origin.example:" + port + "/ws"); + client.set_hostname_addr_map({{"origin.example", "127.0.0.1"}}); + client.set_proxy("127.0.0.1", proxy.port()); + auto res = client.connect(); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + client.close(); + + auto target = "origin.example:" + port; + auto connect_req = proxy.connect_request(); + EXPECT_EQ(0u, connect_req.find("CONNECT " + target + " HTTP/1.1\r\n")); + EXPECT_NE(std::string::npos, + connect_req.find("\r\nHost: " + target + "\r\n")); +} + #ifdef CPPHTTPLIB_SSL_ENABLED TEST(WebSocketProxyTest, DigestRetriesConnectOnFreshSocket) { proxy_tunnel_test::ScopedWebSocketOrigin origin; From 539fe77883151d8b420d46ff06b7a080874a736c Mon Sep 17 00:00:00 2001 From: "hyuk.kim" Date: Mon, 26 Oct 2026 23:58:14 +0900 Subject: [PATCH 07/10] docs: Describe set_resolve_map() and set_connect_to_map() Replace the paragraph that described set_hostname_addr_map() as the CONNECT target with the curl --resolve / --connect-to split, and list the new setters in README-websocket.md. --- README-websocket.md | 2 ++ README.md | 30 ++++++++++++++++++++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README-websocket.md b/README-websocket.md index 49175c6cb5..986cb5fafc 100644 --- a/README-websocket.md +++ b/README-websocket.md @@ -234,6 +234,8 @@ void set_ipv6_v6only(bool on); void set_socket_options(SocketOptions socket_options); void set_interface(const std::string &intf); void set_hostname_addr_map(std::map addr_map); +void set_resolve_map(std::map addr_map); +void set_connect_to_map(std::map connect_to_map); // SSL configuration (wss:// only, SSL builds only) void set_ca_cert_path(const std::string &ca_cert_file_path, diff --git a/README.md b/README.md index 0e8f214469..8d0dda9f1f 100644 --- a/README.md +++ b/README.md @@ -1498,13 +1498,31 @@ cli.set_hostname_addr_map({{"example.com", "internal.example.lan"}}); An empty value is ignored, leaving the original hostname as the connection target. -When the connection is tunnelled through a proxy with `CONNECT` (HTTPS and -WebSocket), the socket connects to the proxy instead, and the mapped address -becomes the `CONNECT` target, so the proxy reaches the same address a direct -connection would. Inside the tunnel the hostname still supplies the `Host` -header and the SNI. +`set_resolve_map` is the same setting under a name that matches curl's +`--resolve`. Like `--resolve`, it is not used through a proxy: the socket +connects to the proxy, and the proxy resolves the original hostname of the +`CONNECT` target itself. -The same method is available on `httplib::ws::WebSocketClient`. +To change where the connection goes whether or not a proxy is used, use +`set_connect_to_map`, which works like curl's `--connect-to`. On a direct +connection it changes the connection target, and through a proxy it becomes +the `CONNECT` target (HTTPS and WebSocket). The hostname still supplies the +`Host` header, the SNI and the certificate check inside the tunnel: + +```cpp +httplib::Client cli("https://sni.example"); + +// curl -x http://proxy.example:3128 \ +// --connect-to sni.example:443:server.example:443 https://sni.example/ +cli.set_connect_to_map({{"sni.example", "server.example"}}); +cli.set_proxy("proxy.example", 3128); +// CONNECT server.example:443, SNI sni.example +``` + +As with curl, the connect-to map applies first on a direct connection, and +the resolve map then applies to the resulting name. + +These methods are also available on `httplib::ws::WebSocketClient`. ### Automatic Path Encoding From fdb60999b3a7961ff6b9cb2cd2c1a479af2c00d5 Mon Sep 17 00:00:00 2001 From: "hyuk.kim" Date: Tue, 27 Oct 2026 18:28:28 +0900 Subject: [PATCH 08/10] Rename the address maps to set_direct/tunnel_hostname_addr_map() Replace set_resolve_map() and set_connect_to_map() with two independent setters on ClientImpl, Client and WebSocketClient, as discussed in the review: - set_direct_hostname_addr_map(): the address the client connects to directly. This is what set_hostname_addr_map() does today. - set_tunnel_hostname_addr_map(): the address the proxy is asked to connect to in the CONNECT request. SNI, the tunnelled Host header and certificate verification keep the original hostname. The two maps no longer interact, so the connect-to-then-resolve helper is gone: the direct map alone picks the direct connection target, and the tunnel map goes straight to the CONNECT target builder. The CONNECT Host still matches the request target, including the Digest retry, and the redirect client still copies both maps. set_hostname_addr_map() is kept as a thin wrapper around set_direct_hostname_addr_map() with no change in behavior, and is marked [[deprecated]]. Tests, README, README-websocket and docs-src switch to the new name; one test keeps calling the old name, with the deprecation warning suppressed, to pin that it still works. A new test pins that the tunnel map leaves a direct connection alone. --- README-websocket.md | 5 +- README.md | 40 ++++----- docs-src/pages/en/tour/09-whats-next.md | 2 +- docs-src/pages/ja/tour/09-whats-next.md | 2 +- httplib.h | 106 +++++++++++------------- test/test.cc | 95 +++++++++++---------- 6 files changed, 119 insertions(+), 131 deletions(-) diff --git a/README-websocket.md b/README-websocket.md index 986cb5fafc..b787118ff7 100644 --- a/README-websocket.md +++ b/README-websocket.md @@ -233,9 +233,8 @@ void set_address_family(int family); void set_ipv6_v6only(bool on); void set_socket_options(SocketOptions socket_options); void set_interface(const std::string &intf); -void set_hostname_addr_map(std::map addr_map); -void set_resolve_map(std::map addr_map); -void set_connect_to_map(std::map connect_to_map); +void set_direct_hostname_addr_map(std::map addr_map); +void set_tunnel_hostname_addr_map(std::map addr_map); // SSL configuration (wss:// only, SSL builds only) void set_ca_cert_path(const std::string &ca_cert_file_path, diff --git a/README.md b/README.md index 8d0dda9f1f..476fa9510a 100644 --- a/README.md +++ b/README.md @@ -1474,17 +1474,17 @@ The same method is available on `httplib::ws::WebSocketClient`. ### Override the connection target for a hostname -`set_hostname_addr_map` redirects where the socket connects, without changing -the identity of the request. The hostname the client was constructed with keeps -supplying the `Host` header, the SNI, and the name that the server certificate -is verified against, so this is a connection-level override only, not a way to -talk to a different origin. +`set_direct_hostname_addr_map` redirects where the socket connects, without +changing the identity of the request. The hostname the client was constructed +with keeps supplying the `Host` header, the SNI, and the name that the server +certificate is verified against, so this is a connection-level override only, +not a way to talk to a different origin. ```cpp httplib::Client cli("https://example.com"); // Connect to this IP address instead of resolving "example.com" -cli.set_hostname_addr_map({{"example.com", "192.168.1.10"}}); +cli.set_direct_hostname_addr_map({{"example.com", "192.168.1.10"}}); ``` A mapped value may be an IP literal or another hostname. An IP literal is used @@ -1492,35 +1492,31 @@ as-is; anything else is resolved as a name, so a host that is only reachable under a different name works too: ```cpp -cli.set_hostname_addr_map({{"example.com", "internal.example.lan"}}); +cli.set_direct_hostname_addr_map({{"example.com", "internal.example.lan"}}); ``` An empty value is ignored, leaving the original hostname as the connection target. -`set_resolve_map` is the same setting under a name that matches curl's -`--resolve`. Like `--resolve`, it is not used through a proxy: the socket -connects to the proxy, and the proxy resolves the original hostname of the -`CONNECT` target itself. - -To change where the connection goes whether or not a proxy is used, use -`set_connect_to_map`, which works like curl's `--connect-to`. On a direct -connection it changes the connection target, and through a proxy it becomes -the `CONNECT` target (HTTPS and WebSocket). The hostname still supplies the -`Host` header, the SNI and the certificate check inside the tunnel: +The direct map applies only when the client connects to the origin itself. +Through a proxy, use `set_tunnel_hostname_addr_map`: its value becomes the +`CONNECT` target (and that request's `Host`) that the proxy is asked to connect +to (HTTPS and WebSocket). The `Host` header, the SNI and the certificate check +inside the tunnel keep the original hostname: ```cpp httplib::Client cli("https://sni.example"); -// curl -x http://proxy.example:3128 \ -// --connect-to sni.example:443:server.example:443 https://sni.example/ -cli.set_connect_to_map({{"sni.example", "server.example"}}); +cli.set_tunnel_hostname_addr_map({{"sni.example", "server.example"}}); cli.set_proxy("proxy.example", 3128); // CONNECT server.example:443, SNI sni.example ``` -As with curl, the connect-to map applies first on a direct connection, and -the resolve map then applies to the resulting name. +The two maps are independent; to use the same target on both paths, set both. +They correspond to curl's `--resolve` and `--connect-to`. + +`set_hostname_addr_map` is deprecated and works the same as +`set_direct_hostname_addr_map`. These methods are also available on `httplib::ws::WebSocketClient`. diff --git a/docs-src/pages/en/tour/09-whats-next.md b/docs-src/pages/en/tour/09-whats-next.md index 0ec66d2385..8b2e5bb4e4 100644 --- a/docs-src/pages/en/tour/09-whats-next.md +++ b/docs-src/pages/en/tour/09-whats-next.md @@ -214,7 +214,7 @@ svr.listen("/tmp/httplib.sock", 80); // The port is unused, but must not be 0 // Client httplib::Client cli("http://localhost"); cli.set_address_family(AF_UNIX); -cli.set_hostname_addr_map({{"localhost", "/tmp/httplib.sock"}}); +cli.set_direct_hostname_addr_map({{"localhost", "/tmp/httplib.sock"}}); auto res = cli.Get("/"); ``` diff --git a/docs-src/pages/ja/tour/09-whats-next.md b/docs-src/pages/ja/tour/09-whats-next.md index 0ff8625ae2..62ff091cb6 100644 --- a/docs-src/pages/ja/tour/09-whats-next.md +++ b/docs-src/pages/ja/tour/09-whats-next.md @@ -214,7 +214,7 @@ svr.listen("/tmp/httplib.sock", 80); // ポート番号は使われない(0 // クライアント httplib::Client cli("http://localhost"); cli.set_address_family(AF_UNIX); -cli.set_hostname_addr_map({{"localhost", "/tmp/httplib.sock"}}); +cli.set_direct_hostname_addr_map({{"localhost", "/tmp/httplib.sock"}}); auto res = cli.Get("/"); ``` diff --git a/httplib.h b/httplib.h index b6b45f6662..19191d7421 100644 --- a/httplib.h +++ b/httplib.h @@ -2921,9 +2921,12 @@ class ClientImpl { size_t is_socket_open() const; socket_t socket() const; + [[deprecated("Use set_direct_hostname_addr_map() instead.")]] void set_hostname_addr_map(std::map addr_map); - void set_resolve_map(std::map addr_map); - void set_connect_to_map(std::map connect_to_map); + void + set_direct_hostname_addr_map(std::map addr_map); + void + set_tunnel_hostname_addr_map(std::map addr_map); void set_default_headers(Headers headers); @@ -3055,9 +3058,9 @@ class ClientImpl { // hostname; only the connection target changes, never the identity. std::map addr_map_; - // Like curl's --connect-to: retargets both a direct connection (before - // addr_map_ applies) and the CONNECT through a proxy. The identity stays. - std::map connect_to_map_; + // Hostname to CONNECT target map, used only through a proxy. Independent of + // addr_map_; the identity (Host, SNI, certificate check) stays the same. + std::map tunnel_addr_map_; // Default headers Headers default_headers_; @@ -3348,9 +3351,12 @@ class Client { size_t is_socket_open() const; socket_t socket() const; + [[deprecated("Use set_direct_hostname_addr_map() instead.")]] void set_hostname_addr_map(std::map addr_map); - void set_resolve_map(std::map addr_map); - void set_connect_to_map(std::map connect_to_map); + void + set_direct_hostname_addr_map(std::map addr_map); + void + set_tunnel_hostname_addr_map(std::map addr_map); void set_default_headers(Headers headers); @@ -4746,9 +4752,12 @@ class WebSocketClient { set_connection_timeout(const std::chrono::duration &duration); void set_interface(const std::string &intf); + [[deprecated("Use set_direct_hostname_addr_map() instead.")]] void set_hostname_addr_map(std::map addr_map); - void set_resolve_map(std::map addr_map); - void set_connect_to_map(std::map connect_to_map); + void + set_direct_hostname_addr_map(std::map addr_map); + void + set_tunnel_hostname_addr_map(std::map addr_map); void set_proxy(const std::string &host, int port); void set_proxy_basic_auth(const std::string &username, @@ -4820,9 +4829,9 @@ class WebSocketClient { // hostname; only the connection target changes, never the identity. std::map addr_map_; - // Like curl's --connect-to: retargets both a direct connection (before - // addr_map_ applies) and the CONNECT through a proxy. The identity stays. - std::map connect_to_map_; + // Hostname to CONNECT target map, used only through a proxy. Independent of + // addr_map_; the identity (Host, SNI, certificate check) stays the same. + std::map tunnel_addr_map_; std::string proxy_host_; int proxy_port_ = -1; @@ -10735,20 +10744,6 @@ inline void apply_addr_map(const std::map &addr_map, } } -// Direct connection target for `host`, as curl applies --connect-to before -// --resolve: the connect-to map rewrites the name first, then the addr map -// applies to the result. Without a connect-to entry this is apply_addr_map(). -inline void apply_connect_to_and_addr_map( - const std::map &connect_to_map, - const std::map &addr_map, const std::string &host, - std::string &connect_host, std::string &ip) { - apply_addr_map(connect_to_map, host, connect_host, ip); - if (!ip.empty()) { return; } - - auto target = connect_host; - apply_addr_map(addr_map, target, connect_host, ip); -} - } // namespace detail /* @@ -13042,7 +13037,7 @@ make_host_and_port_string_always_port(const std::string &host, int port) { } // Request target of a CONNECT for `host`. Through a proxy it is the proxy, not -// this client, that opens the connection to the origin, so a connect-to +// this client, that opens the connection to the origin, so a tunnel map // override has to be carried in the request target. The tunnelled request // keeps `host` for its Host header and SNI, as a direct connection does. inline std::string @@ -15606,8 +15601,7 @@ inline socket_t ClientImpl::create_client_socket(Error &error) const { // Check is custom IP or hostname specified for host_ std::string connect_host; std::string ip; - detail::apply_connect_to_and_addr_map(connect_to_map_, addr_map_, host_, - connect_host, ip); + detail::apply_addr_map(addr_map_, host_, connect_host, ip); return detail::create_client_socket( connect_host, ip, port_, address_family_, tcp_nodelay_, ipv6_v6only_, @@ -16571,7 +16565,7 @@ inline void ClientImpl::setup_redirect_client(ClientType &client) { // Copy network and socket settings client.addr_map_ = addr_map_; - client.connect_to_map_ = connect_to_map_; + client.tunnel_addr_map_ = tunnel_addr_map_; client.set_address_family(address_family_); client.set_tcp_nodelay(tcp_nodelay_); client.set_ipv6_v6only(ipv6_v6only_); @@ -18085,17 +18079,17 @@ inline void ClientImpl::set_path_encode(bool on) { path_encode_ = on; } inline void ClientImpl::set_hostname_addr_map(std::map addr_map) { - addr_map_ = std::move(addr_map); + set_direct_hostname_addr_map(std::move(addr_map)); } -inline void -ClientImpl::set_resolve_map(std::map addr_map) { - set_hostname_addr_map(std::move(addr_map)); +inline void ClientImpl::set_direct_hostname_addr_map( + std::map addr_map) { + addr_map_ = std::move(addr_map); } -inline void ClientImpl::set_connect_to_map( - std::map connect_to_map) { - connect_to_map_ = std::move(connect_to_map); +inline void ClientImpl::set_tunnel_hostname_addr_map( + std::map addr_map) { + tunnel_addr_map_ = std::move(addr_map); } inline void ClientImpl::set_default_headers(Headers headers) { @@ -18806,17 +18800,17 @@ inline socket_t Client::socket() const { return cli_->socket(); } inline void Client::set_hostname_addr_map(std::map addr_map) { - cli_->set_hostname_addr_map(std::move(addr_map)); + cli_->set_direct_hostname_addr_map(std::move(addr_map)); } -inline void -Client::set_resolve_map(std::map addr_map) { - cli_->set_resolve_map(std::move(addr_map)); +inline void Client::set_direct_hostname_addr_map( + std::map addr_map) { + cli_->set_direct_hostname_addr_map(std::move(addr_map)); } -inline void -Client::set_connect_to_map(std::map connect_to_map) { - cli_->set_connect_to_map(std::move(connect_to_map)); +inline void Client::set_tunnel_hostname_addr_map( + std::map addr_map) { + cli_->set_tunnel_hostname_addr_map(std::move(addr_map)); } inline void Client::set_default_headers(Headers headers) { @@ -19184,10 +19178,10 @@ inline bool SSLClient::connect_with_proxy( std::chrono::time_point start_time, Response &res, bool &success, Error &error) { success = true; - // A connect-to entry retargets the CONNECT, and its Host must then name the + // A tunnel map entry retargets the CONNECT, and its Host must then name the // same authority. Without one the request stays exactly as it was. auto connect_target = - detail::make_connect_request_target(connect_to_map_, host_, port_); + detail::make_connect_request_target(tunnel_addr_map_, host_, port_); auto retargeted = connect_target != detail::make_host_and_port_string_always_port(host_, port_); Response proxy_res; @@ -24034,17 +24028,17 @@ inline void WebSocketClient::set_interface(const std::string &intf) { inline void WebSocketClient::set_hostname_addr_map( std::map addr_map) { - addr_map_ = std::move(addr_map); + set_direct_hostname_addr_map(std::move(addr_map)); } -inline void -WebSocketClient::set_resolve_map(std::map addr_map) { - set_hostname_addr_map(std::move(addr_map)); +inline void WebSocketClient::set_direct_hostname_addr_map( + std::map addr_map) { + addr_map_ = std::move(addr_map); } -inline void WebSocketClient::set_connect_to_map( - std::map connect_to_map) { - connect_to_map_ = std::move(connect_to_map); +inline void WebSocketClient::set_tunnel_hostname_addr_map( + std::map addr_map) { + tunnel_addr_map_ = std::move(addr_map); } inline bool WebSocketClient::is_proxy_enabled() const { @@ -24062,8 +24056,7 @@ inline bool WebSocketClient::create_and_connect_socket(Error &error) { connect_host = proxy_host_; port = proxy_port_; } else { - detail::apply_connect_to_and_addr_map(connect_to_map_, addr_map_, host_, - connect_host, ip); + detail::apply_addr_map(addr_map_, host_, connect_host, ip); } sock_ = detail::create_client_socket( @@ -24097,7 +24090,8 @@ inline bool WebSocketClient::connect_with_proxy(Response &proxy_res, Error &error) { Request req; req.method = "CONNECT"; - req.path = detail::make_connect_request_target(connect_to_map_, host_, port_); + req.path = + detail::make_connect_request_target(tunnel_addr_map_, host_, port_); req.headers.emplace("Host", req.path); detail::add_default_user_agent_header(req); detail::add_authorization_header(req, proxy_basic_auth_username_, diff --git a/test/test.cc b/test/test.cc index fff3b17fd9..a90bf6ce03 100644 --- a/test/test.cc +++ b/test/test.cc @@ -3704,7 +3704,7 @@ TEST(SpecifyServerIPAddressTest, AnotherHostname_Online) { Client cli(host); #endif - cli.set_hostname_addr_map({{another_host, wrong_ip}}); + cli.set_direct_hostname_addr_map({{another_host, wrong_ip}}); auto res = cli.Get("/"); ASSERT_TRUE(res) << "Error: " << to_string(res.error()); ASSERT_EQ(StatusCode::MovedPermanently_301, res->status); @@ -3720,7 +3720,7 @@ TEST(SpecifyServerIPAddressTest, RealHostname_Online) { Client cli(host); #endif - cli.set_hostname_addr_map({{host, wrong_ip}}); + cli.set_direct_hostname_addr_map({{host, wrong_ip}}); auto res = cli.Get("/"); ASSERT_TRUE(!res); EXPECT_EQ(Error::Connection, res.error()); @@ -3751,7 +3751,7 @@ TEST(SpecifyServerIPAddressTest, HostnameAsAddrMapValue) { svr.wait_until_ready(); Client cli(host, port); - cli.set_hostname_addr_map({{host, HOST}}); + cli.set_direct_hostname_addr_map({{host, HOST}}); auto res = cli.Get("/hi"); ASSERT_TRUE(res) << "Error: " << to_string(res.error()); @@ -3781,7 +3781,7 @@ TEST(SpecifyServerIPAddressTest, IPAddressAsAddrMapValue) { svr.wait_until_ready(); Client cli(host, port); - cli.set_hostname_addr_map({{host, "127.0.0.1"}}); + cli.set_direct_hostname_addr_map({{host, "127.0.0.1"}}); auto res = cli.Get("/hi"); ASSERT_TRUE(res) << "Error: " << to_string(res.error()); @@ -3814,16 +3814,16 @@ TEST(SpecifyServerIPAddressTest, EmptyAddrMapValueIsIgnored) { svr.wait_until_ready(); Client cli(blackhole, port); - cli.set_hostname_addr_map({{blackhole, ""}}); + cli.set_direct_hostname_addr_map({{blackhole, ""}}); cli.set_connection_timeout(1); auto res = cli.Get("/hi"); EXPECT_FALSE(res) << "empty mapping must not redirect to loopback"; } -TEST(ConnectToMapTest, RetargetsDirectConnection) { - // Like curl's --connect-to, the map changes where the client connects, and - // the identity stays host_. "target.invalid" (RFC 6761) is never resolved. +TEST(SpecifyServerIPAddressTest, DeprecatedSetHostnameAddrMapStillWorks) { + // set_hostname_addr_map() is a deprecated alias of + // set_direct_hostname_addr_map() and must keep redirecting the connection. auto host = "target.invalid"; Server svr; @@ -3845,7 +3845,10 @@ TEST(ConnectToMapTest, RetargetsDirectConnection) { svr.wait_until_ready(); Client cli(host, port); - cli.set_connect_to_map({{host, "127.0.0.1"}}); +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + cli.set_hostname_addr_map({{host, "127.0.0.1"}}); +#pragma GCC diagnostic pop auto res = cli.Get("/hi"); ASSERT_TRUE(res) << "Error: " << to_string(res.error()); @@ -3853,15 +3856,12 @@ TEST(ConnectToMapTest, RetargetsDirectConnection) { EXPECT_EQ(std::string(host) + ":" + std::to_string(port), received_host); } -TEST(ConnectToMapTest, AppliesBeforeResolveMap) { - // As curl applies --connect-to before --resolve, the resolve map applies to - // the rewritten name. Neither ".invalid" name is ever resolved. - auto host = "target.invalid"; - +TEST(TunnelHostnameAddrMapTest, IgnoredOnDirectConnection) { + // The tunnel map only rewrites the CONNECT target through a proxy. On a + // direct connection it must not move the socket: the mapped address is + // TEST-NET-1, so applying it would fail to connect. Server svr; - std::string received_host; - svr.Get("/hi", [&](const Request &req, Response &res) { - received_host = req.get_header_value("Host"); + svr.Get("/hi", [](const Request & /*req*/, Response &res) { res.set_content("Hello World!", "text/plain"); }); @@ -3876,14 +3876,13 @@ TEST(ConnectToMapTest, AppliesBeforeResolveMap) { svr.wait_until_ready(); - Client cli(host, port); - cli.set_connect_to_map({{host, "mid.invalid"}}); - cli.set_resolve_map({{"mid.invalid", "127.0.0.1"}}); + Client cli("127.0.0.1", port); + cli.set_tunnel_hostname_addr_map({{"127.0.0.1", "192.0.2.1"}}); + cli.set_connection_timeout(1); auto res = cli.Get("/hi"); ASSERT_TRUE(res) << "Error: " << to_string(res.error()); EXPECT_EQ(StatusCode::OK_200, res->status); - EXPECT_EQ(std::string(host) + ":" + std::to_string(port), received_host); } TEST(AbsoluteRedirectTest, Redirect_Online) { @@ -13256,7 +13255,7 @@ TEST(SNI_AutoDetectionTest, FailedHandshakeLeavesNoSNI) { { // Sends SNI, then rejects the server certificate, which the CA did not sign SSLClient cli("localhost", port); - cli.set_hostname_addr_map({{"localhost", "127.0.0.1"}}); + cli.set_direct_hostname_addr_map({{"localhost", "127.0.0.1"}}); cli.set_ca_cert_path(CLIENT_CA_CERT_FILE); auto res = cli.Get("/sni"); ASSERT_FALSE(res); @@ -16345,7 +16344,7 @@ TEST(SSLClientServerTest, ClientCertNotSentToUnverifiedServer) { svr.wait_until_ready(); SSLClient cli(c.host, port, CLIENT_CERT_FILE, CLIENT_PRIVATE_KEY_FILE); - cli.set_hostname_addr_map({{c.host, "127.0.0.1"}}); + cli.set_direct_hostname_addr_map({{c.host, "127.0.0.1"}}); cli.set_ca_cert_path(c.trusted_ca); if (with_verifier) { cli.set_server_certificate_verifier( @@ -27458,7 +27457,7 @@ TEST(WebSocketTest, SpecifyServerIPAddress_AnotherHostname) { auto wrong_ip = "192.0.2.1"; ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws"); - client.set_hostname_addr_map({{another_host, wrong_ip}}); + client.set_direct_hostname_addr_map({{another_host, wrong_ip}}); ASSERT_TRUE(client.connect()); EXPECT_TRUE(client.is_open()); @@ -27482,7 +27481,7 @@ TEST(WebSocketTest, SpecifyServerIPAddress_RealHostname) { auto wrong_ip = "192.0.2.1"; ws::WebSocketClient client("ws://localhost:" + std::to_string(port) + "/ws"); - client.set_hostname_addr_map({{"localhost", wrong_ip}}); + client.set_direct_hostname_addr_map({{"localhost", wrong_ip}}); client.set_connection_timeout(1); client.set_read_timeout(1); client.set_write_timeout(1); @@ -27522,7 +27521,7 @@ TEST(WebSocketTest, SpecifyServerIPAddress_HostnameAsAddrMapValue) { ws::WebSocketClient client("ws://" + std::string(host) + ":" + std::to_string(port) + "/ws"); - client.set_hostname_addr_map({{host, HOST}}); + client.set_direct_hostname_addr_map({{host, HOST}}); ASSERT_TRUE(client.connect()); EXPECT_TRUE(client.is_open()); @@ -30453,7 +30452,7 @@ class ProxyAndTargetServers { 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_direct_hostname_addr_map({{host, "127.0.0.1"}}); cli->set_proxy("127.0.0.1", s.proxy_port()); return cli; } @@ -30662,7 +30661,7 @@ TEST(NoProxyTest, IPv4LiteralExactMatch) { 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_direct_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"}); @@ -30679,7 +30678,7 @@ TEST(NoProxyTest, BareIPv6LiteralMatchesIPv6Cidr) { // 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_direct_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"}); @@ -30692,7 +30691,7 @@ TEST(NoProxyTest, BareIPv6LiteralMatchesIPv6Cidr) { TEST(NoProxyTest, BracketedIPv6EntryAccepted) { ProxyAndTargetServers s; Client cli("::1", s.target_port()); - cli.set_hostname_addr_map({{"::1", "127.0.0.1"}}); + cli.set_direct_hostname_addr_map({{"::1", "127.0.0.1"}}); cli.set_proxy("127.0.0.1", s.proxy_port()); cli.set_no_proxy({"[::1]"}); @@ -30705,7 +30704,7 @@ TEST(NoProxyTest, BracketedIPv6EntryAccepted) { 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_direct_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"}); @@ -30721,7 +30720,7 @@ TEST(NoProxyTest, IPv4MappedIPv6IsNotCrossMatchedAgainstIPv4Entry) { // 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_direct_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"}); @@ -30937,7 +30936,7 @@ TEST(NoProxyTest, RedirectToBypassedHostStripsProxyAndProxyAuth) { target.listen(); Client cli("public.example", target.port()); - cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}}); + cli.set_direct_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"}); @@ -30973,7 +30972,7 @@ TEST(NoProxyTest, BypassedTargetReturning407DoesNotLeakProxyDigestCredentials) { // 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_direct_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"}); @@ -31024,7 +31023,7 @@ TEST(NoProxyTest, MultiHopRedirectThroughBypassedHostKeepsProxy) { bypass_server.listen(); Client cli("public.example", public_port); - cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}}); + cli.set_direct_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); @@ -31058,7 +31057,7 @@ TEST(NoProxyTest, KeepAliveSocketInvalidatedOnSetNoProxy) { target.listen(); Client cli("public.example", target.port()); - cli.set_hostname_addr_map({{"public.example", "127.0.0.1"}}); + cli.set_direct_hostname_addr_map({{"public.example", "127.0.0.1"}}); cli.set_proxy("127.0.0.1", proxy_mock.port()); cli.set_keep_alive(true); @@ -31391,7 +31390,7 @@ TEST(ProxyTunnelTest, OriginReturning407InsideTunnelDoesNotLeakProxyDigest) { EXPECT_EQ(1, proxy.connect_hits()); } -TEST(ProxyTunnelTest, ConnectTargetFollowsConnectToMap) { +TEST(ProxyTunnelTest, ConnectTargetFollowsTunnelHostnameAddrMap) { // Like curl's --connect-to, the mapped address is the CONNECT target and its // Host. Inside the tunnel the hostname keeps supplying SNI and Host, as it // does on a direct connection. @@ -31411,7 +31410,7 @@ TEST(ProxyTunnelTest, ConnectTargetFollowsConnectToMap) { SSLClient cli("origin.example", origin.port()); cli.enable_server_certificate_verification(false); - cli.set_connect_to_map({{"origin.example", "127.0.0.1"}}); + cli.set_tunnel_hostname_addr_map({{"origin.example", "127.0.0.1"}}); cli.set_proxy("127.0.0.1", proxy.port()); auto res = cli.Get("/x"); @@ -31429,7 +31428,7 @@ TEST(ProxyTunnelTest, ConnectTargetFollowsConnectToMap) { EXPECT_EQ("origin.example:" + port, origin_host); } -TEST(ProxyTunnelTest, DigestRetryConnectTargetFollowsConnectToMap) { +TEST(ProxyTunnelTest, DigestRetryConnectTargetFollowsTunnelHostnameAddrMap) { proxy_tunnel_test::ScopedSSLServer origin; origin.svr().Get(".*", [&](const Request &, Response &res) { res.set_content("ok", "text/plain"); @@ -31452,7 +31451,7 @@ TEST(ProxyTunnelTest, DigestRetryConnectTargetFollowsConnectToMap) { SSLClient cli("origin.example", origin.port()); cli.enable_server_certificate_verification(false); - cli.set_connect_to_map({{"origin.example", "127.0.0.1"}}); + cli.set_tunnel_hostname_addr_map({{"origin.example", "127.0.0.1"}}); cli.set_proxy("127.0.0.1", proxy.port()); cli.set_proxy_digest_auth("proxy-user", "proxy-pass"); @@ -31469,9 +31468,9 @@ TEST(ProxyTunnelTest, DigestRetryConnectTargetFollowsConnectToMap) { connect_req.find("\r\nHost: " + target + "\r\n")); } -TEST(ProxyTunnelTest, HostnameAddrMapLeavesConnectTarget) { - // set_hostname_addr_map() works like curl's --resolve: through a proxy it is - // not used, and the proxy resolves the original hostname itself. +TEST(ProxyTunnelTest, DirectHostnameAddrMapLeavesConnectTarget) { + // set_direct_hostname_addr_map() works like curl's --resolve: through a proxy + // it is not used, and the proxy resolves the original hostname itself. proxy_tunnel_test::ScopedSSLServer origin; origin.svr().Get(".*", [&](const Request &, Response &res) { res.set_content("ok", "text/plain"); @@ -31483,7 +31482,7 @@ TEST(ProxyTunnelTest, HostnameAddrMapLeavesConnectTarget) { SSLClient cli("origin.example", origin.port()); cli.enable_server_certificate_verification(false); - cli.set_hostname_addr_map({{"origin.example", "127.0.0.1"}}); + cli.set_direct_hostname_addr_map({{"origin.example", "127.0.0.1"}}); cli.set_proxy("127.0.0.1", proxy.port()); auto res = cli.Get("/x"); @@ -31687,14 +31686,14 @@ TEST(WebSocketProxyTest, ProxySettingAppliesOnReconnect) { EXPECT_EQ(2, proxy.connect_hits()); } -TEST(WebSocketProxyTest, ConnectTargetFollowsConnectToMap) { +TEST(WebSocketProxyTest, ConnectTargetFollowsTunnelHostnameAddrMap) { proxy_tunnel_test::ScopedWebSocketOrigin origin; proxy_tunnel_test::ScopedConnectProxy proxy(origin.port()); ASSERT_NE(0, proxy.port()); auto port = std::to_string(origin.port()); ws::WebSocketClient client("ws://origin.example:" + port + "/ws"); - client.set_connect_to_map({{"origin.example", "127.0.0.1"}}); + client.set_tunnel_hostname_addr_map({{"origin.example", "127.0.0.1"}}); client.set_proxy("127.0.0.1", proxy.port()); auto res = client.connect(); ASSERT_TRUE(res) << "Error: " << to_string(res.error()); @@ -31711,14 +31710,14 @@ TEST(WebSocketProxyTest, ConnectTargetFollowsConnectToMap) { connect_req.find("\r\nHost: " + target + "\r\n")); } -TEST(WebSocketProxyTest, HostnameAddrMapLeavesConnectTarget) { +TEST(WebSocketProxyTest, DirectHostnameAddrMapLeavesConnectTarget) { proxy_tunnel_test::ScopedWebSocketOrigin origin; proxy_tunnel_test::ScopedConnectProxy proxy(origin.port()); ASSERT_NE(0, proxy.port()); auto port = std::to_string(origin.port()); ws::WebSocketClient client("ws://origin.example:" + port + "/ws"); - client.set_hostname_addr_map({{"origin.example", "127.0.0.1"}}); + client.set_direct_hostname_addr_map({{"origin.example", "127.0.0.1"}}); client.set_proxy("127.0.0.1", proxy.port()); auto res = client.connect(); ASSERT_TRUE(res) << "Error: " << to_string(res.error()); From e81978ec0410ad0235b29239b5c8b019990ab340 Mon Sep 17 00:00:00 2001 From: "hyuk.kim" Date: Tue, 27 Oct 2026 23:16:05 +0000 Subject: [PATCH 09/10] docs: Note that the address maps do not apply to the proxy address --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 476fa9510a..94ea78f8ee 100644 --- a/README.md +++ b/README.md @@ -1515,6 +1515,10 @@ cli.set_proxy("proxy.example", 3128); The two maps are independent; to use the same target on both paths, set both. They correspond to curl's `--resolve` and `--connect-to`. +Neither map applies to the proxy's own address ("direct" means not through a +proxy, as in a PAC file's `DIRECT`). To connect to the proxy at a fixed +address, pass an IP literal to `set_proxy()`. + `set_hostname_addr_map` is deprecated and works the same as `set_direct_hostname_addr_map`. From 43864e8d2583bb1aa71e5c45242cb3e3decf897a Mon Sep 17 00:00:00 2001 From: yhirose Date: Tue, 27 Oct 2026 20:48:03 -0400 Subject: [PATCH 10/10] Trim comments and cover the tunnel map on a redirect Shorten the comments added for the tunnel map, and add a test that a cross-host redirect client keeps sending its CONNECT to the mapped target. --- httplib.h | 13 +++---------- test/test.cc | 45 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/httplib.h b/httplib.h index 19191d7421..baf61a838d 100644 --- a/httplib.h +++ b/httplib.h @@ -3058,8 +3058,7 @@ class ClientImpl { // hostname; only the connection target changes, never the identity. std::map addr_map_; - // Hostname to CONNECT target map, used only through a proxy. Independent of - // addr_map_; the identity (Host, SNI, certificate check) stays the same. + // Hostname to CONNECT target map, used only through a proxy. std::map tunnel_addr_map_; // Default headers @@ -4829,8 +4828,7 @@ class WebSocketClient { // hostname; only the connection target changes, never the identity. std::map addr_map_; - // Hostname to CONNECT target map, used only through a proxy. Independent of - // addr_map_; the identity (Host, SNI, certificate check) stays the same. + // Hostname to CONNECT target map, used only through a proxy. std::map tunnel_addr_map_; std::string proxy_host_; @@ -13036,10 +13034,7 @@ make_host_and_port_string_always_port(const std::string &host, int port) { return prepare_host_string(host) + ":" + std::to_string(port); } -// Request target of a CONNECT for `host`. Through a proxy it is the proxy, not -// this client, that opens the connection to the origin, so a tunnel map -// override has to be carried in the request target. The tunnelled request -// keeps `host` for its Host header and SNI, as a direct connection does. +// Request target of a CONNECT for `host`, with `addr_map` applied. inline std::string make_connect_request_target(const std::map &addr_map, const std::string &host, int port) { @@ -19178,8 +19173,6 @@ inline bool SSLClient::connect_with_proxy( std::chrono::time_point start_time, Response &res, bool &success, Error &error) { success = true; - // A tunnel map entry retargets the CONNECT, and its Host must then name the - // same authority. Without one the request stays exactly as it was. auto connect_target = detail::make_connect_request_target(tunnel_addr_map_, host_, port_); auto retargeted = connect_target != diff --git a/test/test.cc b/test/test.cc index a90bf6ce03..b287a0dc52 100644 --- a/test/test.cc +++ b/test/test.cc @@ -3822,8 +3822,6 @@ TEST(SpecifyServerIPAddressTest, EmptyAddrMapValueIsIgnored) { } TEST(SpecifyServerIPAddressTest, DeprecatedSetHostnameAddrMapStillWorks) { - // set_hostname_addr_map() is a deprecated alias of - // set_direct_hostname_addr_map() and must keep redirecting the connection. auto host = "target.invalid"; Server svr; @@ -3857,9 +3855,7 @@ TEST(SpecifyServerIPAddressTest, DeprecatedSetHostnameAddrMapStillWorks) { } TEST(TunnelHostnameAddrMapTest, IgnoredOnDirectConnection) { - // The tunnel map only rewrites the CONNECT target through a proxy. On a - // direct connection it must not move the socket: the mapped address is - // TEST-NET-1, so applying it would fail to connect. + // 192.0.2.1 is TEST-NET-1: applying the map would fail to connect. Server svr; svr.Get("/hi", [](const Request & /*req*/, Response &res) { res.set_content("Hello World!", "text/plain"); @@ -31391,9 +31387,6 @@ TEST(ProxyTunnelTest, OriginReturning407InsideTunnelDoesNotLeakProxyDigest) { } TEST(ProxyTunnelTest, ConnectTargetFollowsTunnelHostnameAddrMap) { - // Like curl's --connect-to, the mapped address is the CONNECT target and its - // Host. Inside the tunnel the hostname keeps supplying SNI and Host, as it - // does on a direct connection. std::string origin_sni; std::string origin_host; @@ -31469,8 +31462,6 @@ TEST(ProxyTunnelTest, DigestRetryConnectTargetFollowsTunnelHostnameAddrMap) { } TEST(ProxyTunnelTest, DirectHostnameAddrMapLeavesConnectTarget) { - // set_direct_hostname_addr_map() works like curl's --resolve: through a proxy - // it is not used, and the proxy resolves the original hostname itself. proxy_tunnel_test::ScopedSSLServer origin; origin.svr().Get(".*", [&](const Request &, Response &res) { res.set_content("ok", "text/plain"); @@ -31495,6 +31486,40 @@ TEST(ProxyTunnelTest, DirectHostnameAddrMapLeavesConnectTarget) { EXPECT_NE(std::string::npos, connect_req.find("\r\nHost: " + target + "\r\n")); } + +TEST(ProxyTunnelTest, RedirectKeepsTunnelHostnameAddrMap) { + proxy_tunnel_test::ScopedSSLServer origin; + auto port = std::to_string(origin.port()); + origin.svr().Get("/x", [&](const Request &, Response &res) { + res.set_redirect("https://next.example:" + port + "/y"); + }); + origin.svr().Get("/y", [](const Request &, Response &res) { + res.set_content("ok", "text/plain"); + }); + origin.listen(); + + proxy_tunnel_test::ScopedConnectProxy proxy(origin.port()); + ASSERT_NE(0, proxy.port()); + + SSLClient cli("origin.example", origin.port()); + cli.enable_server_certificate_verification(false); + cli.set_tunnel_hostname_addr_map( + {{"origin.example", "127.0.0.1"}, {"next.example", "127.0.0.1"}}); + cli.set_proxy("127.0.0.1", proxy.port()); + cli.set_follow_location(true); + + auto res = cli.Get("/x"); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ("ok", res->body); + + // The last CONNECT is the redirect client's. + ASSERT_EQ(2, proxy.connect_hits()); + auto target = "127.0.0.1:" + port; + auto connect_req = proxy.connect_request(); + EXPECT_EQ(0u, connect_req.find("CONNECT " + target + " HTTP/1.1\r\n")); + EXPECT_NE(std::string::npos, + connect_req.find("\r\nHost: " + target + "\r\n")); +} #endif namespace proxy_tunnel_test {