Add set_tunnel_hostname_addr_map() and deprecate set_hostname_addr_map() (#2647)

Through a CONNECT proxy it is the proxy that connects to the origin, and the CONNECT target was always the original hostname, so there was no way to send the tunnel to another address. set_tunnel_hostname_addr_map() sets that target on SSLClient, Client and WebSocketClient. The CONNECT request's Host follows it, including the Digest retry, while the Host header, the SNI and the certificate check inside the tunnel keep the original hostname. The map is also copied to a redirect client.

set_hostname_addr_map() is deprecated in favor of set_direct_hostname_addr_map(), which behaves the same and names the connection it applies to. The two maps are independent and correspond to curl's --resolve and --connect-to; neither applies to the proxy's own address.
This commit is contained in:
Kim, Hyuk authored and GitHub committed 2026-10-27 21:09:25 -04:00
1 parent 25ba40f6fe
commit 290c4c0bbe
6 files changed
+399 -37

No files matched your search

+289 -20
View File
@@ -3748,7 +3748,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);
@@ -3764,7 +3764,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());
@@ -3795,7 +3795,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());
@@ -3825,7 +3825,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());
@@ -3858,13 +3858,73 @@ 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(SpecifyServerIPAddressTest, DeprecatedSetHostnameAddrMapStillWorks) {
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);
#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());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(std::string(host) + ":" + std::to_string(port), received_host);
}
TEST(TunnelHostnameAddrMapTest, IgnoredOnDirectConnection) {
// 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");
});
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("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);
}
TEST(AbsoluteRedirectTest, Redirect_Online) {
auto host = "httpbingo.org";
auto path = std::string{"/absolute-redirect/3"};
@@ -13347,7 +13407,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);
@@ -16436,7 +16496,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(
@@ -21530,6 +21590,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<std::string, std::string> &addr_map,
const std::string &host, int port);
} // namespace detail
} // namespace httplib
TEST(MakeConnectRequestTargetTest, FollowsHostnameAddrMap) {
std::map<std::string, std::string> 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);
@@ -27908,7 +27998,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());
@@ -27932,7 +28022,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);
@@ -27972,7 +28062,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());
@@ -30961,7 +31051,7 @@ private:
inline std::unique_ptr<Client> make_client(const std::string &host,
ProxyAndTargetServers &s) {
auto cli = detail::make_unique<Client>(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;
}
@@ -31170,7 +31260,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"});
@@ -31187,7 +31277,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"});
@@ -31200,7 +31290,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]"});
@@ -31213,7 +31303,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"});
@@ -31229,7 +31319,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"});
@@ -31445,7 +31535,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"});
@@ -31481,7 +31571,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"});
@@ -31532,7 +31622,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);
@@ -31566,7 +31656,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);
@@ -31898,6 +31988,141 @@ TEST(ProxyTunnelTest, OriginReturning407InsideTunnelDoesNotLeakProxyDigest) {
EXPECT_FALSE(origin_saw_proxy_authz.load());
EXPECT_EQ(1, proxy.connect_hits());
}
TEST(ProxyTunnelTest, ConnectTargetFollowsTunnelHostnameAddrMap) {
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_tunnel_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());
auto target = "127.0.0.1:" + port;
auto connect_req = proxy.connect_request();
EXPECT_EQ(1, proxy.connect_hits());
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, DigestRetryConnectTargetFollowsTunnelHostnameAddrMap) {
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_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");
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.
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, 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, DirectHostnameAddrMapLeavesConnectTarget) {
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_direct_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"));
}
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 {
@@ -32089,6 +32314,50 @@ TEST(WebSocketProxyTest, ProxySettingAppliesOnReconnect) {
EXPECT_EQ(2, proxy.connect_hits());
}
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_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());
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"));
}
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_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());
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;