Skip to content

Commit 04f167c

Browse files
committed
Advertise only http/1.1 on the hop to an HTTPS proxy
1 parent 01c3395 commit 04f167c

4 files changed

Lines changed: 588 additions & 12 deletions

File tree

client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,22 @@ public Future<Channel> updatePipelineForHttpsTunneling(ChannelPipeline pipeline,
924924
}
925925

926926
public SslHandler addSslHandler(ChannelPipeline pipeline, Uri uri, String virtualHost, boolean hasSocksProxyHandler) {
927+
// A WebSocket connection must not negotiate h2 (no RFC 8441 support), so advertise only http/1.1 in ALPN.
928+
return addSslHandler(pipeline, uri, virtualHost, hasSocksProxyHandler, !uri.isWebSocket());
929+
}
930+
931+
/**
932+
* Same as {@link #addSslHandler(ChannelPipeline, Uri, String, boolean)}, but with ALPN decided by the
933+
* caller rather than derived from {@code uri}.
934+
* <p>
935+
* Deriving it from the URI only holds for a hop that ends at the origin. On the hop to an HTTPS proxy
936+
* {@code uri} names the proxy, so nothing about it says WebSocket, yet everything written there is
937+
* HTTP/1.1; such callers pass {@code false}.
938+
*
939+
* @param http2Allowed whether the ALPN advertisement for this hop may include h2
940+
*/
941+
public SslHandler addSslHandler(ChannelPipeline pipeline, Uri uri, String virtualHost, boolean hasSocksProxyHandler,
942+
boolean http2Allowed) {
927943
String peerHost;
928944
int peerPort;
929945

@@ -942,8 +958,7 @@ public SslHandler addSslHandler(ChannelPipeline pipeline, Uri uri, String virtua
942958
peerPort = uri.getExplicitPort();
943959
}
944960

945-
// A WebSocket connection must not negotiate h2 (no RFC 8441 support), so advertise only http/1.1 in ALPN.
946-
SslHandler sslHandler = createSslHandler(peerHost, peerPort, !uri.isWebSocket());
961+
SslHandler sslHandler = createSslHandler(peerHost, peerPort, http2Allowed);
947962
// Check if SOCKS handler actually exists in the pipeline before trying to add after it
948963
if (hasSocksProxyHandler && pipeline.get(SOCKS_HANDLER) != null) {
949964
pipeline.addAfter(SOCKS_HANDLER, SSL_HANDLER, sslHandler);

client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,13 @@ public void onSuccess(Channel channel, InetSocketAddress remoteAddress) {
142142
if (proxyServer != null && ProxyType.HTTPS.equals(proxyServer.getProxyType())) {
143143
SslHandler sslHandler;
144144
try {
145-
sslHandler = channelManager.addSslHandler(channel.pipeline(),
146-
Uri.create("https://" + proxyServer.getHost() + ":" + proxyServer.getSecuredPort()),
147-
null, false);
145+
// This hop ends at the proxy, and everything written on it is HTTP/1.1: a CONNECT, or an
146+
// absolute-URI request for a cleartext target. Advertise only http/1.1, otherwise an
147+
// h2-capable proxy selects h2 and then drops the connection on our HTTP/1.1 bytes. The hop
148+
// to the origin is set up after CONNECT succeeds and may still negotiate h2.
149+
sslHandler = channelManager.addSslHandler(channel.pipeline(),
150+
Uri.create("https://" + proxyServer.getHost() + ":" + proxyServer.getSecuredPort()),
151+
null, false, false);
148152
} catch (Exception sslError) {
149153
onFailure(channel, sslError);
150154
return;
@@ -170,6 +174,16 @@ protected void onSuccess(Channel value) {
170174
NettyConnectListener.this.onFailure(channel, e);
171175
return;
172176
}
177+
// The engine advertises only http/1.1, so a conformant proxy cannot have selected h2.
178+
// A user-supplied SslContext is reused as-is and can still do it, in which case the
179+
// proxy rejects our HTTP/1.1 bytes and the request dies of a bare "Remotely closed".
180+
// Name the cause, as the WebSocket path below does.
181+
if (ApplicationProtocolNames.HTTP_2.equals(sslHandler.applicationProtocol())) {
182+
LOGGER.warn("HTTPS proxy {}:{} negotiated HTTP/2; AsyncHttpClient speaks only HTTP/1.1 to a "
183+
+ "proxy, so this connection will likely be rejected. Supply an SslContext "
184+
+ "or SslEngineFactory that advertises http/1.1 for the proxy connection.",
185+
proxyServer.getHost(), proxyServer.getSecuredPort());
186+
}
173187
// After SSL handshake to proxy, continue with normal proxy request
174188
writeRequest(channel);
175189
}

client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@
3535
public class DefaultSslEngineFactory extends SslEngineFactoryBase {
3636

3737
private volatile SslContext sslContext;
38-
// WebSocket connections use a context that advertises only http/1.1 in ALPN: AsyncHttpClient does not
39-
// implement RFC 8441 (WebSocket over HTTP/2), so the server must not be able to negotiate h2 for them.
38+
// Connections that must stay on HTTP/1.1 use a context that advertises only http/1.1 in ALPN: WebSocket,
39+
// because AsyncHttpClient does not implement RFC 8441 (WebSocket over HTTP/2), and the hop to an HTTPS
40+
// proxy, which only ever carries an HTTP/1.1 CONNECT or absolute-URI request.
4041
private volatile SslContext http1OnlySslContext;
4142

4243
private SslContext buildSslContext(AsyncHttpClientConfig config, boolean http2Allowed) throws SSLException {
@@ -98,16 +99,19 @@ public SSLEngine newSslEngine(AsyncHttpClientConfig config, String peerHost, int
9899
}
99100

100101
/**
101-
* Returns the context for a WebSocket connection, which must advertise only http/1.1 (AsyncHttpClient does
102-
* not implement RFC 8441, WebSocket over HTTP/2). Built lazily and cached on first use so a client that
103-
* never opens a {@code wss://} connection never pays for a second {@link SslContext}.
102+
* Returns the context for a connection that must stay on HTTP/1.1 and therefore advertise only http/1.1:
103+
* a WebSocket connection (AsyncHttpClient does not implement RFC 8441, WebSocket over HTTP/2), and the hop
104+
* to an HTTPS proxy (that hop only ever carries an HTTP/1.1 {@code CONNECT} or absolute-URI request).
105+
* Built lazily and cached on first use so a client that never opens such a connection never pays for a
106+
* second {@link SslContext}.
104107
* <p>
105108
* Only a self-built, h2-enabled context advertises h2 and therefore needs a separate http/1.1-only variant;
106109
* a user-supplied context or an h2-disabled one already negotiates http/1.1, so it is reused (which also
107110
* avoids double-releasing it in {@link #destroy()}). <strong>Note:</strong> a user-supplied
108111
* {@link AsyncHttpClientConfig#getSslContext()} is used as-is for every connection type — if it advertises
109-
* h2 in ALPN, a {@code wss://} connection may still negotiate h2, which AHC cannot speak for WebSocket. A
110-
* caller needing WebSocket with a custom context must supply one that negotiates http/1.1.
112+
* h2 in ALPN, a {@code wss://} connection or an HTTPS-proxy connection may still negotiate h2, which AHC
113+
* cannot speak on either. A caller needing those with a custom context must supply one that negotiates
114+
* http/1.1.
111115
*/
112116
private SslContext http1OnlySslContext(AsyncHttpClientConfig config) {
113117
SslContext ctx = http1OnlySslContext;

0 commit comments

Comments
 (0)