Skip to content

Support HTTP/2 over HTTPS and apply the configured TLS settings - #1483

Merged
mkurz merged 6 commits into
playframework:mainfrom
mkurz:feature/https-alpn
Oct 6, 2026
Merged

mkurz merged 6 commits into
playframework:mainfrom
mkurz:feature/https-alpn

Conversation

@mkurz

@mkurz mkurz commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Play WS can now negotiate HTTP/2 over HTTPS through ALPN when play.ws.ahc.http2Enabled = true. The default stays false, so the TLS engines Play WS creates keep HTTPS on HTTP/1.1 unless you opt in. A custom SslContext on the loose path, or a custom SslEngineFactory, still decides ALPN itself, as before.

To get there, Play WS now creates all its TLS engines through one engine factory built from its configured SSLContext. That applies with or without HTTP/2, but with the default configuration the TLS engines stay exactly the same; see "TLS behavior changes" below.

TLS behavior changes

These apply whether or not HTTP/2 is enabled.

With the default configuration, nothing changes on the normal and play.ws.ssl.default = true paths. The TLS engines that main and this branch create from the default settings are identical: the same protocols (TLS 1.3 and 1.2), the same 31 cipher suites in the same order, the same hostname verification and SNI, and no ALPN while HTTP/2 is off.

Who is affected:

  1. Users who set play.ws.ssl.enabledProtocols or play.ws.ssl.enabledCipherSuites. These lists now take effect. Earlier versions ignored them, so a handshake could use a TLS version or cipher suite outside them.
    • An old allowlist such as ["TLSv1.2", "TLSv1.1"] now caps connections at TLS 1.2, where TLS 1.3 was negotiated before.
    • An allowlist that leaves nothing enabled, such as enabledProtocols = [TLSv1.1] on a JVM that disables TLS 1.1, now fails when the client is created instead of being ignored. Falling back to the defaults would defeat the allowlist. The error names the requested values and, for protocols, the enabled ones.
    • AhcConfigBuilder.modifyUnderlying can change the enabled protocols and cipher suites, because the factory reads them from the final AHC configuration.
  2. Users of play.ws.ssl.loose.acceptAnyCertificate = true. Harmless in practice:
    • Configured client certificates (play.ws.ssl.keyManager.stores) are now presented. Before, they were silently dropped, and a server that required one failed the handshake with certificate_required. A server that only optionally requests a client certificate now receives one, and may act on it.
    • The loose path now offers the JDK's default cipher suites instead of Netty's eight. That is a superset, so every server that worked before still works; only the negotiated suite may differ. The loose path still accepts any certificate for any host, so the cipher choice does not protect against an attacker in the middle anyway.
  3. Rare cases:
    • On a JVM that has TLS 1.0 or 1.1 re-enabled, Play WS's default enabledProtocols = ["TLSv1.3", "TLSv1.2"] used to be ignored and now applies. Servers that only speak TLS 1.0 or 1.1 then stop working.
    • A Netty SslContext set through modifyUnderlying is handled as before:
      • On the loose path it is still used as is. It then decides protocols, cipher suites, certificates and ALPN itself, so http2Enabled does not restrict what it offers.
      • Otherwise it is still ignored, as on main, but Play WS now logs a warning once per client instead of ignoring it silently. Using it there would bypass Play WS's hostname verification and HTTP/2 gating.
      • To take over TLS completely, set an SslEngineFactory through modifyUnderlying; it then becomes responsible for hostname verification and ALPN.

Unchanged: hostname verification on the normal and ssl.default paths, disableHttpsEndpointIdentificationAlgorithm, and the SSL debug options. The hostname tests from #1480 still pass, with and without HTTP/2.

HTTP/2 over HTTPS

  • Opt-in: this applies to the TLS engines Play WS creates. With play.ws.ahc.http2Enabled = true, their HTTPS connections offer h2 and then http/1.1 through ALPN. They use HTTP/2 when the server selects it, and HTTP/1.1 otherwise. With the default false, they send no ALPN extension at all, exactly as before. A custom SslContext used on the loose path, or a custom SslEngineFactory, controls its own ALPN.
  • The gate: AHC switches a connection to HTTP/2 whenever ALPN selects h2. So Play WS's engines offer h2 only when HTTP/2 is enabled and AHC allows it for that connection. Connections that AHC must keep on HTTP/1.1, such as WebSocket and per-connection authentication (NTLM, SPNEGO, Kerberos), offer only http/1.1.
  • Proxies: this works through an HTTP proxy's CONNECT tunnel.
  • Limitation: with AHC 3.0.14, the TLS connection to a proxy of type HTTPS also offers h2. If the proxy selects it, the request fails, because AHC keeps speaking HTTP/1.1 to the proxy.

Turning HTTP/2 on by default is a separate decision and not part of this PR.

Commits

  1. Record TLS handshakes in the ALPN test server. Test infrastructure only. The server records the client's ALPN offer, TLS version, cipher suite and client certificate, can act as an HTTP/1.1-only server or require a client certificate, and answers h2.
  2. Apply the configured TLS protocols and cipher suites.
    • ConfiguredSslEngineFactory on the normal and ssl.default paths.
    • The empty-allowlist check, modifyUnderlying support, and the warning for an ignored SslContext.
    • The loose path is unchanged in this commit.
  3. Present client certificates on the loose TLS path. The loose path goes through the same factory, and a user-supplied SslContext is still used as is. This can be reviewed on its own, but not reverted on its own once commit 5 is in, because commit 5 builds on it.
  4. Assert HTTP/2 backpressure in the sibling streaming test. This strengthens the existing h2c test from Upgrade to AsyncHttpClient 3.0.14 #1481:
    • Held back: the server keeps the write of the suspended response's last frame, and the test asserts that it is still pending after a sibling stream's 1 MiB has arrived. A completed or failed write both fail the test.
    • Stream window above the connection window: the 128 KiB stream window is above the 65,535-byte connection window, so the suspended stream alone could use up the connection's credit.
    • What this does not prove: AHC's own tests cover how it hands that credit back while a response is suspended; this test doesn't claim to prove it.
  5. Negotiate HTTP/2 over HTTPS through ALPN. The gate, the documentation, and the tests below.
  6. Document the AHC 3.0.14 HTTPS-proxy ALPN limitation. README only; revert it once an AHC release contains the fix.

Testing

New and changed tests:

  • TlsConfigurationSpec (normal and loose paths):
    • the configured TLS version and cipher suite are the ones negotiated;
    • modifyUnderlying changes apply;
    • client certificates are presented;
    • a user SslContext is used only on the loose path;
    • a wrong host is rejected, except on the loose path.
  • HttpsHttp2Spec:
    • h2 with every SSL debug option, on the normal and the loose path. On the loose path, which never used ssl-config's tracing, this only shows that ALPN works with the options set.
    • fallback to HTTP/1.1, and an HTTP CONNECT tunnel with HTTP/2 enabled and disabled;
    • NTLM offering only http/1.1, also after the same client used h2;
    • client certificates and TLS 1.2 over h2;
    • the suspended-sibling test over HTTPS.
  • AhcConfigBuilderSpec:
    • the ALPN offer for each combination of TLS path, http2Enabled and http2Allowed;
    • hostname verification on every path;
    • the empty-allowlist errors, and the warning for an ignored SslContext.
  • SslDebugHostnameSpec: now also runs with HTTP/2 enabled.
  • Ahc3CompatibilitySpec:
    • commit 9's HTTPS tests are flipped: HTTP/1.1 with no ALPN by default, h2 when enabled;
    • the stronger h2c backpressure assertions.

Local runs, all on JDK 17:

  • Every commit: validateCode, Test/compile and the tests covering its change.
  • The tip, Scala 2.13: MiMa, doc, and all tests (18 standalone and 17 integration suites).
  • The tip, Scala 3.3: compile, MiMa and the TLS/ALPN specs.

The tests were also checked against deliberately broken code:

  • the protocol, cipher, modifyUnderlying and loose-path client-certificate tests fail against main's configureSSL, while the tests for unchanged behavior, such as the hostname checks and the SslContext override, pass there;
  • they fail when h2 is offered while HTTP/2 is disabled, when http2Allowed is ignored, and when a user SslContext is ignored on the loose path;
  • the backpressure tests fail when the stream window is larger than the suspended response.

A one-off comparison, not part of the test suite, checked that main and this branch build identical TLS engines from the default settings, on the normal and the ssl.default path.

Not covered: SOCKS proxies, and Kerberos/SPNEGO beyond the NTLM case.

The CI matrix (JDK 17/21/25 × Scala 2.13/3.3/3.9/3.next) runs on this PR.

mkurz added 6 commits October 6, 2026 13:51
AlpnProtocolTestServer only reported the negotiated HTTP protocol, and closed any connection that negotiated h2. Record the last handshake instead: the protocols the client offered in ALPN, the TLS version, the cipher suite and the client certificate. Let it act as an HTTP/1.1-only server or require a client certificate, and answer h2 requests as well, so that changes to the TLS configuration and to ALPN can be tested.
Play WS created its TLS engines with AHC's JsseSslEngineFactory, which does not apply the enabled protocols and cipher suites, so play.ws.ssl.enabledProtocols and play.ws.ssl.enabledCipherSuites had no effect. Create the engines through ConfiguredSslEngineFactory instead. It wraps the configured SSLContext in Netty's JdkSslContext and takes the protocols and cipher suites from the final AHC configuration, so modifyUnderlying can still change them. Fail when the configured lists leave nothing enabled, rather than at the first handshake. The loose path is unchanged for now.

A Netty SslContext set through modifyUnderlying is still ignored on this path, as before, but now with a warning.
With play.ws.ssl.loose.acceptAnyCertificate = true, Play WS used a separate Netty context, which dropped the configured client certificates and ignored the configured protocols and cipher suites in favor of Netty's defaults. Create the loose path's engines through ConfiguredSslEngineFactory as well, from an SSLContext with the configured key managers and an insecure trust manager, still without a hostname check. A Netty SslContext set through modifyUnderlying is still used as is on this path, as before.
The h2c sibling test only showed that a demanded stream keeps moving while another one on the same connection is suspended. A client that buffered the whole suspended response would have passed as well. Keep the server's write of the suspended response's last frame, and assert that it is still pending after the sibling's 1 MiB has arrived; a write that failed would not pass either.

Use a 128 KiB stream window, above the 65,535-byte connection window, so that the suspended stream alone could use up the connection's credit. AHC returns connection credit while a response is suspended; AHC's own tests verify that mechanism directly.
With play.ws.ahc.http2Enabled = true, HTTPS still stayed on HTTP/1.1, because Play WS's engine factory offered no ALPN. Offer h2 and then http/1.1 when HTTP/2 is enabled, as AHC's own factory does, and only http/1.1 for connections that AHC must keep on HTTP/1.1, such as WebSocket and per-connection authentication. With HTTP/2 disabled, the default, no ALPN extension is sent, as before. An SslContext used as is on the loose path still decides ALPN itself.

Test h2 over HTTPS on the normal and loose paths, the fallback to HTTP/1.1, an HTTP CONNECT tunnel, and every SSL debug option. Test that NTLM-authenticated requests offer only http/1.1, also after the same client used h2, that client certificates and TLS protocols apply over h2, and that a suspended h2 stream over HTTPS is held back without stalling a sibling. Run the SSL debug hostname tests with HTTP/2 enabled as well.
With HTTP/2 enabled, AHC 3.0.14 also offers h2 on the TLS connection to an HTTPS proxy. If the proxy selects it, the request fails, because AHC keeps writing HTTP/1.1. Play WS's own proxy settings never create such a proxy: setting WSProxyServer.protocol to "https" only selects the authentication scheme. So only a proxy configured on AHC directly is affected. Revert this note once an AHC release offers only http/1.1 on that connection.
@mkurz
mkurz merged commit 3d65df8 into playframework:main Oct 6, 2026
21 checks passed
@mkurz
mkurz deleted the feature/https-alpn branch October 6, 2026 12:56
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.

1 participant