From fb36bc9c70e7b2680a08129777de07d86813c814 Mon Sep 17 00:00:00 2001 From: Giorgos Gaganis Date: Sun, 21 Jun 2026 17:41:01 +0200 Subject: [PATCH 1/8] Add https proxy support Closes: #6207 --- vertx-core/src/main/asciidoc/http.adoc | 8 +- vertx-core/src/main/asciidoc/tcp.adoc | 10 +- .../vertx/core/net/ProxyOptionsConverter.java | 8 + .../io/vertx/core/http/impl/EndpointKey.java | 5 +- .../vertx/core/http/impl/HttpClientImpl.java | 9 +- .../core/http/impl/HttpClientRequestImpl.java | 2 +- .../core/http/impl/HttpConnectParams.java | 12 + .../impl/http1/Http1ClientConnection.java | 5 + .../http/impl/tcp/TcpHttpClientTransport.java | 57 +++- .../java/io/vertx/core/net/ProxyOptions.java | 30 ++ .../java/io/vertx/core/net/ProxyType.java | 10 + .../vertx/core/net/impl/VertxConnection.java | 4 +- .../core/net/impl/tcp/ChannelProvider.java | 97 +++++-- .../core/net/impl/tcp/NetClientImpl.java | 2 +- .../core/net/impl/tcp/NetSocketImpl.java | 21 +- .../java/io/vertx/test/proxy/HttpProxy.java | 33 +++ .../io/vertx/tests/http/HttpsProxyTest.java | 274 ++++++++++++++++++ .../test/java/io/vertx/tests/net/NetTest.java | 54 ++++ .../io/vertx/tests/net/ProxyOptionsTest.java | 26 ++ 19 files changed, 618 insertions(+), 49 deletions(-) create mode 100644 vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java diff --git a/vertx-core/src/main/asciidoc/http.adoc b/vertx-core/src/main/asciidoc/http.adoc index a4c289abf8a..304199bbb13 100644 --- a/vertx-core/src/main/asciidoc/http.adoc +++ b/vertx-core/src/main/asciidoc/http.adoc @@ -2407,7 +2407,8 @@ used with binary frames that are no split over multiple frames. === Using a proxy for HTTP/HTTPS connections -The {@link io.vertx.core.http.HttpClient} supports accessing HTTP/HTTPS URLs via an HTTP proxy (e.g. Squid), a _SOCKS4a_, or a _SOCKS5_ proxy. +The {@link io.vertx.core.http.HttpClient} supports accessing HTTP/HTTPS URLs via an HTTP proxy (e.g. Squid), an HTTPS proxy +(an HTTP proxy reached over SSL/TLS), a _SOCKS4a_, or a _SOCKS5_ proxy. The `CONNECT` protocol uses HTTP/1.x but can connect to HTTP/1.x and HTTP/2 servers. Connecting to `h2c` (unencrypted HTTP/2 servers) is likely not supported by http proxies since they will support HTTP/1.1 only. @@ -2426,6 +2427,11 @@ When the client connects to an HTTP URL, it connects to the proxy server and pro When the client connects to an HTTPS URL, it asks the proxy to create a tunnel to the remote host with the `CONNECT` method. +To connect to the proxy itself over SSL/TLS, set the proxy type to {@link io.vertx.core.net.ProxyType#HTTPS} and configure +the proxy SSL options with {@link io.vertx.core.net.ProxyOptions#setSslOptions(io.vertx.core.net.ClientSSLOptions)}. The +proxy SSL options are independent of the target server's SSL options, and hostname verification of the proxy certificate is +enabled by default. + For a SOCKS5 proxy: [source,$lang] diff --git a/vertx-core/src/main/asciidoc/tcp.adoc b/vertx-core/src/main/asciidoc/tcp.adoc index 96b1c9dd9d6..d8e8590663c 100644 --- a/vertx-core/src/main/asciidoc/tcp.adoc +++ b/vertx-core/src/main/asciidoc/tcp.adoc @@ -506,11 +506,19 @@ the update. === Using a proxy for client connections -The {@link io.vertx.core.net.NetClient} supports either an HTTP/1.x _CONNECT_, _SOCKS4a_ or _SOCKS5_ proxy. +The {@link io.vertx.core.net.NetClient} supports either an HTTP/1.x _CONNECT_, an HTTPS (HTTP _CONNECT_ over SSL/TLS), +_SOCKS4a_ or _SOCKS5_ proxy. The proxy can be configured in the {@link io.vertx.core.net.TcpClientConfig} by setting a {@link io.vertx.core.net.ProxyOptions} object containing proxy type, hostname, port and optionally username and password. +To reach the proxy itself over SSL/TLS, use the {@link io.vertx.core.net.ProxyType#HTTPS} proxy type and configure the +SSL options for the proxy connection with {@link io.vertx.core.net.ProxyOptions#setSslOptions(io.vertx.core.net.ClientSSLOptions)}. +These options (trust store, optional client certificate, hostname verification) apply to the connection to the proxy and +are independent of the options used for the target server. Hostname verification of the proxy certificate is enabled by +default. Note that {@link io.vertx.core.net.ProxyType#HTTPS} denotes a proxy that is itself reached over SSL/TLS, which is +distinct from the `https_proxy` environment-variable convention of a proxy used for `https` traffic. + Here's an example: [source,$lang] diff --git a/vertx-core/src/main/generated/io/vertx/core/net/ProxyOptionsConverter.java b/vertx-core/src/main/generated/io/vertx/core/net/ProxyOptionsConverter.java index 9a1e5d37ce1..3ce716c6af9 100644 --- a/vertx-core/src/main/generated/io/vertx/core/net/ProxyOptionsConverter.java +++ b/vertx-core/src/main/generated/io/vertx/core/net/ProxyOptionsConverter.java @@ -47,6 +47,11 @@ static void fromJson(Iterable> json, ProxyOp obj.setConnectTimeout(java.time.Duration.of(((Number)member.getValue()).longValue(), java.time.temporal.ChronoUnit.MILLIS)); } break; + case "sslOptions": + if (member.getValue() instanceof JsonObject) { + obj.setSslOptions(new io.vertx.core.net.ClientSSLOptions((io.vertx.core.json.JsonObject)member.getValue())); + } + break; } } } @@ -75,5 +80,8 @@ static void toJson(ProxyOptions obj, java.util.Map json) { if (obj.getConnectTimeout() != null) { json.put("connectTimeout", obj.getConnectTimeout().toMillis()); } + if (obj.getSslOptions() != null) { + json.put("sslOptions", obj.getSslOptions().toJson()); + } } } diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/EndpointKey.java b/vertx-core/src/main/java/io/vertx/core/http/impl/EndpointKey.java index 9ab8316b753..d955ff9edb6 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/EndpointKey.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/EndpointKey.java @@ -79,14 +79,15 @@ private static boolean equals(ProxyOptions options1, ProxyOptions options2) { Objects.equals(options1.getType(), options2.getType()) && Objects.equals(options1.getUsername(), options2.getUsername()) && Objects.equals(options1.getPassword(), options2.getPassword()) && - Objects.equals(options1.getProxyAuthorization(), options2.getProxyAuthorization()); + Objects.equals(options1.getProxyAuthorization(), options2.getProxyAuthorization()) && + Objects.equals(options1.getSslOptions(), options2.getSslOptions()); } return false; } private static int hashCode(ProxyOptions options) { return Objects.hash(options.getHost(), options.getPort(), options.getType(), options.getUsername(), - options.getPassword(), options.getProxyAuthorization()); + options.getPassword(), options.getProxyAuthorization(), options.getSslOptions()); } @Override diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/HttpClientImpl.java b/vertx-core/src/main/java/io/vertx/core/http/impl/HttpClientImpl.java index 9ec95bef2a7..1df9334bca9 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/HttpClientImpl.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/HttpClientImpl.java @@ -191,10 +191,13 @@ private Function httpEndpointProvi PoolMetrics poolMetrics = HttpClientImpl.this.httpMetrics != null ? vertx.metrics().createPoolMetrics("http", key.authority.toString(), maxPoolSize) : null; ProxyOptions proxyOptions = key.proxyOptions; ClientSSLOptions sslOptions = key.sslOptions; - if (proxyOptions != null && !key.ssl && proxyOptions.getType() == ProxyType.HTTP) { + boolean forwardProxy = false; + if (proxyOptions != null && !key.ssl && (proxyOptions.getType() == ProxyType.HTTP || proxyOptions.getType() == ProxyType.HTTPS)) { SocketAddress server = SocketAddress.inetSocketAddress(proxyOptions.getPort(), proxyOptions.getHost()); key = new EndpointKey(key.ssl, key.protocol, sslOptions, proxyOptions, server, key.authority); - proxyOptions = null; + // Forward mode: the single socket connects to the proxy as the server while the logical + // origin stays plain HTTP (key.ssl == false). + forwardProxy = true; } HttpVersion protocol = key.protocol; List protocols; @@ -203,7 +206,7 @@ private Function httpEndpointProvi } else { protocols = List.of(protocol); } - HttpConnectParams params = new HttpConnectParams(protocols, sslOptions, proxyOptions, key.ssl); + HttpConnectParams params = new HttpConnectParams(protocols, sslOptions, proxyOptions, key.ssl, forwardProxy); Function p = group -> { int queueMaxSize = poolOptions.getMaxWaitQueueSize(); if (transport instanceof TcpHttpClientTransport) { diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/HttpClientRequestImpl.java b/vertx-core/src/main/java/io/vertx/core/http/impl/HttpClientRequestImpl.java index 42a9795caf9..a188c6eafed 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/HttpClientRequestImpl.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/HttpClientRequestImpl.java @@ -89,7 +89,7 @@ public void init(RequestOptions options) { Boolean followRedirects = options.getFollowRedirects(); long idleTimeout = options.getIdleTimeout(); ProxyOptions proxyOptions = options.getProxyOptions(); - if (proxyOptions != null && !useSSL && proxyOptions.getType() == ProxyType.HTTP) { + if (proxyOptions != null && !useSSL && (proxyOptions.getType() == ProxyType.HTTP || proxyOptions.getType() == ProxyType.HTTPS)) { HostAndPort authority = conn.authority(); if (!ABS_URI_START_PATTERN.matcher(requestURI).find()) { int defaultPort = 80; diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/HttpConnectParams.java b/vertx-core/src/main/java/io/vertx/core/http/impl/HttpConnectParams.java index 807563b3d65..7f6bde92c4c 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/HttpConnectParams.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/HttpConnectParams.java @@ -12,15 +12,27 @@ public HttpConnectParams(List protocols, ClientSSLOptions sslOptions, ProxyOptions proxyOptions, boolean ssl) { + this(protocols, sslOptions, proxyOptions, ssl, false); + } + + public HttpConnectParams(List protocols, + ClientSSLOptions sslOptions, + ProxyOptions proxyOptions, + boolean ssl, + boolean forwardProxy) { this.protocols = protocols; this.sslOptions = sslOptions; this.proxyOptions = proxyOptions; this.ssl = ssl; + this.forwardProxy = forwardProxy; } public final List protocols; public final ClientSSLOptions sslOptions; public final ProxyOptions proxyOptions; + public final boolean ssl; + public final boolean forwardProxy; + } diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java b/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java index eb9bb3b52c0..b94c469c5e1 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java @@ -149,6 +149,11 @@ public HostAndPort authority() { return authority; } + @Override + public boolean isSsl() { + return ssl; + } + @Override public io.vertx.core.http.impl.HttpClientConnection evictionHandler(Handler handler) { evictionHandler = handler; diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/tcp/TcpHttpClientTransport.java b/vertx-core/src/main/java/io/vertx/core/http/impl/tcp/TcpHttpClientTransport.java index 8ac09b26cd4..b143e3cccde 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/tcp/TcpHttpClientTransport.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/tcp/TcpHttpClientTransport.java @@ -143,26 +143,57 @@ private Http2ClientChannelInitializer http2Initializer() { } } + /** + * Whether this connection's transport TLS terminates at an HTTPS proxy (forward mode through an + * HTTPS proxy) — the only forward case that uses leg-1 TLS to the proxy. + */ + private static boolean proxyHttps(HttpConnectParams params) { + return params.forwardProxy && params.proxyOptions != null && params.proxyOptions.getType() == ProxyType.HTTPS; + } + + /** Whether the socket is encrypted: origin TLS, or leg-1 TLS to an HTTPS proxy (forward mode). */ + private static boolean transportSsl(HttpConnectParams params) { + return params.ssl || proxyHttps(params); + } + private void connect(ContextInternal context, HttpConnectParams params, HostAndPort authority, SocketAddress server, Promise promise) { + boolean forward = params.forwardProxy; + boolean proxyHttps = proxyHttps(params); + boolean transportSsl = transportSsl(params); + ConnectOptions connectOptions = new ConnectOptions(); connectOptions.setRemoteAddress(server); - if (authority != null) { + if (proxyHttps) { + // Forward mode through an HTTPS proxy: the socket's TLS terminates at the proxy, so the peer / + // SNI is the proxy (the server), not the origin authority. + connectOptions.setHost(server.host()); + connectOptions.setPort(server.port()); + connectOptions.setSniServerName(server.host()); + } else if (authority != null) { connectOptions.setHost(authority.host()); connectOptions.setPort(authority.port()); - if (params.ssl && forceSni) { + if (transportSsl && forceSni) { connectOptions.setSniServerName(authority.host()); } } - connectOptions.setSsl(params.ssl); - if (params.ssl) { + connectOptions.setSsl(transportSsl); + if (transportSsl) { + // The TLS being configured terminates at the proxy (forward + HTTPS proxy) or at the origin + // (direct, or the leg-2 of a CONNECT tunnel); pick the matching options. + ClientSSLOptions transportSslOptions = proxyHttps ? params.proxyOptions.getSslOptions() : params.sslOptions; ClientSSLOptions copy; - if (params.sslOptions != null) { - copy = params.sslOptions.copy(); + if (transportSslOptions != null) { + copy = transportSslOptions.copy(); } else { // We might end up using javax.net.ssl.trustStore copy = new ClientSSLOptions().setHostnameVerificationAlgorithm("HTTPS"); } - boolean useAlpn = params.protocols.contains(HttpVersion.HTTP_2); + if (proxyHttps && copy.getHostnameVerificationAlgorithm() == null) { + // Verify the proxy certificate against the proxy host by default (secure default). + copy.setHostnameVerificationAlgorithm("HTTPS"); + } + // No ALPN on a leg-1 TLS connection to the proxy: ALPN belongs to the origin (leg 2). + boolean useAlpn = !proxyHttps && params.protocols.contains(HttpVersion.HTTP_2); copy.setUseAlpn(useAlpn); if (useAlpn) { List list = params.protocols @@ -174,7 +205,9 @@ private void connect(ContextInternal context, HttpConnectParams params, HostAndP } connectOptions.setSslOptions(copy); } - connectOptions.setProxyOptions(params.proxyOptions); + // Only CONNECT/SOCKS-tunnel through the proxy when NOT forwarding; in forward mode the socket + // connects straight to the proxy (the server) and issues absolute-URI requests. + connectOptions.setProxyOptions(forward ? null : params.proxyOptions); client.connectInternal(connectOptions, promise, context); } @@ -197,7 +230,11 @@ public Future wrap(ContextInternal context, HttpConnectPar // Channel ch = so.channelHandlerContext().channel(); - if (params.ssl) { + // Transport TLS may be on because the origin is HTTPS or because leg-1 TLS to an HTTPS proxy is + // in use (forward mode). The connection's ssl flag, however, must reflect the logical origin + // (params.ssl), not the transport. + boolean transportSsl = transportSsl(params); + if (transportSsl) { String protocol = so.applicationLayerProtocol(); if (protocol == null) { protocol = ""; @@ -218,7 +255,7 @@ public Future wrap(ContextInternal context, HttpConnectPar if (http1Config != null) { applyHttp1xConnectionOptions(ch.pipeline()); HttpVersion version = "http/1.0".equals(protocol) ? HttpVersion.HTTP_1_0 : HttpVersion.HTTP_1_1; - http1xConnected(version, server, authority, true, context, transportMetrics, metric, ch, clientMetrics, promise); + http1xConnected(version, server, authority, params.ssl, context, transportMetrics, metric, ch, clientMetrics, promise); } else { so.close(); promise.tryFail(new IllegalStateException("HTTP/1.1 not supported")); diff --git a/vertx-core/src/main/java/io/vertx/core/net/ProxyOptions.java b/vertx-core/src/main/java/io/vertx/core/net/ProxyOptions.java index 0174491d177..eb32f29c661 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/ProxyOptions.java +++ b/vertx-core/src/main/java/io/vertx/core/net/ProxyOptions.java @@ -56,6 +56,7 @@ public class ProxyOptions { private String proxyAuthorization; private ProxyType type; private Duration connectTimeout; + private ClientSSLOptions sslOptions; /** * Default constructor. @@ -80,6 +81,7 @@ public ProxyOptions(ProxyOptions other) { proxyAuthorization = other.getProxyAuthorization(); type = other.getType(); connectTimeout = other.getConnectTimeout(); + sslOptions = other.sslOptions != null ? other.sslOptions.copy() : null; } /** @@ -260,4 +262,32 @@ public ProxyOptions setConnectTimeout(Duration connectTimeout) { this.connectTimeout = connectTimeout; return this; } + + /** + * Get the SSL options used for the connection to the proxy itself. + *

+ * Only relevant when {@link #getType()} is {@link ProxyType#HTTPS}. + * + * @return the proxy SSL options, or {@code null} + */ + public ClientSSLOptions getSslOptions() { + return sslOptions; + } + + /** + * Set the SSL options used for the connection to the proxy itself, when the proxy is reached over + * SSL/TLS ({@link ProxyType#HTTPS}). + *

+ * These options are resolved independently of the origin SSL options, so the proxy presents and + * is validated against its own certificate and trust store. When left unset for an + * {@link ProxyType#HTTPS} proxy, the connection is still established over SSL/TLS using the default + * trust source, with hostname verification enabled against the proxy host. + * + * @param sslOptions the proxy SSL options + * @return a reference to this, so the API can be used fluently + */ + public ProxyOptions setSslOptions(ClientSSLOptions sslOptions) { + this.sslOptions = sslOptions; + return this; + } } diff --git a/vertx-core/src/main/java/io/vertx/core/net/ProxyType.java b/vertx-core/src/main/java/io/vertx/core/net/ProxyType.java index f71fa3ae3e2..87f537674f8 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/ProxyType.java +++ b/vertx-core/src/main/java/io/vertx/core/net/ProxyType.java @@ -24,6 +24,16 @@ public enum ProxyType { * HTTP CONNECT ssl proxy */ HTTP, + /** + * HTTP proxy reached over SSL/TLS, i.e. the client opens an {@code https} connection to the proxy + * itself before issuing {@code CONNECT} or absolute-URI requests. The proxying semantics are + * identical to {@link #HTTP}; only the connection to the proxy is encrypted, configured via + * {@link ProxyOptions#setSslOptions(ClientSSLOptions)}. + *

+ * Note this is distinct from the {@code https_proxy} environment-variable convention, which + * denotes the proxy used for {@code https} traffic rather than a proxy reached over SSL/TLS. + */ + HTTPS, /** * SOCKS4/4a tcp proxy */ diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/VertxConnection.java b/vertx-core/src/main/java/io/vertx/core/net/impl/VertxConnection.java index 30bcdefa0f1..cb946abddbf 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/VertxConnection.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/VertxConnection.java @@ -12,6 +12,7 @@ import io.netty.buffer.Unpooled; import io.netty.channel.*; +import io.netty.handler.ssl.SslHandler; import io.netty.handler.timeout.IdleStateEvent; import io.netty.util.ReferenceCountUtil; import io.netty.util.ReferenceCounted; @@ -141,7 +142,8 @@ protected void handleIdle(IdleStateEvent event) { } protected boolean supportsFileRegion() { - return vertx.transport().supportFileRegion() && !isSsl() &&!isTrafficShaped(); + boolean sslChannel = chctx.pipeline().get(SslHandler.class) != null; + return vertx.transport().supportFileRegion() && !sslChannel && !isTrafficShaped(); } /** diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/ChannelProvider.java b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/ChannelProvider.java index f8bc0caf0b3..5ae359e98fd 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/ChannelProvider.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/ChannelProvider.java @@ -34,6 +34,7 @@ import io.vertx.core.internal.ContextInternal; import io.vertx.core.internal.VertxInternal; import io.vertx.core.internal.net.SslChannelProvider; +import io.vertx.core.internal.tls.ClientSslContextManager; import io.vertx.core.internal.tls.SslContextProvider; import io.vertx.core.net.ClientSSLOptions; import io.vertx.core.net.HostAndPort; @@ -58,16 +59,19 @@ public final class ChannelProvider { private final Bootstrap bootstrap; private final SslContextProvider sslContextProvider; + private final ClientSslContextManager sslContextManager; private final ContextInternal context; private ProxyOptions proxyOptions; private String applicationProtocol; public ChannelProvider(Bootstrap bootstrap, SslContextProvider sslContextProvider, + ClientSslContextManager sslContextManager, ContextInternal context) { this.bootstrap = bootstrap; this.context = context; this.sslContextProvider = sslContextProvider; + this.sslContextManager = sslContextManager; } /** @@ -202,6 +206,7 @@ private void handleProxyConnect(SocketAddress remoteAddress, HostAndPort peerAdd switch (proxyType) { default: case HTTP: + case HTTPS: proxy = createHttpProxyHandler(proxyAddr, proxyUsername, proxyPassword, proxyAuthorization); break; case SOCKS5: @@ -222,36 +227,63 @@ private void handleProxyConnect(SocketAddress remoteAddress, HostAndPort peerAdd bootstrap.resolver(NoopAddressResolverGroup.INSTANCE); java.net.SocketAddress targetAddress = vertx.transport().convert(remoteAddress); - bootstrap.handler(new ChannelInitializer() { - @Override - protected void initChannel(Channel ch) throws Exception { - ChannelPipeline pipeline = ch.pipeline(); - pipeline.addFirst("proxy", proxy); - pipeline.addLast(new ChannelInboundHandlerAdapter() { - @Override - public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { - if (evt instanceof ProxyConnectionEvent) { - pipeline.remove(proxy); - pipeline.remove(this); - initSSL(peerAddress, serverName, ssl, sslOptions, ch, channelHandler); - connected(ch, ssl, channelHandler); + final ClientSSLOptions proxySslOptions; + final io.vertx.core.Future proxySslProviderFuture; + if (proxyType == ProxyType.HTTPS) { + proxySslOptions = resolveProxySslOptions(); + proxySslProviderFuture = sslContextManager.resolveSslContextProvider(proxySslOptions, context) + .map(p -> new SslChannelProvider(vertx, p, false, proxySslOptions.isUseHybridKeyExchangeProtocol())); + } else { + proxySslOptions = null; + proxySslProviderFuture = context.succeededFuture(null); + } + + proxySslProviderFuture.onComplete(ar -> { + if (ar.failed()) { + channelHandler.setFailure(ar.cause()); + return; + } + SslChannelProvider proxySslChannelProvider = ar.result(); + bootstrap.handler(new ChannelInitializer() { + @Override + protected void initChannel(Channel ch) { + ChannelPipeline pipeline = ch.pipeline(); + ChannelInboundHandlerAdapter proxyConnected = new ChannelInboundHandlerAdapter() { + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { + if (evt instanceof ProxyConnectionEvent) { + pipeline.remove(proxy); + pipeline.remove(this); + initSSL(peerAddress, serverName, ssl, sslOptions, ch, channelHandler); + connected(ch, ssl, channelHandler); + } + ctx.fireUserEventTriggered(evt); } - ctx.fireUserEventTriggered(evt); - } - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { - channelHandler.setFailure(cause); + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + channelHandler.setFailure(cause); + } + }; + if (proxySslChannelProvider != null) { + SslHandler proxySslHandler = proxySslChannelProvider.createClientSslHandler( + HostAndPort.create(proxyHost, proxyPort), proxyHost, null, + proxySslOptions.getSslHandshakeTimeout(), proxySslOptions.getSslHandshakeTimeoutUnit()); + pipeline.addFirst("proxy-ssl", proxySslHandler); + pipeline.addAfter("proxy-ssl", "proxy", proxy); + } else { + pipeline.addFirst("proxy", proxy); } - }); - } - }); - ChannelFuture future = bootstrap.connect(targetAddress); + pipeline.addLast(proxyConnected); + } + }); + ChannelFuture future = bootstrap.connect(targetAddress); - future.addListener(res -> { - if (!res.isSuccess()) { - channelHandler.setFailure(res.cause()); - } + future.addListener(res -> { + if (!res.isSuccess()) { + channelHandler.setFailure(res.cause()); + } + }); }); } else { channelHandler.setFailure(dnsRes.cause()); @@ -259,6 +291,19 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { }); } + + private ClientSSLOptions resolveProxySslOptions() { + ClientSSLOptions proxySslOptions = proxyOptions.getSslOptions(); + proxySslOptions = proxySslOptions != null ? proxySslOptions.copy() : new ClientSSLOptions(); + if (proxySslOptions.getHostnameVerificationAlgorithm() == null) { + proxySslOptions.setHostnameVerificationAlgorithm("HTTPS"); + } + // ALPN is negotiated for the origin (leg 2), never for the proxy connection. + proxySslOptions.setUseAlpn(false); + proxySslOptions.setApplicationLayerProtocols(null); + return proxySslOptions; + } + private static ProxyHandler createHttpProxyHandler(InetSocketAddress proxyAddr, String proxyUsername, String proxyPassword, String proxyAuthorization) { if (proxyAuthorization != null) { diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java index f2d4a9fc9cd..32edfe4237d 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java @@ -354,7 +354,7 @@ private void connectInternal(ConnectOptions connectOptions, } } - ChannelProvider channelProvider = new ChannelProvider(bootstrap, sslContextProvider, context) + ChannelProvider channelProvider = new ChannelProvider(bootstrap, sslContextProvider, sslContextManager, context) .proxyOptions(proxyOptions); SocketAddress captured = remoteAddress; diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetSocketImpl.java b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetSocketImpl.java index fe29c570bb2..4292b1739ca 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetSocketImpl.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetSocketImpl.java @@ -37,6 +37,7 @@ import io.vertx.core.spi.metrics.TransportMetrics; import java.util.List; +import java.util.Map; import java.util.UUID; /** @@ -148,7 +149,7 @@ private Future sslUpgrade(String serverName, SSLOptions sslOptions, ByteBu chctx.channel().config().setAutoRead(true); if (res.isSuccess()) { ChannelPromise channelPromise = chctx.newPromise(); - chctx.pipeline().addFirst("handshaker", new SslHandshakeCompletionHandler(channelPromise)); + SslHandshakeCompletionHandler handshaker = new SslHandshakeCompletionHandler(channelPromise); ChannelHandler sslHandler; if (sslOptions instanceof ClientSSLOptions) { ClientSSLOptions clientSSLOptions = (ClientSSLOptions) sslOptions; @@ -158,7 +159,13 @@ private Future sslUpgrade(String serverName, SSLOptions sslOptions, ByteBu sslHandler = provider.createServerHandler(applicationProtocols, sslOptions.getSslHandshakeTimeout(), sslOptions.getSslHandshakeTimeoutUnit(), HttpUtils.socketAddressToHostAndPort(chctx.channel().remoteAddress())); } - chctx.pipeline().addFirst("ssl", sslHandler); + if (chctx.pipeline().get("proxy-ssl") != null) { + chctx.pipeline().addAfter("proxy-ssl", "ssl", sslHandler); + chctx.pipeline().addAfter("ssl", "handshaker", handshaker); + } else { + chctx.pipeline().addFirst("handshaker", handshaker); + chctx.pipeline().addFirst("ssl", sslHandler); + } channelPromise.addListener(p); doPause(); } else { @@ -177,7 +184,15 @@ private Future sslUpgrade(String serverName, SSLOptions sslOptions, ByteBu @Override public String applicationLayerProtocol() { - SslHandler handler = channel.pipeline().get(SslHandler.class); + // Use the innermost (last) SslHandler: when tunnelling through an HTTPS proxy the pipeline holds + // two SslHandlers (leg-1 to the proxy at the head, leg-2 to the origin after it), and the + // application protocol is negotiated on the origin (leg-2) handshake. + SslHandler handler = null; + for (Map.Entry entry : channel.pipeline()) { + if (entry.getValue() instanceof SslHandler) { + handler = (SslHandler) entry.getValue(); + } + } if (handler != null) { return handler.engine().getApplicationProtocol(); } else { diff --git a/vertx-core/src/test/java/io/vertx/test/proxy/HttpProxy.java b/vertx-core/src/test/java/io/vertx/test/proxy/HttpProxy.java index 3b8cc5aaf51..410cff62ff6 100644 --- a/vertx-core/src/test/java/io/vertx/test/proxy/HttpProxy.java +++ b/vertx-core/src/test/java/io/vertx/test/proxy/HttpProxy.java @@ -17,8 +17,10 @@ import io.vertx.core.http.*; import io.vertx.core.internal.logging.Logger; import io.vertx.core.internal.logging.LoggerFactory; +import io.vertx.core.net.KeyCertOptions; import io.vertx.core.net.NetClient; import io.vertx.core.net.NetSocket; +import io.vertx.core.net.TrustOptions; import io.vertx.test.http.HttpTestBase; import java.net.UnknownHostException; @@ -60,11 +62,36 @@ public class HttpProxy extends ProxyBase { private MultiMap lastRequestHeaders = null; private HttpMethod lastMethod; + private KeyCertOptions serverCert = null; + private TrustOptions clientAuthTrust = null; + @Override public int defaultPort() { return DEFAULT_PORT; } + /** + * Make the proxy itself listen over TLS, so the client establishes an HTTPS connection to the + * proxy (leg 1). This is used to test {@link io.vertx.core.net.ProxyType#HTTPS}. + * + * @param serverCert the key/cert the proxy presents to the client + */ + public HttpProxy ssl(KeyCertOptions serverCert) { + this.serverCert = serverCert; + return this; + } + + /** + * Additionally require and validate a client certificate (mutual TLS on leg 1). Implies + * {@link #ssl(KeyCertOptions)} having been set. + * + * @param clientAuthTrust trust used to validate the client certificate + */ + public HttpProxy requireClientAuth(TrustOptions clientAuthTrust) { + this.clientAuthTrust = clientAuthTrust; + return this; + } + /** * Start the server. * @@ -75,6 +102,12 @@ public int defaultPort() { public HttpProxy start(Vertx vertx) throws Exception { HttpServerOptions options = new HttpServerOptions(); options.setHost("localhost").setPort(port); + if (serverCert != null) { + options.setSsl(true).setKeyCertOptions(serverCert); + if (clientAuthTrust != null) { + options.setClientAuth(ClientAuth.REQUIRED).setTrustOptions(clientAuthTrust); + } + } client = vertx.createNetClient(); server = vertx.createHttpServer(options); server.requestHandler(request -> { diff --git a/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java b/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java new file mode 100644 index 00000000000..c151fba6f19 --- /dev/null +++ b/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java @@ -0,0 +1,274 @@ +/* + * Copyright (c) 2011-2024 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.tests.http; + +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; + +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpClientResponse; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpResponseExpectation; +import io.vertx.core.http.HttpVersion; +import io.vertx.core.http.RequestOptions; +import io.vertx.core.net.ClientSSLOptions; +import io.vertx.core.net.ProxyOptions; +import io.vertx.core.net.ProxyType; +import io.vertx.core.net.SocketAddress; +import io.vertx.test.http.HttpTestBase; +import io.vertx.test.proxy.HttpProxy; +import io.vertx.test.tls.Cert; +import io.vertx.test.tls.Trust; +import org.junit.Test; + +/** + * Tests for {@link ProxyType#HTTPS}: the connection to the proxy itself (leg 1) is established over + * TLS. The proxying semantics (absolute-URI {@code GET} forwarding for plain origins, {@code CONNECT} + * tunnel for TLS origins) are unchanged from {@link ProxyType#HTTP}. + */ +public class HttpsProxyTest extends HttpTestBase { + + private Vertx proxyVertx; + private HttpProxy proxy; + + @Override + public void setUp() throws Exception { + super.setUp(); + // The proxy runs on its own Vertx instance so that closing it in tearDown awaits the release of + // its listen socket. HttpProxy.stop() alone does not wait for the close to complete, which leaks + // the port into the following tests. + proxyVertx = Vertx.vertx(); + } + + @Override + protected void tearDown() throws Exception { + try { + if (proxy != null) { + proxy.stop(); + } + } finally { + if (proxyVertx != null) { + proxyVertx.close().await(); + } + } + super.tearDown(); + } + + private void startHttpOrigin() throws Exception { + server.requestHandler(req -> req.response().end("Hello from origin")); + startServer(); + } + + private void startHttpsOrigin(HttpVersion version) throws Exception { + server = vertx.createHttpServer(createBaseServerOptions() + .setSsl(true) + .setUseAlpn(version == HttpVersion.HTTP_2) + .setKeyCertOptions(Cert.SERVER_JKS.get())); + server.requestHandler(req -> req.response().end("Hello from origin")); + startServer(SocketAddress.inetSocketAddress(DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST)); + } + + /** Default proxy SSL options trusting the proxy's (localhost) certificate. */ + private static ClientSSLOptions trustingProxySsl() { + return new ClientSSLOptions().setTrustOptions(Trust.SERVER_JKS.get()); + } + + private static ProxyOptions httpsProxy(int port) { + return new ProxyOptions() + .setType(ProxyType.HTTPS) + .setHost("localhost") + .setPort(port) + .setSslOptions(trustingProxySsl()); + } + + // --- Happy paths ---------------------------------------------------------- + + @Test + public void testHttpsProxy_HttpOrigin_forwarded() throws Exception { + proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); + proxy.start(proxyVertx); + startHttpOrigin(); + + client.close(); + client = vertx.createHttpClient(new HttpClientOptions().setProxyOptions(httpsProxy(proxy.port()))); + + Buffer body = client.request(new RequestOptions().setMethod(HttpMethod.GET) + .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) + .compose(req -> req.send().expecting(HttpResponseExpectation.SC_OK)) + .compose(HttpClientResponse::body) + .await(); + + assertEquals("Hello from origin", body.toString()); + // Plain origin: the proxy GET-forwards (it sees the absolute URI), it does not CONNECT. + assertEquals(HttpMethod.GET, proxy.getLastMethod()); + assertNotNull(proxy.getLastUri()); + } + + @Test + public void testHttpsProxy_HttpsOrigin_tunnelled() throws Exception { + proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); + proxy.start(proxyVertx); + startHttpsOrigin(HttpVersion.HTTP_1_1); + + client.close(); + client = vertx.createHttpClient(new HttpClientOptions() + .setSsl(true) + .setTrustOptions(Trust.SERVER_JKS.get()) + .setProxyOptions(httpsProxy(proxy.port()))); + + Buffer body = client.request(new RequestOptions().setMethod(HttpMethod.GET) + .setHost(DEFAULT_HTTPS_HOST).setPort(DEFAULT_HTTPS_PORT).setURI("/")) + .compose(req -> req.send().expecting(HttpResponseExpectation.SC_OK)) + .compose(HttpClientResponse::body) + .await(); + + assertEquals("Hello from origin", body.toString()); + // TLS origin: nested TLS through the CONNECT tunnel (the proxy never sees the encrypted request). + assertEquals(HttpMethod.CONNECT, proxy.getLastMethod()); + } + + @Test + public void testHttpsProxy_Http2Origin_tunnelled() throws Exception { + proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); + proxy.start(proxyVertx); + startHttpsOrigin(HttpVersion.HTTP_2); + + client.close(); + client = vertx.createHttpClient(new HttpClientOptions() + .setProtocolVersion(HttpVersion.HTTP_2) + .setSsl(true) + .setUseAlpn(true) + .setTrustOptions(Trust.SERVER_JKS.get()) + .setProxyOptions(httpsProxy(proxy.port()))); + + String result = client.request(new RequestOptions().setMethod(HttpMethod.GET) + .setHost(DEFAULT_HTTPS_HOST).setPort(DEFAULT_HTTPS_PORT).setURI("/")) + .compose(req -> req.send().expecting(HttpResponseExpectation.SC_OK)) + .compose(resp -> resp.body().map(body -> resp.version() + ":" + body)) + .await(); + + // h2 rides the CONNECT tunnel for free; ALPN negotiates h2 end-to-end with the origin. + assertEquals(HttpVersion.HTTP_2 + ":Hello from origin", result); + assertEquals(HttpMethod.CONNECT, proxy.getLastMethod()); + } + + @Test + public void testHttpsProxy_proxyAuthentication() throws Exception { + proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()).username("user1"); + proxy.start(proxyVertx); + startHttpOrigin(); + + client.close(); + client = vertx.createHttpClient(new HttpClientOptions() + .setProxyOptions(httpsProxy(proxy.port()).setUsername("user1").setPassword("user1"))); + + Buffer body = client.request(new RequestOptions().setMethod(HttpMethod.GET) + .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) + .compose(req -> req.send().expecting(HttpResponseExpectation.SC_OK)) + .compose(HttpClientResponse::body) + .await(); + + // SC_OK proves the Basic Proxy-Authorization (sent inside the established leg-1 TLS) was accepted; + // the proxy returns 407 on missing/incorrect credentials. + assertEquals("Hello from origin", body.toString()); + } + + @Test + public void testHttpsProxy_mutualTls() throws Exception { + proxy = new HttpProxy() + .ssl(Cert.SERVER_JKS.get()) + .requireClientAuth(Trust.CLIENT_JKS.get()); + proxy.start(proxyVertx); + startHttpOrigin(); + + client.close(); + client = vertx.createHttpClient(new HttpClientOptions().setProxyOptions(httpsProxy(proxy.port()) + .setSslOptions(new ClientSSLOptions() + .setTrustOptions(Trust.SERVER_JKS.get()) + .setKeyCertOptions(Cert.CLIENT_JKS.get())))); + + Buffer body = client.request(new RequestOptions().setMethod(HttpMethod.GET) + .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) + .compose(req -> req.send().expecting(HttpResponseExpectation.SC_OK)) + .compose(HttpClientResponse::body) + .await(); + + assertEquals("Hello from origin", body.toString()); + } + + // --- Security / failure cases --------------------------------------------- + + @Test + public void testHttpsProxy_untrustedProxyCertRejected() throws Exception { + proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); + proxy.start(proxyVertx); + startHttpOrigin(); + + client.close(); + // No sslOptions => default trust source (no self-signed cert): the proxy cert is not trusted and + // there is no plaintext fallback for an HTTPS proxy. + client = vertx.createHttpClient(new HttpClientOptions() + .setProxyOptions(new ProxyOptions().setType(ProxyType.HTTPS).setHost("localhost").setPort(proxy.port()))); + + try { + client.request(new RequestOptions().setMethod(HttpMethod.GET) + .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) + .compose(req -> req.send()) + .await(); + fail("Expected the connection to the untrusted HTTPS proxy to fail"); + } catch (Exception expected) { + // connect failed rather than hanging or silently downgrading + } + } + + @Test + public void testHttpsProxy_hostnameMismatchRejected() throws Exception { + proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); + proxy.start(proxyVertx); + startHttpOrigin(); + + client.close(); + // Proxy cert is CN=localhost; connecting via 127.0.0.1 must fail hostname verification (on by default). + client = vertx.createHttpClient(new HttpClientOptions() + .setProxyOptions(new ProxyOptions().setType(ProxyType.HTTPS).setHost("127.0.0.1").setPort(proxy.port()) + .setSslOptions(trustingProxySsl()))); + + try { + client.request(new RequestOptions().setMethod(HttpMethod.GET) + .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) + .compose(req -> req.send()) + .await(); + fail("Expected hostname verification against the proxy to fail"); + } catch (Exception expected) { + // verification rejected the mismatched proxy hostname + } + } + + @Test + public void testHttpsProxy_againstPlaintextProxyFailsCleanly() throws Exception { + proxy = new HttpProxy(); // plaintext proxy + proxy.start(proxyVertx); + startHttpOrigin(); + + client.close(); + client = vertx.createHttpClient(new HttpClientOptions().setProxyOptions(httpsProxy(proxy.port()))); + + try { + client.request(new RequestOptions().setMethod(HttpMethod.GET) + .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) + .compose(req -> req.send()) + .await(); + fail("Expected the TLS handshake against a plaintext proxy to fail"); + } catch (Exception expected) { + // no silent downgrade: a HTTPS proxy type never falls back to plaintext + } + } +} diff --git a/vertx-core/src/test/java/io/vertx/tests/net/NetTest.java b/vertx-core/src/test/java/io/vertx/tests/net/NetTest.java index 04262de4635..d0c8b9537d4 100755 --- a/vertx-core/src/test/java/io/vertx/tests/net/NetTest.java +++ b/vertx-core/src/test/java/io/vertx/tests/net/NetTest.java @@ -3143,6 +3143,39 @@ public void testUpgradeSSLWithSocks5Proxy() throws Exception { .await(); } + /** + * Same as {@link #testUpgradeSSLWithSocks5Proxy()} but through an HTTPS proxy: the connection to + * the proxy itself (leg 1) is TLS, and {@code upgradeToSsl} then establishes a second, nested TLS + * to the origin (leg 2). Guards the relative ordering of the two SslHandlers in the pipeline. + */ + @Test + public void testUpgradeSSLWithHttpsConnectProxy() throws Exception { + NetServerOptions options = new NetServerOptions() + .setPort(1234) + .setHost("localhost") + .setSsl(true) + .setKeyCertOptions(Cert.SERVER_JKS_ROOT_CA.get()); + server = vertx.createNetServer(options); + server.connectHandler(sock -> { + + }); + + NetClientOptions clientOptions = new NetClientOptions() + .setHostnameVerificationAlgorithm("HTTPS") + .setProxyOptions(new ProxyOptions().setType(ProxyType.HTTPS).setHost("localhost").setPort(13128) + .setSslOptions(new ClientSSLOptions().setTrustOptions(Trust.SERVER_JKS.get()))) + .setTrustOptions(Trust.SERVER_JKS_ROOT_CA.get()); + client = vertx.createNetClient(clientOptions); + proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); + proxy.start(vertx); + server.listen().await(); + client.connect(1234, "localhost") + .compose(NetSocket::upgradeToSsl) + .await(); + // make sure we have gone through the proxy + assertEquals("localhost:1234", proxy.getLastUri()); + } + /** * test http connect proxy for accessing a arbitrary server port * note that this may not work with a "real" proxy since there are usually access rules defined @@ -3164,6 +3197,27 @@ public void testWithHttpConnectProxy() throws Exception { assertEquals("localhost:1234", proxy.getLastUri()); } + /** + * Same as {@link #testWithHttpConnectProxy()} but the connection to the proxy itself is over TLS + * (an {@link ProxyType#HTTPS} proxy). + */ + @Test + public void testWithHttpsConnectProxy() throws Exception { + NetClientOptions clientOptions = new NetClientOptions() + .setProxyOptions(new ProxyOptions().setType(ProxyType.HTTPS).setHost("localhost").setPort(13128) + .setSslOptions(new ClientSSLOptions().setTrustOptions(Trust.SERVER_JKS.get()))); + NetClient client = vertx.createNetClient(clientOptions); + server.connectHandler(sock -> { + + }); + proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); + proxy.start(vertx); + server.listen(1234, "localhost").await(); + client.connect(1234, "localhost").await(); + // make sure we have gone through the proxy + assertEquals("localhost:1234", proxy.getLastUri()); + } + /** * test socks4a proxy for accessing arbitrary server port. */ diff --git a/vertx-core/src/test/java/io/vertx/tests/net/ProxyOptionsTest.java b/vertx-core/src/test/java/io/vertx/tests/net/ProxyOptionsTest.java index e0314c82d09..81e89cd058b 100644 --- a/vertx-core/src/test/java/io/vertx/tests/net/ProxyOptionsTest.java +++ b/vertx-core/src/test/java/io/vertx/tests/net/ProxyOptionsTest.java @@ -12,6 +12,7 @@ package io.vertx.tests.net; import io.vertx.core.json.JsonObject; +import io.vertx.core.net.ClientSSLOptions; import io.vertx.core.net.ProxyOptions; import io.vertx.core.net.ProxyType; import io.vertx.test.core.TestUtils; @@ -75,6 +76,31 @@ public void testProxyOptions() { assertEquals(null, options.getProxyAuthorization()); assertEquals(options, options.setProxyAuthorization(randProxyAuthorization)); assertEquals(randProxyAuthorization, options.getProxyAuthorization()); + + assertEquals(null, options.getSslOptions()); + ClientSSLOptions sslOptions = new ClientSSLOptions(); + assertEquals(options, options.setSslOptions(sslOptions)); + assertSame(sslOptions, options.getSslOptions()); + } + + @Test + public void testCopyAndJsonSslOptions() { + ProxyOptions options = new ProxyOptions() + .setType(ProxyType.HTTPS) + .setSslOptions(new ClientSSLOptions().setHostnameVerificationAlgorithm("HTTPS")); + + // copy + ProxyOptions copy = new ProxyOptions(options); + assertEquals(ProxyType.HTTPS, copy.getType()); + assertNotNull(copy.getSslOptions()); + assertNotSame(options.getSslOptions(), copy.getSslOptions()); + assertEquals("HTTPS", copy.getSslOptions().getHostnameVerificationAlgorithm()); + + // json round-trip + ProxyOptions fromJson = new ProxyOptions(options.toJson()); + assertEquals(ProxyType.HTTPS, fromJson.getType()); + assertNotNull(fromJson.getSslOptions()); + assertEquals("HTTPS", fromJson.getSslOptions().getHostnameVerificationAlgorithm()); } @Test From 8ae9b167c72fcc2cf248a3d6a65aeec943b90d14 Mon Sep 17 00:00:00 2001 From: Giorgos Gaganis Date: Wed, 1 Jul 2026 11:03:26 +0200 Subject: [PATCH 2/8] Update vertx-core/src/main/java/io/vertx/core/net/ProxyOptions.java Co-authored-by: Julien Viet --- vertx-core/src/main/java/io/vertx/core/net/ProxyOptions.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vertx-core/src/main/java/io/vertx/core/net/ProxyOptions.java b/vertx-core/src/main/java/io/vertx/core/net/ProxyOptions.java index eb32f29c661..9589319f838 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/ProxyOptions.java +++ b/vertx-core/src/main/java/io/vertx/core/net/ProxyOptions.java @@ -264,7 +264,7 @@ public ProxyOptions setConnectTimeout(Duration connectTimeout) { } /** - * Get the SSL options used for the connection to the proxy itself. + * Get the SSL options used between the client and the proxy. *

* Only relevant when {@link #getType()} is {@link ProxyType#HTTPS}. * From 050a51a3792d968854a7401b9d5c5f7aad8c2733 Mon Sep 17 00:00:00 2001 From: Giorgos Gaganis Date: Wed, 1 Jul 2026 11:55:22 +0200 Subject: [PATCH 3/8] User assertions for expected failures in HttpsProxyTest --- .../io/vertx/tests/http/HttpsProxyTest.java | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java b/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java index c151fba6f19..82139c07355 100644 --- a/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java +++ b/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java @@ -29,6 +29,10 @@ import io.vertx.test.tls.Trust; import org.junit.Test; +import javax.net.ssl.SSLHandshakeException; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + /** * Tests for {@link ProxyType#HTTPS}: the connection to the proxy itself (leg 1) is established over * TLS. The proxying semantics (absolute-URI {@code GET} forwarding for plain origins, {@code CONNECT} @@ -218,15 +222,13 @@ public void testHttpsProxy_untrustedProxyCertRejected() throws Exception { client = vertx.createHttpClient(new HttpClientOptions() .setProxyOptions(new ProxyOptions().setType(ProxyType.HTTPS).setHost("localhost").setPort(proxy.port()))); - try { - client.request(new RequestOptions().setMethod(HttpMethod.GET) - .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) - .compose(req -> req.send()) - .await(); - fail("Expected the connection to the untrusted HTTPS proxy to fail"); - } catch (Exception expected) { - // connect failed rather than hanging or silently downgrading - } + // connect must fail rather than hanging or silently downgrading + assertThatThrownBy(() -> client.request(new RequestOptions().setMethod(HttpMethod.GET) + .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) + .compose(req -> req.send()) + .await()) + .isInstanceOf(SSLHandshakeException.class) + .hasStackTraceContaining("unable to find valid certification path"); } @Test @@ -241,15 +243,13 @@ public void testHttpsProxy_hostnameMismatchRejected() throws Exception { .setProxyOptions(new ProxyOptions().setType(ProxyType.HTTPS).setHost("127.0.0.1").setPort(proxy.port()) .setSslOptions(trustingProxySsl()))); - try { - client.request(new RequestOptions().setMethod(HttpMethod.GET) - .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) - .compose(req -> req.send()) - .await(); - fail("Expected hostname verification against the proxy to fail"); - } catch (Exception expected) { - // verification rejected the mismatched proxy hostname - } + // verification must reject the mismatched proxy hostname + assertThatThrownBy(() -> client.request(new RequestOptions().setMethod(HttpMethod.GET) + .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) + .compose(req -> req.send()) + .await()) + .isInstanceOf(SSLHandshakeException.class) + .hasStackTraceContaining("No subject alternative names present"); } @Test @@ -261,14 +261,12 @@ public void testHttpsProxy_againstPlaintextProxyFailsCleanly() throws Exception client.close(); client = vertx.createHttpClient(new HttpClientOptions().setProxyOptions(httpsProxy(proxy.port()))); - try { - client.request(new RequestOptions().setMethod(HttpMethod.GET) - .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) - .compose(req -> req.send()) - .await(); - fail("Expected the TLS handshake against a plaintext proxy to fail"); - } catch (Exception expected) { - // no silent downgrade: a HTTPS proxy type never falls back to plaintext - } + // no silent downgrade: a HTTPS proxy type never falls back to plaintext + assertThatThrownBy(() -> client.request(new RequestOptions().setMethod(HttpMethod.GET) + .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) + .compose(req -> req.send()) + .await()) + .isInstanceOf(SSLHandshakeException.class) + .hasStackTraceContaining("not an SSL/TLS record"); } } From 68916d0db773f496b991871869f24bab4ab96b41 Mon Sep 17 00:00:00 2001 From: Giorgos Gaganis Date: Wed, 1 Jul 2026 17:13:30 +0200 Subject: [PATCH 4/8] Add https proxy support to Proxy rule --- .../test/java/io/vertx/test/proxy/Proxy.java | 22 ++++- .../java/io/vertx/test/proxy/ProxyKind.java | 5 + .../java/io/vertx/test/proxy/WithProxy.java | 7 ++ .../io/vertx/tests/http/HttpsProxyTest.java | 92 ++++++------------- 4 files changed, 59 insertions(+), 67 deletions(-) diff --git a/vertx-core/src/test/java/io/vertx/test/proxy/Proxy.java b/vertx-core/src/test/java/io/vertx/test/proxy/Proxy.java index 18ae3eb1a38..966637c8b5a 100644 --- a/vertx-core/src/test/java/io/vertx/test/proxy/Proxy.java +++ b/vertx-core/src/test/java/io/vertx/test/proxy/Proxy.java @@ -16,8 +16,11 @@ import io.vertx.core.buffer.Buffer; import io.vertx.core.dns.AddressResolverOptions; import io.vertx.core.http.HttpMethod; +import io.vertx.core.net.ClientSSLOptions; import io.vertx.core.net.ProxyOptions; import io.vertx.core.net.ProxyType; +import io.vertx.test.tls.Cert; +import io.vertx.test.tls.Trust; import org.junit.rules.TestRule; import org.junit.runner.Description; import org.junit.runners.model.Statement; @@ -63,12 +66,14 @@ private class ProxyLifecycle extends Statement { private final String username; private final ProxyKind kind; + private final boolean requireSslClientAuth; private final Statement statement; private final String hosts; - public ProxyLifecycle(String username, ProxyKind kind, String hosts, Statement statement) { + public ProxyLifecycle(String username, ProxyKind kind, boolean requireSslClientAuth, String hosts, Statement statement) { this.username = username; this.kind = kind; + this.requireSslClientAuth = requireSslClientAuth; this.statement = statement; this.hosts = hosts; } @@ -83,9 +88,19 @@ public void evaluate() throws Throwable { Vertx vertx = Vertx.vertx(vertxOptions); ProxyType type; ProxyBase proxy; + ClientSSLOptions sslOptions = null; if (kind == ProxyKind.HTTP) { type = ProxyType.HTTP; proxy = new HttpProxy(); + } else if (kind == ProxyKind.HTTPS) { + // TLS requires the proxy to present a certificate, so an HTTPS proxy always has a server cert. + type = ProxyType.HTTPS; + HttpProxy httpProxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); + if (requireSslClientAuth) { + httpProxy.requireClientAuth(Trust.CLIENT_JKS.get()); + } + proxy = httpProxy; + sslOptions = new ClientSSLOptions().setTrustOptions(Trust.SERVER_JKS.get()); } else if (kind == ProxyKind.SOCKS5) { type = ProxyType.SOCKS5; proxy = new SocksProxy(); @@ -99,7 +114,8 @@ public void evaluate() throws Throwable { ProxyOptions proxyOptions = new ProxyOptions() .setHost("localhost") .setPort(proxy.port()) - .setType(type); + .setType(type) + .setSslOptions(sslOptions); proxy.start(vertx); Proxy.this.proxy = proxy; Proxy.this.options = proxyOptions; @@ -124,7 +140,7 @@ public Statement apply(Statement statement, Description description) { } hosts.append("127.0.0.1 ").append(host); } - result = new ProxyLifecycle(repeat.username(), repeat.kind(), hosts.toString(), statement); + result = new ProxyLifecycle(repeat.username(), repeat.kind(), repeat.requireSslClientAuth(), hosts.toString(), statement); } return result; } diff --git a/vertx-core/src/test/java/io/vertx/test/proxy/ProxyKind.java b/vertx-core/src/test/java/io/vertx/test/proxy/ProxyKind.java index 7901616162c..e7035d08b89 100644 --- a/vertx-core/src/test/java/io/vertx/test/proxy/ProxyKind.java +++ b/vertx-core/src/test/java/io/vertx/test/proxy/ProxyKind.java @@ -17,6 +17,11 @@ public enum ProxyKind { */ HTTP, + /** + * HTTP proxy whose own connection (leg 1) is established over TLS + */ + HTTPS, + /** * SOCKS4/4a tcp proxy */ diff --git a/vertx-core/src/test/java/io/vertx/test/proxy/WithProxy.java b/vertx-core/src/test/java/io/vertx/test/proxy/WithProxy.java index 23693fb3685..64d1d6000a2 100644 --- a/vertx-core/src/test/java/io/vertx/test/proxy/WithProxy.java +++ b/vertx-core/src/test/java/io/vertx/test/proxy/WithProxy.java @@ -22,6 +22,13 @@ ProxyKind kind(); + /** + * Only meaningful for {@link ProxyKind#HTTPS}: when {@code true} the proxy requires and validates a + * client certificate (mutual TLS on leg 1). This is a server-side knob only; the client presenting a + * certificate remains the responsibility of the test's own {@code ProxyOptions.sslOptions}. + */ + boolean requireSslClientAuth() default false; + String[] localhosts() default {}; } diff --git a/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java b/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java index 82139c07355..995113a69b8 100644 --- a/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java +++ b/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java @@ -10,7 +10,6 @@ */ package io.vertx.tests.http; -import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpClientOptions; @@ -24,9 +23,12 @@ import io.vertx.core.net.ProxyType; import io.vertx.core.net.SocketAddress; import io.vertx.test.http.HttpTestBase; -import io.vertx.test.proxy.HttpProxy; +import io.vertx.test.proxy.Proxy; +import io.vertx.test.proxy.ProxyKind; +import io.vertx.test.proxy.WithProxy; import io.vertx.test.tls.Cert; import io.vertx.test.tls.Trust; +import org.junit.Rule; import org.junit.Test; import javax.net.ssl.SSLHandshakeException; @@ -40,31 +42,8 @@ */ public class HttpsProxyTest extends HttpTestBase { - private Vertx proxyVertx; - private HttpProxy proxy; - - @Override - public void setUp() throws Exception { - super.setUp(); - // The proxy runs on its own Vertx instance so that closing it in tearDown awaits the release of - // its listen socket. HttpProxy.stop() alone does not wait for the close to complete, which leaks - // the port into the following tests. - proxyVertx = Vertx.vertx(); - } - - @Override - protected void tearDown() throws Exception { - try { - if (proxy != null) { - proxy.stop(); - } - } finally { - if (proxyVertx != null) { - proxyVertx.close().await(); - } - } - super.tearDown(); - } + @Rule + public Proxy proxy = new Proxy(); private void startHttpOrigin() throws Exception { server.requestHandler(req -> req.response().end("Hello from origin")); @@ -85,24 +64,15 @@ private static ClientSSLOptions trustingProxySsl() { return new ClientSSLOptions().setTrustOptions(Trust.SERVER_JKS.get()); } - private static ProxyOptions httpsProxy(int port) { - return new ProxyOptions() - .setType(ProxyType.HTTPS) - .setHost("localhost") - .setPort(port) - .setSslOptions(trustingProxySsl()); - } - // --- Happy paths ---------------------------------------------------------- @Test + @WithProxy(kind = ProxyKind.HTTPS) public void testHttpsProxy_HttpOrigin_forwarded() throws Exception { - proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); - proxy.start(proxyVertx); startHttpOrigin(); client.close(); - client = vertx.createHttpClient(new HttpClientOptions().setProxyOptions(httpsProxy(proxy.port()))); + client = vertx.createHttpClient(new HttpClientOptions().setProxyOptions(proxy.options())); Buffer body = client.request(new RequestOptions().setMethod(HttpMethod.GET) .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) @@ -112,21 +82,20 @@ public void testHttpsProxy_HttpOrigin_forwarded() throws Exception { assertEquals("Hello from origin", body.toString()); // Plain origin: the proxy GET-forwards (it sees the absolute URI), it does not CONNECT. - assertEquals(HttpMethod.GET, proxy.getLastMethod()); - assertNotNull(proxy.getLastUri()); + assertEquals(HttpMethod.GET, proxy.lastMethod()); + assertNotNull(proxy.lastUri()); } @Test + @WithProxy(kind = ProxyKind.HTTPS) public void testHttpsProxy_HttpsOrigin_tunnelled() throws Exception { - proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); - proxy.start(proxyVertx); startHttpsOrigin(HttpVersion.HTTP_1_1); client.close(); client = vertx.createHttpClient(new HttpClientOptions() .setSsl(true) .setTrustOptions(Trust.SERVER_JKS.get()) - .setProxyOptions(httpsProxy(proxy.port()))); + .setProxyOptions(proxy.options())); Buffer body = client.request(new RequestOptions().setMethod(HttpMethod.GET) .setHost(DEFAULT_HTTPS_HOST).setPort(DEFAULT_HTTPS_PORT).setURI("/")) @@ -136,13 +105,12 @@ public void testHttpsProxy_HttpsOrigin_tunnelled() throws Exception { assertEquals("Hello from origin", body.toString()); // TLS origin: nested TLS through the CONNECT tunnel (the proxy never sees the encrypted request). - assertEquals(HttpMethod.CONNECT, proxy.getLastMethod()); + assertEquals(HttpMethod.CONNECT, proxy.lastMethod()); } @Test + @WithProxy(kind = ProxyKind.HTTPS) public void testHttpsProxy_Http2Origin_tunnelled() throws Exception { - proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); - proxy.start(proxyVertx); startHttpsOrigin(HttpVersion.HTTP_2); client.close(); @@ -151,7 +119,7 @@ public void testHttpsProxy_Http2Origin_tunnelled() throws Exception { .setSsl(true) .setUseAlpn(true) .setTrustOptions(Trust.SERVER_JKS.get()) - .setProxyOptions(httpsProxy(proxy.port()))); + .setProxyOptions(proxy.options())); String result = client.request(new RequestOptions().setMethod(HttpMethod.GET) .setHost(DEFAULT_HTTPS_HOST).setPort(DEFAULT_HTTPS_PORT).setURI("/")) @@ -161,18 +129,17 @@ public void testHttpsProxy_Http2Origin_tunnelled() throws Exception { // h2 rides the CONNECT tunnel for free; ALPN negotiates h2 end-to-end with the origin. assertEquals(HttpVersion.HTTP_2 + ":Hello from origin", result); - assertEquals(HttpMethod.CONNECT, proxy.getLastMethod()); + assertEquals(HttpMethod.CONNECT, proxy.lastMethod()); } @Test + @WithProxy(kind = ProxyKind.HTTPS, username = "user1") public void testHttpsProxy_proxyAuthentication() throws Exception { - proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()).username("user1"); - proxy.start(proxyVertx); startHttpOrigin(); client.close(); client = vertx.createHttpClient(new HttpClientOptions() - .setProxyOptions(httpsProxy(proxy.port()).setUsername("user1").setPassword("user1"))); + .setProxyOptions(proxy.options().setUsername("user1").setPassword("user1"))); Buffer body = client.request(new RequestOptions().setMethod(HttpMethod.GET) .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) @@ -186,15 +153,13 @@ public void testHttpsProxy_proxyAuthentication() throws Exception { } @Test + @WithProxy(kind = ProxyKind.HTTPS, requireSslClientAuth = true) public void testHttpsProxy_mutualTls() throws Exception { - proxy = new HttpProxy() - .ssl(Cert.SERVER_JKS.get()) - .requireClientAuth(Trust.CLIENT_JKS.get()); - proxy.start(proxyVertx); startHttpOrigin(); client.close(); - client = vertx.createHttpClient(new HttpClientOptions().setProxyOptions(httpsProxy(proxy.port()) + // The proxy requires a client certificate (server-side); the client presents one here (client-side). + client = vertx.createHttpClient(new HttpClientOptions().setProxyOptions(proxy.options() .setSslOptions(new ClientSSLOptions() .setTrustOptions(Trust.SERVER_JKS.get()) .setKeyCertOptions(Cert.CLIENT_JKS.get())))); @@ -211,9 +176,8 @@ public void testHttpsProxy_mutualTls() throws Exception { // --- Security / failure cases --------------------------------------------- @Test + @WithProxy(kind = ProxyKind.HTTPS) public void testHttpsProxy_untrustedProxyCertRejected() throws Exception { - proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); - proxy.start(proxyVertx); startHttpOrigin(); client.close(); @@ -232,9 +196,8 @@ public void testHttpsProxy_untrustedProxyCertRejected() throws Exception { } @Test + @WithProxy(kind = ProxyKind.HTTPS) public void testHttpsProxy_hostnameMismatchRejected() throws Exception { - proxy = new HttpProxy().ssl(Cert.SERVER_JKS.get()); - proxy.start(proxyVertx); startHttpOrigin(); client.close(); @@ -253,15 +216,16 @@ public void testHttpsProxy_hostnameMismatchRejected() throws Exception { } @Test + @WithProxy(kind = ProxyKind.HTTP) public void testHttpsProxy_againstPlaintextProxyFailsCleanly() throws Exception { - proxy = new HttpProxy(); // plaintext proxy - proxy.start(proxyVertx); startHttpOrigin(); client.close(); - client = vertx.createHttpClient(new HttpClientOptions().setProxyOptions(httpsProxy(proxy.port()))); + // A HTTPS proxy type pointed at a plaintext proxy: no silent downgrade, the TLS handshake must fail. + client = vertx.createHttpClient(new HttpClientOptions() + .setProxyOptions(new ProxyOptions().setType(ProxyType.HTTPS).setHost("localhost").setPort(proxy.port()) + .setSslOptions(trustingProxySsl()))); - // no silent downgrade: a HTTPS proxy type never falls back to plaintext assertThatThrownBy(() -> client.request(new RequestOptions().setMethod(HttpMethod.GET) .setHost(DEFAULT_HTTP_HOST).setPort(DEFAULT_HTTP_PORT).setURI("/")) .compose(req -> req.send()) From 93c8818ea3a9e7c4cfef40bceb272dbf95b0d114 Mon Sep 17 00:00:00 2001 From: Giorgos Gaganis Date: Wed, 1 Jul 2026 17:54:31 +0200 Subject: [PATCH 5/8] Test sendFile through https proxy --- .../core/net/impl/tcp/NetClientImpl.java | 4 +- .../io/vertx/tests/http/HttpsProxyTest.java | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java index 32edfe4237d..e5dd87c3e22 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/tcp/NetClientImpl.java @@ -16,6 +16,7 @@ import io.netty.channel.Channel; import io.netty.handler.logging.ByteBufFormat; import io.netty.handler.logging.LoggingHandler; +import io.netty.handler.ssl.SslHandler; import io.netty.handler.stream.ChunkedWriteHandler; import io.netty.handler.timeout.IdleStateHandler; import io.netty.util.concurrent.GenericFutureListener; @@ -106,7 +107,8 @@ protected void initChannel(ChannelPipeline pipeline, boolean ssl) { if (logging != null) { pipeline.addLast("logging", new LoggingHandler(logging)); } - if (ssl || !vertx.transport().supportFileRegion()) { + boolean sslChannel = pipeline.get(SslHandler.class) != null; + if (sslChannel || !vertx.transport().supportFileRegion()) { // only add ChunkedWriteHandler when SSL is enabled otherwise it is not needed as FileRegion is used. pipeline.addLast("chunkedWriter", new ChunkedWriteHandler()); // For large file / sendfile support } diff --git a/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java b/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java index 995113a69b8..f9241197ce8 100644 --- a/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java +++ b/vertx-core/src/test/java/io/vertx/tests/http/HttpsProxyTest.java @@ -19,9 +19,14 @@ import io.vertx.core.http.HttpVersion; import io.vertx.core.http.RequestOptions; import io.vertx.core.net.ClientSSLOptions; +import io.vertx.core.net.NetClient; +import io.vertx.core.net.NetClientOptions; +import io.vertx.core.net.NetServer; +import io.vertx.core.net.NetSocket; import io.vertx.core.net.ProxyOptions; import io.vertx.core.net.ProxyType; import io.vertx.core.net.SocketAddress; +import io.vertx.test.core.TestUtils; import io.vertx.test.http.HttpTestBase; import io.vertx.test.proxy.Proxy; import io.vertx.test.proxy.ProxyKind; @@ -32,6 +37,8 @@ import org.junit.Test; import javax.net.ssl.SSLHandshakeException; +import java.io.File; +import java.nio.file.Files; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -173,6 +180,40 @@ public void testHttpsProxy_mutualTls() throws Exception { assertEquals("Hello from origin", body.toString()); } + @Test + @WithProxy(kind = ProxyKind.HTTPS) + public void testHttpsProxy_sendFileThroughTunnel() throws Exception { + // A client NetSocket.sendFile() to a plain origin through the HTTPS proxy: the file must travel + // through the leg-1 TLS (the "proxy-ssl" SslHandler on the client pipeline). Because the origin is + // plain, the connection's isSsl() is false, so the old !isSsl() zero-copy check would send the raw + // file bytes bypassing the SslHandler and break the TLS tunnel. supportsFileRegion() must instead + // detect the SslHandler and fall back to chunked writes. + String content = TestUtils.randomUnicodeString(10000); + File file = Files.createTempFile("vertx", ".txt").toFile(); + file.deleteOnExit(); + Files.write(file.toPath(), content.getBytes("UTF-8")); + Buffer expected = Buffer.buffer(content); + + // Plain origin. Not DEFAULT_HTTP_PORT: the proxy denies CONNECT to that port. + Buffer received = Buffer.buffer(); + NetServer origin = vertx.createNetServer(); + origin.connectHandler(sock -> sock.handler(buff -> { + received.appendBuffer(buff); + if (received.length() == expected.length()) { + assertEquals(expected, received); + testComplete(); + } + })); + origin.listen(DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST).await(); + + NetClient netClient = vertx.createNetClient(new NetClientOptions().setProxyOptions(proxy.options())); + NetSocket sock = netClient.connect(DEFAULT_HTTPS_PORT, DEFAULT_HTTPS_HOST).await(); + sock.sendFile(file.getAbsolutePath()).await(); + + await(); + assertEquals(HttpMethod.CONNECT, proxy.lastMethod()); + } + // --- Security / failure cases --------------------------------------------- @Test From 2e95e4b9330208facdba25b37d5d47a22b1c7595 Mon Sep 17 00:00:00 2001 From: Giorgos Gaganis Date: Thu, 2 Jul 2026 13:09:25 +0200 Subject: [PATCH 6/8] Add isSsl tests --- .../tests/http/ProxyIsSslConsistencyTest.java | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 vertx-core/src/test/java/io/vertx/tests/http/ProxyIsSslConsistencyTest.java diff --git a/vertx-core/src/test/java/io/vertx/tests/http/ProxyIsSslConsistencyTest.java b/vertx-core/src/test/java/io/vertx/tests/http/ProxyIsSslConsistencyTest.java new file mode 100644 index 00000000000..098bb72b7fd --- /dev/null +++ b/vertx-core/src/test/java/io/vertx/tests/http/ProxyIsSslConsistencyTest.java @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2011-2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.tests.http; + +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.http.HttpVersion; +import io.vertx.core.http.RequestOptions; +import io.vertx.core.http.WebSocket; +import io.vertx.core.http.WebSocketClient; +import io.vertx.core.http.WebSocketClientOptions; +import io.vertx.core.http.WebSocketConnectOptions; +import io.vertx.core.net.NetClient; +import io.vertx.core.net.NetClientOptions; +import io.vertx.core.net.NetServerOptions; +import io.vertx.core.net.NetSocket; +import io.vertx.core.net.ProxyOptions; +import io.vertx.test.http.HttpTestBase; +import io.vertx.test.proxy.Proxy; +import io.vertx.test.proxy.ProxyKind; +import io.vertx.test.proxy.WithProxy; +import io.vertx.test.tls.Cert; +import io.vertx.test.tls.Trust; +import org.junit.Rule; +import org.junit.Test; + +/** + * The invariant: {@code connection.isSsl()} must be identical whether a connection is direct, via an + * HTTP proxy, or via an HTTPS proxy. The proxy is a transport detail and must not change + * {@code isSsl()} — it reports whether the ORIGIN connection is encrypted. + * + *

Each test starts one origin, then opens a connection direct and a connection through the proxy of + * the method's {@link WithProxy} kind, and asserts the two {@code isSsl()} values are equal. + * {@code @WithProxy} selects one proxy kind per method, so there are two tests per case (HTTP proxy and + * HTTPS proxy). Origins are cleaned up by {@code tearDown} (vertx close), like other {@link HttpTestBase} + * tests; the per-type client logic ({@link #connectNetSocket}, {@link #connectHttp}, + * {@link #connectWebSocket}) is reused across cases. + * + *

Covers the four main proxy-capable connection types (NetSocket, HTTP/1, HTTP/2, WebSocket) + * against a plain and a TLS origin. + */ +public class ProxyIsSslConsistencyTest extends HttpTestBase { + + // CONNECT-safe origin port: >= 1024 and != DEFAULT_HTTP_PORT (8080), which HttpProxy denies for CONNECT. + private static final int ORIGIN_PORT = DEFAULT_HTTPS_PORT; + private static final String ORIGIN_HOST = "localhost"; + + @Rule + public Proxy proxy = new Proxy(); + + // --- Origins (one per test; closed by tearDown) --------------------------- + + private void startNetOrigin(boolean originTls) throws Exception { + NetServerOptions options = new NetServerOptions().setPort(ORIGIN_PORT).setHost(ORIGIN_HOST); + if (originTls) { + options.setSsl(true).setKeyCertOptions(Cert.SERVER_JKS.get()); + } + vertx.createNetServer(options).connectHandler(so -> {}).listen().await(); + } + + private void startHttpOrigin(HttpVersion version, boolean originTls) throws Exception { + HttpServerOptions options = new HttpServerOptions().setPort(ORIGIN_PORT).setHost(ORIGIN_HOST); + if (originTls) { + options.setSsl(true).setKeyCertOptions(Cert.SERVER_JKS.get()).setUseAlpn(version == HttpVersion.HTTP_2); + } + createHttpServer(options).requestHandler(req -> req.response().end("ok")).listen().await(); + } + + private void startWebSocketOrigin(boolean originTls) throws Exception { + HttpServerOptions options = new HttpServerOptions().setPort(ORIGIN_PORT).setHost(ORIGIN_HOST); + if (originTls) { + options.setSsl(true).setKeyCertOptions(Cert.SERVER_JKS.get()); + } + createHttpServer(options).webSocketHandler(ws -> ws.handler(buff -> {})).listen().await(); + } + + // --- Reusable per-type client logic: connect (direct if proxyOptions is null, else through the + // proxy) and return connection.isSsl() ------------------------------------------------------ + + private boolean connectNetSocket(boolean originTls, ProxyOptions proxyOptions) throws Exception { + NetClientOptions options = new NetClientOptions(); + if (originTls) { + options.setSsl(true).setTrustOptions(Trust.SERVER_JKS.get()).setHostnameVerificationAlgorithm("HTTPS"); + } + if (proxyOptions != null) { + options.setProxyOptions(proxyOptions); + } + NetClient client = vertx.createNetClient(options); + try { + NetSocket so = client.connect(ORIGIN_PORT, ORIGIN_HOST).await(); + return so.isSsl(); + } finally { + client.close().await(); + } + } + + private boolean connectHttp(HttpVersion version, boolean originTls, ProxyOptions proxyOptions) throws Exception { + HttpClientOptions options = new HttpClientOptions().setProtocolVersion(version); + if (originTls) { + options.setSsl(true).setTrustOptions(Trust.SERVER_JKS.get()).setUseAlpn(version == HttpVersion.HTTP_2); + } + if (proxyOptions != null) { + options.setProxyOptions(proxyOptions); + } + HttpClient client = createHttpClient(options); + try { + return client.request(new RequestOptions().setHost(ORIGIN_HOST).setPort(ORIGIN_PORT).setURI("/")) + .compose(req -> req.send().map(resp -> resp.request().connection().isSsl())) + .await(); + } finally { + client.close().await(); + } + } + + private boolean connectWebSocket(boolean originTls, ProxyOptions proxyOptions) throws Exception { + WebSocketClientOptions options = new WebSocketClientOptions(); + if (originTls) { + options.setSsl(true).setTrustOptions(Trust.SERVER_JKS.get()); + } + if (proxyOptions != null) { + options.setProxyOptions(proxyOptions); + } + WebSocketClient client = vertx.createWebSocketClient(options); + try { + WebSocket ws = client.connect(new WebSocketConnectOptions() + .setHost(ORIGIN_HOST).setPort(ORIGIN_PORT).setURI("/").setSsl(originTls)).await(); + return ws.isSsl(); + } finally { + client.close().await(); + } + } + + // --- NetSocket ------------------------------------------------------------ + + @Test + @WithProxy(kind = ProxyKind.HTTP) + public void netSocket_plainOrigin_httpProxy() throws Exception { + startNetOrigin(false); + boolean isSslDirect = connectNetSocket(false, null); + boolean isSslViaProxy = connectNetSocket(false, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTPS) + public void netSocket_plainOrigin_httpsProxy() throws Exception { + startNetOrigin(false); + boolean isSslDirect = connectNetSocket(false, null); + boolean isSslViaProxy = connectNetSocket(false, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTP) + public void netSocket_tlsOrigin_httpProxy() throws Exception { + startNetOrigin(true); + boolean isSslDirect = connectNetSocket(true, null); + boolean isSslViaProxy = connectNetSocket(true, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTPS) + public void netSocket_tlsOrigin_httpsProxy() throws Exception { + startNetOrigin(true); + boolean isSslDirect = connectNetSocket(true, null); + boolean isSslViaProxy = connectNetSocket(true, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + // --- HTTP/1 --------------------------------------------------------------- + + @Test + @WithProxy(kind = ProxyKind.HTTP) + public void http1_plainOrigin_httpProxy() throws Exception { + startHttpOrigin(HttpVersion.HTTP_1_1, false); + boolean isSslDirect = connectHttp(HttpVersion.HTTP_1_1, false, null); + boolean isSslViaProxy = connectHttp(HttpVersion.HTTP_1_1, false, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTPS) + public void http1_plainOrigin_httpsProxy() throws Exception { + startHttpOrigin(HttpVersion.HTTP_1_1, false); + boolean isSslDirect = connectHttp(HttpVersion.HTTP_1_1, false, null); + boolean isSslViaProxy = connectHttp(HttpVersion.HTTP_1_1, false, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTP) + public void http1_tlsOrigin_httpProxy() throws Exception { + startHttpOrigin(HttpVersion.HTTP_1_1, true); + boolean isSslDirect = connectHttp(HttpVersion.HTTP_1_1, true, null); + boolean isSslViaProxy = connectHttp(HttpVersion.HTTP_1_1, true, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTPS) + public void http1_tlsOrigin_httpsProxy() throws Exception { + startHttpOrigin(HttpVersion.HTTP_1_1, true); + boolean isSslDirect = connectHttp(HttpVersion.HTTP_1_1, true, null); + boolean isSslViaProxy = connectHttp(HttpVersion.HTTP_1_1, true, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + // --- HTTP/2 --------------------------------------------------------------- + + @Test + @WithProxy(kind = ProxyKind.HTTP) + public void http2_plainOrigin_httpProxy() throws Exception { + startHttpOrigin(HttpVersion.HTTP_2, false); + boolean isSslDirect = connectHttp(HttpVersion.HTTP_2, false, null); + boolean isSslViaProxy = connectHttp(HttpVersion.HTTP_2, false, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTPS) + public void http2_plainOrigin_httpsProxy() throws Exception { + startHttpOrigin(HttpVersion.HTTP_2, false); + boolean isSslDirect = connectHttp(HttpVersion.HTTP_2, false, null); + boolean isSslViaProxy = connectHttp(HttpVersion.HTTP_2, false, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTP) + public void http2_tlsOrigin_httpProxy() throws Exception { + startHttpOrigin(HttpVersion.HTTP_2, true); + boolean isSslDirect = connectHttp(HttpVersion.HTTP_2, true, null); + boolean isSslViaProxy = connectHttp(HttpVersion.HTTP_2, true, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTPS) + public void http2_tlsOrigin_httpsProxy() throws Exception { + startHttpOrigin(HttpVersion.HTTP_2, true); + boolean isSslDirect = connectHttp(HttpVersion.HTTP_2, true, null); + boolean isSslViaProxy = connectHttp(HttpVersion.HTTP_2, true, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + // --- WebSocket ------------------------------------------------------------ + + @Test + @WithProxy(kind = ProxyKind.HTTP) + public void webSocket_plainOrigin_httpProxy() throws Exception { + startWebSocketOrigin(false); + boolean isSslDirect = connectWebSocket(false, null); + boolean isSslViaProxy = connectWebSocket(false, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTPS) + public void webSocket_plainOrigin_httpsProxy() throws Exception { + startWebSocketOrigin(false); + boolean isSslDirect = connectWebSocket(false, null); + boolean isSslViaProxy = connectWebSocket(false, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTP) + public void webSocket_tlsOrigin_httpProxy() throws Exception { + startWebSocketOrigin(true); + boolean isSslDirect = connectWebSocket(true, null); + boolean isSslViaProxy = connectWebSocket(true, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } + + @Test + @WithProxy(kind = ProxyKind.HTTPS) + public void webSocket_tlsOrigin_httpsProxy() throws Exception { + startWebSocketOrigin(true); + boolean isSslDirect = connectWebSocket(true, null); + boolean isSslViaProxy = connectWebSocket(true, proxy.options()); + assertEquals(isSslDirect, isSslViaProxy); + } +} From 5bfc8a9e7332bf40255a3293dc46e1016a316f65 Mon Sep 17 00:00:00 2001 From: Giorgos Gaganis Date: Thu, 2 Jul 2026 15:04:02 +0200 Subject: [PATCH 7/8] Fix isSsl inconsistency We want isSsl to return consistent results regardless of whether we connect via a http or https proxy. A proxy is a connection detail that should not affect how origin connections are viewed --- .../io/vertx/core/http/impl/http1/Http1ClientConnection.java | 5 ----- .../src/main/java/io/vertx/core/net/impl/ConnectionBase.java | 4 +++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java b/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java index b94c469c5e1..eb9bb3b52c0 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java @@ -149,11 +149,6 @@ public HostAndPort authority() { return authority; } - @Override - public boolean isSsl() { - return ssl; - } - @Override public io.vertx.core.http.impl.HttpClientConnection evictionHandler(Handler handler) { evictionHandler = handler; diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/ConnectionBase.java b/vertx-core/src/main/java/io/vertx/core/net/impl/ConnectionBase.java index 2a294adf121..a7bd6154805 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/ConnectionBase.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/ConnectionBase.java @@ -301,7 +301,9 @@ public void flushBytesWritten() { } public boolean isSsl() { - return chctx.pipeline().get(SslHandler.class) != null; + // Check if the origin ssl handler (named "ssl") is in the pipeline. We don't check for the presence + // of an SslHandler as that could be an instance of the handler for the https proxy. + return chctx.pipeline().get("ssl") != null; } public boolean isTrafficShaped() { From d9f9a04303d76e650356d149ecd5db495ba1a743 Mon Sep 17 00:00:00 2001 From: Giorgos Gaganis Date: Fri, 3 Jul 2026 17:11:23 +0200 Subject: [PATCH 8/8] Fix hanging of http origin through https proxy --- .../http/impl/http1/Http1ClientConnection.java | 7 +++++++ .../io/vertx/core/net/impl/ConnectionBase.java | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java b/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java index eb9bb3b52c0..84f3df1f364 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ClientConnection.java @@ -149,6 +149,13 @@ public HostAndPort authority() { return authority; } + @Override + public boolean isSsl() { + // Report the origin ssl, not the pipeline: in forward-proxy mode the TLS leg to an HTTPS proxy is + // itself the pipeline "ssl" handler. + return ssl; + } + @Override public io.vertx.core.http.impl.HttpClientConnection evictionHandler(Handler handler) { evictionHandler = handler; diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/ConnectionBase.java b/vertx-core/src/main/java/io/vertx/core/net/impl/ConnectionBase.java index a7bd6154805..a3e5bf647da 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/ConnectionBase.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/ConnectionBase.java @@ -301,9 +301,18 @@ public void flushBytesWritten() { } public boolean isSsl() { - // Check if the origin ssl handler (named "ssl") is in the pipeline. We don't check for the presence - // of an SslHandler as that could be an instance of the handler for the https proxy. - return chctx.pipeline().get("ssl") != null; + // Ignore a TLS leg to an HTTPS proxy ("proxy-ssl"): report whether the origin connection is encrypted. + ChannelHandler proxySsl = chctx.pipeline().get("proxy-ssl"); + if (proxySsl == null) { + return chctx.pipeline().get(SslHandler.class) != null; + } + for (String name : chctx.pipeline().names()) { + ChannelHandler handler = chctx.pipeline().get(name); + if (handler != proxySsl && handler instanceof SslHandler) { + return true; + } + } + return false; } public boolean isTrafficShaped() {