Skip to content

HTTP/2 not honored on forward-proxy CONNECT tunnel — h2 ALPN negotiated but HTTP/1.1 codec left in pipeline (InvalidLineSeparatorException) #2311

Description

@racorn

NOTE this bug report was written with the help from Claude Opus 4.8, but bug and workaround verified by me.

Version: async-http-client 3.0.12 (Netty 4.2.16.Final). Regression introduced in 3.0.8.

Summary

When an HTTPS request is routed through an HTTP forward proxy (ProxyServer), AHC establishes the CONNECT tunnel, performs the TLS handshake to the target, and ALPN negotiates h2 (HTTP/2 is enabled by default and advertised). However, on the proxy-tunnel code path AHC installs an HTTP/1.1HttpClientCodec and never upgrades to HTTP/2. The h2 response frames are then decoded by the HTTP/1.1 decoder, which fails on the first frame's leading 0x00 byte:

io.netty.handler.codec.http.InvalidLineSeparatorException: Line Feed must be
preceded by Carriage Return when terminating HTTP start- and header field-lines

(On older Netty without strict line parsing this manifests as silent corruption / a different protocol error rather than a clean exception.)

The same request without a proxy works, because the direct path does check the negotiated ALPN protocol and upgrades the pipeline to HTTP/2.

Regression range

Works on 3.0.1 (no HTTP/2 support, ALPN only offers http/1.1). Most likely breaks from 3.0.8 onward, where HTTP/2 support was added (PR #2144 "Add Support for Multiplexing HTTP/2") and enabled by default — h2 is now advertised on the proxy tunnel but not handled there.

Environment

  • async-http-client 3.0.8–3.0.12
  • Netty 4.2.16.Final (netty-codec-http)
  • Target: an h2-capable HTTPS origin (reproduced against Amazon CloudFront)
  • Proxy: any plain HTTP forward proxy honoring CONNECT (reproduced with tinyproxy 1.11.2)
  • http2Enabled left at its default (true)

Root cause (source references)

Direct (no-proxy) path correctly upgrades to h2 after the handshake — NettyConnectListener (onSuccess):

// Detect ALPN-negotiated protocol and upgrade pipeline to HTTP/2 if "h2" was selectedStringalpnProtocol = sslHandler.applicationProtocol();
if (ApplicationProtocolNames.HTTP_2.equals(alpnProtocol)) {
channelManager.upgradePipelineToHttp2(channel.pipeline());
...
}

Proxy CONNECT-tunnel path never checks ALPN — ChannelManager.updatePipelineForHttpTunneling(...):

SslHandlersslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort());
whenHandshaked = sslHandler.handshakeFuture();
pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler);
pipeline.addAfter(SSL_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec()); // always HTTP/1.1
...
returnwhenHandshaked;

createSslHandler uses the same ALPN-enabled SslContext (advertising h2, http/1.1 via DefaultSslEngineFactory), so the tunnel TLS session negotiates h2 — but the pipeline is hardwired to an HTTP/1.1 codec.

The caller only waits for the handshake, then writes — it does not inspect applicationProtocol() nor call upgradePipelineToHttp2NettyRequestSender.drainChannelAndExecuteNextRequest(..., Future<Channel> whenHandshaked):

whenHandshaked.addListener(f -> {
if (f.isSuccess()) {
sendNextRequest(nextRequest, future); // no ALPN / h2 check
} else {
future.abort(f.cause());
}
});

Entry point: ConnectSuccessInterceptor.exitAfterHandlingConnectupdatePipelineForHttpTunneling(...).

So the h2-upgrade logic that exists on the direct path is missing on the proxy-tunnel path.

Steps to reproduce

  1. Run any HTTP forward proxy that supports CONNECT (e.g. tinyproxy) on localhost:8888.
  2. Send an HTTPS request to an h2-capable origin through it, with defaults (h2 enabled):
try (AsyncHttpClientclient = Dsl.asyncHttpClient(Dsl.config()
.setProxyServer(newProxyServer.Builder("localhost", 8888)))) {
Responser = client.prepareOptions("https://<h2-origin>/...")
.execute().toCompletableFuture().get();
}
  1. The request fails with InvalidLineSeparatorException. Hex-dumping the decrypted inbound bytes (handler inserted right after the ssl handler) shows HTTP/2 frames, e.g. 00 00 12 04 00 ... (SETTINGS) and 00 01 96 01 05 00 00 00 03 ... (HEADERS).
  2. Removing .setProxyServer(...) (direct) makes the same request succeed over h2.

Expected

On the proxy CONNECT-tunnel path, AHC should inspect the ALPN-negotiated protocol after the target TLS handshake and call upgradePipelineToHttp2(...) when h2 was selected — mirroring the direct path — or, at minimum, not advertise h2 in ALPN when it cannot handle h2 on this path.

Workaround

Disable HTTP/2 for clients that go through a forward proxy, so ALPN only offers http/1.1:

Dsl.config().setHttp2Enabled(false).setProxyServer(...)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions