Skip to content

Keep the hop to a proxy on HTTP/1.1 - #2369

Merged
hyperxpro merged 8 commits into
AsyncHttpClient:mainfrom
mkurz:fix/proxy-hop-alpn
Nov 6, 2026
Merged

hyperxpro merged 8 commits into
AsyncHttpClient:mainfrom
mkurz:fix/proxy-hop-alpn

Conversation

@mkurz

@mkurz mkurz commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Fixes Plans for the fix/proxy-hop-alpn branch #2358. This is the fix/proxy-hop-alpn branch rebased onto main, with its two commits and their authorship kept, plus six commits of mine, three of them after review.
  • The TLS connection to an HTTPS proxy no longer offers h2 in ALPN, and h2c prior knowledge is no longer sent to an HTTP proxy. Both connections only ever carry HTTP/1.1.

Problem

  1. HTTPS proxy: the TLS connection to an HTTPS proxy derived http2Allowed from the proxy's URI, so with HTTP/2 enabled it offered h2. A proxy that accepts h2 then receives the HTTP/1.1 CONNECT, or the absolute-URI request, and drops the connection (RemotelyClosedException). Proxies that don't accept h2 are not affected.
  2. HTTP proxy with h2c: with http2CleartextEnabled, an http:// target behind an HTTP proxy got the HTTP/2 connection preface (PRI * HTTP/2.0) sent to the proxy, which never agreed to HTTP/2. SOCKS is not affected, since there the peer is the origin.

Both are reproduced by the tests below on main, against test proxies (an h2-capable TLS CONNECT proxy and a stub HTTP proxy) rather than a real proxy product.

Change

  • 04f167c, rebased: the HTTPS proxy connection passes http2Allowed = false, so DefaultSslEngineFactory uses its http/1.1-only context. If the connection still negotiates h2, which a user-supplied context can, this commit logged a warning; the request now fails with the reason instead (see below). main already has the 5-argument addSslHandler (Keep connection-authenticated sockets off the HTTP/2 registry #2342), so of the ChannelManager change only the Javadoc is left.
  • 0b4f999, rebased: h2c is only used when the socket's peer is the origin, that is without a proxy or through SOCKS. The check joins main's handling of connections a login authenticates (Keep connection-authenticated sockets off the HTTP/2 registry #2342), so its "instead of h2c" warning is only logged when h2c would otherwise be used. The test imports Http2Headers, since main rejects fully qualified names (Reject fully qualified names and wildcard imports #2338).
  • Don't wait for h2c behind an HTTP proxy (mine): Fail fast when the scheme rules out HTTP/2 #2334 fails a request refused a connection permit at once when no HTTP/2 connection could ever be registered for it. A cleartext origin behind an HTTP or HTTPS proxy is now such a case too. On main, the check only looked at http2CleartextEnabled, so with it enabled such a request waited out connectTimeout behind either proxy type.
  • Let the h2c proxy test check the response (mine): the stub HTTP proxy now answers, and the test checks that the request completes over HTTP/1.1. It no longer waits out its 10-second timeout.
  • Correct the proxy tests' comments (mine): the TLS test proxy's Javadoc no longer claims that it checks the HTTP/2 preface, and both tests use ASCII only.

After review:

  • Fail the connect when the proxy selects h2: if the TLS connection to an HTTPS proxy still negotiates h2, as an engine from a user-supplied SslContext can, the connect now fails at once, before anything is written to the proxy, with an exception that names the cause. Before, this was only logged, and the request repeated the handshake once per retry and then failed with a bare Remotely closed. The failure is not retried in any state, also not after an earlier retry or a closed pooled connection, since a new connection would select h2 again.
  • Test the h2c waiter through SOCKS and HTTPS: an over-cap h2c request through SOCKS must wait for the first connection and complete on it, and an http target behind an HTTPS proxy must be refused at once.
  • Document every connection kept on HTTP/1.1: SslEngineFactory and DefaultSslEngineFactory now list all three cases of http2Allowed = false: WebSocket, the connection to an HTTPS proxy, and a connection that a login authenticates (NTLM, Kerberos or SPNEGO, or any proxy login on a CONNECT tunnel or through SOCKS). The h2c comments say that prior knowledge in RFC 9113 is about the immediate peer, and that it is http2CleartextEnabled that limits it to origins. Test comments corrected.

Unchanged:

  • the connection to the origin through a CONNECT tunnel still negotiates h2, also behind an HTTPS proxy;
  • h2c without a proxy and through SOCKS;
  • WebSocket staying on HTTP/1.1;
  • connections a login authenticates staying off the HTTP/2 registry.

Not in scope

A user-supplied SslContext is used as-is for every connection, so if it offers h2, the proxy connection can still negotiate it, as a wss:// connection already can. The request then fails with the reason (see above). To keep h2 to origins, a custom SslEngineFactory can offer only http/1.1 when it receives http2Allowed = false, which it does for the proxy connection.

Release note

With http2CleartextEnabled, requests through an HTTP forward proxy now use HTTP/1.1 rather than h2c, including when that proxy supports h2c.

AI disclosure

Claude Code on behalf of Matthias Kurz. The two rebased commits keep their original authorship; Claude Code did the rebase and the conflict resolution, which their messages describe. All eight commits include Co-Authored-By per AGENTS.md.

Test plan

  • Fail on main, pass with this PR:
    • ProxyTunnelHttp2Test.httpsProxyHopMustNotNegotiateHttp2: on main the proxy negotiates h2, then Remotely closed. With the PR, it also checks that the origin behind the HTTPS proxy still gets HTTP/2.
    • ProxyH2cTest.h2cIsNotSpokenToAnHttpProxy: on main the proxy receives PRI * HTTP/2.0.
    • Http2ConnectionWaiterGateTest.permitExhaustedOnCleartextOriginBehindAnHttpProxyFailsWithoutWaitingOutConnectTimeout and ...BehindAnHttpsProxy...: fail without my first commit, with the refused request still pending.
    • ProxyTunnelHttp2Test.httpsProxyHopThatSelectsHttp2FailsTheConnect: before the fail-fast commit, the request failed with RemotelyClosedException. Checks the exception, that the proxy receives no bytes after selecting h2, one handshake per request, and that the connection permit is returned.
    • ProxyHopAlpnConnectListenerTest: drives the connect listener with a proxy connection that selected h2, in the NEW, RECONNECTED and POOLED states. With the failure going through onFailure(), the latter two were retried. Checks that nothing is retried, the exception, that the channel is closed, and that the permit is returned once.
  • The new waiter tests catch the changes described in review: without the isSocks() exemption, ProxyH2cTest.overCapH2cRequestThroughASocksProxyWaitsForTheFirstConnection fails; with HTTPS proxies let through, the HTTPS-proxy waiter test fails.
  • Pass on both: the other five ProxyTunnelHttp2Test tests (HTTP/2 to the origin through a CONNECT proxy, tunnel reuse, concurrent requests, SOCKS, maxConnectionsPerHost) and ProxyH2cTest.h2cIsSpokenThroughASocksProxy.
  • ./mvnw -B -ntp -Dgpg.skip=true clean verify on JDK 11, merged with main 3e95ec8c2: 1,919 tests, 0 failures, 0 errors, 11 skipped; Revapi passed. No test-skipping flags were used.
  • ProxyTunnelHttp2Test, ProxyH2cTest, Http2ConnectionWaiterGateTest, ConnectionOrientedAuthConnectListenerTest and ProxyHopAlpnConnectListenerTest compiled on JDK 11 and run on JDK 17, 21 and 25 (Maven Surefire's -Djvm): 21 tests each, 0 failures.

hyperxpro and others added 5 commits November 4, 2026 22:54
Rebased onto main by Claude Code on behalf of Matthias Kurz. main
already has the addSslHandler overload that takes http2Allowed, so the
conflict in ChannelManager was resolved by keeping main's code: of this
commit's ChannelManager change, only the Javadoc is left.

(cherry picked from commit 04f167c)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebased onto main by Claude Code on behalf of Matthias Kurz. The
conflict in NettyConnectListener was resolved by joining the check with
main's handling of connections that a login authenticates, and the test
imports Http2Headers, since main rejects fully qualified names.

(cherry picked from commit 0b4f999)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Now that h2c is never spoken to an HTTP proxy, a cleartext origin
behind one can never register an HTTP/2 connection, just as behind an
HTTPS proxy. A request refused a connection permit still armed a
waiter for one, so it was held for connectTimeout and then failed with
the permit exception it already had. Fail it at once, as AsyncHttpClient#2334 does
when h2c is disabled.

The comment there said this rests on every hop to a cleartext origin
being cleartext, which the hop to an HTTPS proxy is not. What it rests
on is that no proxy is ever spoken to over HTTP/2.

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stub proxy recorded the first line and hung up, so the test showed
what was sent, but not that the request still works. Answer with an
HTTP/1.1 response and check that the request completes with it. The
test no longer waits out its 10 second timeout either.

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The TLS test proxy's Javadoc said that it checks the HTTP/2 connection
preface, but it drops the connection whatever arrived, which is all the
test needs. Also replace the non-ASCII punctuation in both tests, since
AGENTS.md asks for ASCII only.

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@hyperxpro hyperxpro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few comments

mkurz and others added 3 commits November 5, 2026 22:50
When the TLS connection to an HTTPS proxy still negotiates h2, as an
engine from a user-supplied SslContext can, the proxy rejects the
HTTP/1.1 bytes written next. This was only logged: the request then
repeated the handshake once per retry and failed with a bare "Remotely
closed". Fail the connect at once instead, with an exception that
names the cause and writes nothing to the proxy. It is not retried,
also not after an earlier retry or a closed pooled connection, where
onFailure() would retry other failures: a new connection would select
h2 again. The hint points to an SslEngineFactory, since an SslContext
that offers only http/1.1 would also turn off h2 to the origins.

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The waiter tests only covered a cleartext origin behind an HTTP proxy:
dropping the SOCKS exemption, or letting HTTPS proxies through, still
passed all of them. Add an over-cap h2c request through SOCKS, which
must wait for the first connection's HTTP/2 and then complete on it,
and an http target behind an HTTPS proxy, which must be refused at
once. Each fails with the corresponding change to the check.

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SslEngineFactory and DefaultSslEngineFactory still described
http2Allowed = false as WebSocket only. List all three cases there: a
WebSocket, the connection to an HTTPS proxy, and one that a login
authenticates, that is an NTLM, Kerberos or SPNEGO login, or any proxy
login on a CONNECT tunnel or through SOCKS. A user-supplied SslContext
is reused because it is used as-is for every connection, not because
it is known to negotiate http/1.1. The h2c comments now say that prior
knowledge in RFC 9113 is about the immediate peer, and that it is
http2CleartextEnabled that limits it to origins. The test comments now
match tinyproxy's versions and what the h2c stub echoes.

Claude Code on behalf of Matthias Kurz

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mkurz mkurz mentioned this pull request Nov 5, 2026
5 tasks done
@hyperxpro
hyperxpro merged commit fca39b0 into AsyncHttpClient:main Nov 6, 2026
13 checks passed
@mkurz
mkurz deleted the fix/proxy-hop-alpn branch November 6, 2026 17:42
hyperxpro pushed a commit that referenced this pull request Nov 6, 2026
## Summary

- Upgrades Netty from 4.2.19.Final to 4.2.20.Final. Its [security
fixes](https://github.com/netty/netty/releases/tag/netty-4.2.20.Final)
include several in modules AHC uses: three out-of-bounds reads in
`netty-buffer`, unbounded memory usage in `netty-codec-http2`,
insufficient control character neutralization in `netty-codec-http`,
insufficient entropy in `netty-resolver-dns`, and an mTLS bypass via
session resumption in `netty-handler`.
- On 4.2.20, two of AHC's tests fail, both because of Netty changes.
This PR adapts AHC to them.

## Change

- **Upgrade Netty to 4.2.20.Final, and check the second gzip magic
byte:** `JdkZlibDecoder` now rejects a gzip header with a wrong second
magic byte (netty/netty#17972). The gzip inflate loop from #2357 follows
`JdkZlibDecoder`, and `Http1ContentDecompressorTest` compares the two,
so it now checks that byte too. Both bytes are read unsigned, since
`0x8b` is negative as a signed byte.
- **Keep zstd working at a non-positive threshold:** since
netty/netty#17896, the `ZstdDecoder` that `HttpContentDecompressor`
creates sizes its output buffers by
`io.netty.compression.defaultMaxForwardBytes`. At 0 it hands on an empty
body without an error; below 0 it fails with `IllegalArgumentException:
initialCapacity : -1`. AHC leaves zstd to that decoder while the
property is not positive (#2362), so it now replaces it with a
`ZstdDecoder` whose buffers hold `MAX_PART_SIZE`. The size limit still
applies, and the body is still decompressed eagerly there, as before. At
the default value of the property nothing changes. netty/netty#18289
fixes the decoder in Netty; this workaround is harmless with that fix
and can go once AHC requires a Netty version that has it.

## Notes

- Netty 4.2.0 to 4.2.4 have no `ZstdDecoder(int)` constructor.
Overriding AHC's Netty with one of those and also setting the property
to 0 or below would fail zstd responses with a `NoSuchMethodError`.

## AI disclosure

Claude Code on behalf of Matthias Kurz. Both commits include
`Co-Authored-By` per `AGENTS.md`.

## Test plan

- [x] `main` at `5542c9fc4` with `-Dnetty.version=4.2.20.Final`: 1,857
tests, 2 failures:
- `Http1ContentDecompressorTest.decodesLikeNettyForEveryBodyAndSplit`,
case "gzip second magic byte ignored": Netty now fails that body, AHC
did not.
- `Http1ContentDecompressorForwardThresholdTest`: "the zstd body
differs".
- [x] `Http1ContentDecompressorForwardThresholdTest` now runs at
thresholds 0 and -1, each in a fresh JVM, and also checks the size limit
for zstd and that every exchange releases its input and buffers. On
4.2.20 without the zstd commit it fails at both ("the zstd body
differs", "the zstd body did not end"). With it, it passes on 4.2.19 and
4.2.20.
- [x] `./mvnw -B -ntp -Dgpg.skip=true clean verify` on JDK 11, on `main`
`3e95ec8c2`: 1,905 tests, 0 failures, 0 errors, 11 skipped; Revapi
passed. No test-skipping flags were used.
- [x] `Http1ContentDecompressorForwardThresholdTest` and
`Http1ContentDecompressorTest` compiled on JDK 11 and run on JDK 17, 21
and 25 (Maven Surefire's `-Djvm`): 36 tests each, 0 failures.
- [x] This branch merged with #2369: `clean verify` on JDK 11, 1,914
tests, 0 failures, 0 errors, 11 skipped; Revapi passed (run before
#2359, #2360 and #2361 were merged, with those three merged in as well).

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plans for the fix/proxy-hop-alpn branch

2 participants