Skip to content

Apply hostname address mappings to proxy CONNECT targets - #2647

Merged
yhirose merged 10 commits into
yhirose:masterfrom
Hyukya:master
Oct 28, 2026
Merged

yhirose merged 10 commits into
yhirose:masterfrom
Hyukya:master

Conversation

@Hyukya

@Hyukya Hyukya commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Apply set_hostname_addr_map() mappings to proxy CONNECT targets in SSLClient and WebSocketClient.

Use the mapped address for the proxy's connection to the origin, while preserving the original hostname for the Host header and TLS SNI inside the tunnel, matching direct-connection behavior.

Problem

When connecting through a proxy, the proxy—not the client—opens the connection to the origin.

Previously, SSLClient and WebSocketClient built the CONNECT target from host_ alone. As a result, addresses configured with set_hostname_addr_map() took effect on direct connections but were ignored when connecting through a proxy.

This could cause:

  • Tunnel establishment to fail when the proxy cannot resolve the hostname, such as a name used only for SNI.
  • The proxy to connect to a different origin than a direct connection would when the hostname resolves to a different address.

Changes

  • Add detail::make_connect_request_target() to build the CONNECT target using apply_addr_map().
    • Support IPv4, IPv6, and hostname mappings.
    • Preserve the original hostname when the mapping is empty or absent.
    • Format IPv6 targets with brackets.
  • Use the helper for:
    • The initial CONNECT request in SSLClient.
    • The CONNECT retry after a proxy Digest authentication challenge in SSLClient.
    • The CONNECT request in WebSocketClient.
  • Preserve the original hostname for the Host header and TLS SNI inside the tunnel.
  • Document how set_hostname_addr_map() behaves with CONNECT proxies.

For example, with origin.example mapped to 127.0.0.1 on port 443:

Value Before After
CONNECT target origin.example:443 127.0.0.1:443
HTTP Host inside the tunnel origin.example origin.example
TLS SNI origin.example origin.example

For WebSocket connections, the CONNECT request's own Host header uses the mapped CONNECT target. This is separate from the Host header of the request sent inside the tunnel.

Tests

Add regression coverage for:

  • CONNECT target generation:
    • IPv4, IPv6, and hostname mappings.
    • Empty and absent mappings.
    • IPv6 literal formatting.
  • SSLClient:
    • The CONNECT target uses the mapped address.
    • SNI and the Host header inside the tunnel retain the original hostname.
    • The CONNECT retry after a Digest 407 response also uses the mapped address.
  • WebSocketClient:
    • Both the CONNECT target and its Host header use the mapped address.

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.
- 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.
@yhirose

yhirose commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Thanks for the PR. Before going further, I'd like to understand the need.

1. Do you need this now?
Are you hitting this in a real setup today (an SSLClient or WebSocketClient going through a CONNECT proxy while using set_hostname_addr_map())? If so, could you share a short, concrete example? For instance, which proxy, what the mapping looks like, and what fails without this change.

2. Existing behavior can't change
Today set_hostname_addr_map() only affects the client's own connection, and the CONNECT target is always the original hostname. Some users may rely on that, for example by mapping a name to an address that only the client can reach while the proxy resolves the name itself. So making the existing method also rewrite the CONNECT target is not something I can accept.

3. If we add it, this is the shape I have in mind
Keep the two cases separate and explicit, with a consistent naming scheme:

  • set_direct_hostname_addr_map(): the address the client connects to directly (what set_hostname_addr_map() does today, which keeps working unchanged).
  • set_tunnel_hostname_addr_map(): the address the proxy is asked to connect to in the CONNECT request. SNI and the tunnelled Host header keep the original hostname.

This is similar to how curl separates --resolve and --connect-to.

If we go this way, one more thing to cover: in SSLClient the CONNECT request's Host header is built from host_, so with a mapped target it would read CONNECT 127.0.0.1:443 with Host: origin.example. It should match the request target, as WebSocketClient does in this PR, and the test should check the Host header too.

Let me know about the use case first, and we can go from there.

@Hyukya

Hyukya commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review!

1. Yes, I need it. I have to set the SNI separately from the server address, and now need the same connection to go through a proxy. In curl terms:

# works today, without a proxy
curl --connect-to sni.example:443:server.example:443 https://sni.example/

# what I need: the same, through a proxy
curl -x http://proxy.example:3128 --connect-to sni.example:443:server.example:443 https://sni.example/

The first one maps to set_hostname_addr_map() in httplib, but there's no way to do the second: the CONNECT goes out as sni.example:443, which the proxy can't resolve.

2. You're right, my mistake. set_hostname_addr_map() is implemented and works like --resolve, and I wrongly assumed it was --connect-to.

3. Your proposal sounds good. Following the curl --resolve / --connect-to split you mentioned, I tried naming the setters after the curl options to see how it would look. Could you check whether this matches what you had in mind?

  • set_resolve_map(): the --resolve role. An alias of set_hostname_addr_map(), with the same behavior as today.
  • set_connect_to_map(): a new setter for the --connect-to role.
    • Direct connection: it changes where the client connects. Like curl, it applies first, and set_resolve_map() then applies to the resulting name.
    • Through a proxy: it becomes the CONNECT target, and the CONNECT Host matches that target.
    • SNI, the tunnelled Host and certificate verification keep the original hostname.
  • Added to SSLClient, Client and WebSocketClient. Tests check the CONNECT target and its Host and the SNI, and that set_resolve_map() alone still sends the same CONNECT as today.

The calls line up one-to-one with curl.

My case (always with a custom SNI). Without a proxy:

curl --connect-to sni.example:443:server.example:443 https://sni.example/
httplib::SSLClient cli("sni.example", 443);
cli.set_connect_to_map({{"sni.example", "server.example"}});
// connects to server.example, SNI sni.example

Through a proxy, only the proxy setting is added:

curl -x http://proxy.example:3128 --connect-to sni.example:443:server.example:443 https://sni.example/
httplib::SSLClient cli("sni.example", 443);
cli.set_connect_to_map({{"sni.example", "server.example"}});
cli.set_proxy("proxy.example", 3128);
// CONNECT server.example:443, SNI sni.example

The users you mentioned (the proxy resolves the original name). Nothing changes for them:

curl -x http://proxy.example:3128 --resolve origin.example:443:10.0.0.5 https://origin.example/
httplib::SSLClient cli("origin.example", 443);
cli.set_proxy("proxy.example", 3128);
cli.set_resolve_map({{"origin.example", "10.0.0.5"}}); // = set_hostname_addr_map()
// CONNECT origin.example:443 (the mapping is not used)

Different addresses per path (10.0.0.5 for direct, y.example through a proxy). As in curl, --connect-to applies first, --resolve applies to the resulting name, and --resolve is not used through a proxy:

curl --connect-to origin.example:443:y.example:443 --resolve y.example:443:10.0.0.5 https://origin.example/
httplib::SSLClient cli("origin.example", 443);
cli.set_connect_to_map({{"origin.example", "y.example"}});
cli.set_resolve_map({{"y.example", "10.0.0.5"}});      // = set_hostname_addr_map()
// direct: connects to 10.0.0.5 / with set_proxy(): CONNECT y.example:443

Let me know if I've misread anything. Going with your set_direct_hostname_addr_map() / set_tunnel_hostname_addr_map() as is works for me too, and I'll rework the PR in whichever direction you prefer.

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.
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.
- 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.
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.
@yhirose

yhirose commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks for working through the options and for the curl comparison, that made the trade-off easy to see.

After looking at both, I'd like to go with the explicit naming:

  • 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.

Each name says exactly when it applies, and the two maps are independent, so there is no ordering rule between them and the docs stay short. To get the same target on both paths, set both. A one-line note in the README that they correspond to curl's --resolve and --connect-to should be enough for curl users.

Since the old name doesn't say which path it applies to (which is how the --connect-to mix-up happened in the first place), I'd also like to deprecate it in the same change: set_hostname_addr_map() stays as a thin wrapper around set_direct_hostname_addr_map() with no change in behavior, marked [[deprecated("Use set_direct_hostname_addr_map() instead.")]].

Could you rework the PR along these lines? Most of what you have carries over:

  • Replace set_resolve_map() / set_connect_to_map() with the two setters above on SSLClient, Client and WebSocketClient, and drop the combined connect-to-then-resolve helper. The tunnel map goes straight to the CONNECT target builder.
  • Deprecate set_hostname_addr_map() as above, and switch the existing uses in the tests, README, README-websocket and docs-src to set_direct_hostname_addr_map(). Keep one test that calls the old name to pin that it still works, wrapped in #pragma GCC diagnostic ignored "-Wdeprecated-declarations" as earlier deprecated-API tests did.
  • Keep the rest as it is: the CONNECT Host matching the request target (including the Digest retry), copying the map to the redirect client, and the tests that pin the direct map leaving the CONNECT target unchanged.

Let me know if anything here doesn't work for you.

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.
@Hyukya

Hyukya commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks! I’ve reworked the PR as you suggested.

One thing I’d like to raise: with a proxy set, “the address the client connects to directly” could be read as the proxy server. But set_direct_hostname_addr_map(), like set_hostname_addr_map() before it, is applied only to the request host, never to the proxy address.

In curl, --resolve also applies to the proxy address given with --proxy. For example, curl --resolve proxy.example:3128:127.0.0.1 --proxy http://proxy.example:3128 https://example.com/ connects to the proxy at 127.0.0.1.

So the name set_direct_hostname_addr_map() may suggest that it also covers the proxy address, when it doesn’t. A --resolve equivalent for the proxy address would need to be a separate function. What do you think?

@yhirose

yhirose commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Good catch, thank you. "Direct" is meant in the proxy sense (as in a PAC file's DIRECT): a connection that does not go through a proxy. So neither map applies to the proxy address, and I'd rather not add a setter for it, since passing an IP literal to set_proxy() already covers that.

Could you add a sentence to the README saying so?

@Hyukya

Hyukya commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Added the README note as you suggested. Thanks!

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.
@yhirose
yhirose merged commit 290c4c0 into yhirose:master Oct 28, 2026
27 of 28 checks passed
@yhirose

yhirose commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks for your excellent contribution!

@Hyukya

Hyukya commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

@AhnLab-OSS @AhnLab-OSSG

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants