This is an automated email from the ASF dual-hosted git repository.
bneradt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/master by this push:
new c0520405e5 Restrict HTTP/2 TLS cipher suites (#13428)
c0520405e5 is described below
commit c0520405e5a5953339245f92455cbd0829a095b1
Author: Brian Neradt <[email protected]>
AuthorDate: Mon Aug 3 10:48:12 2026 -0500
Restrict HTTP/2 TLS cipher suites (#13428)
HTTP/2 can currently be negotiated with TLS 1.2 cipher suites that
RFC 9113 prohibits. Clients that enforce the restriction can reject
those connections instead of falling back to HTTP/1.1.
This checks the pending cipher during ALPN selection and removes
HTTP/2 when its IANA identifier is prohibited. This also adds AuTest
coverage for permitted negotiation and HTTP/1.1 fallback.
Fixes: #5287
Co-authored-by: bneradt <[email protected]>
---
src/iocore/net/SSLUtils.cc | 53 +++++++++++++
tests/gold_tests/tls/tls_h2_cipher_suite.test.py | 94 ++++++++++++++++++++++++
2 files changed, 147 insertions(+)
diff --git a/src/iocore/net/SSLUtils.cc b/src/iocore/net/SSLUtils.cc
index b78865526b..a88bdceb56 100644
--- a/src/iocore/net/SSLUtils.cc
+++ b/src/iocore/net/SSLUtils.cc
@@ -75,6 +75,7 @@
#endif
#include <algorithm>
+#include <cstdint>
#include <thread>
#include <utility>
#include <string>
@@ -99,6 +100,7 @@ static int ssl_vc_index = -1;
static ink_mutex *mutex_buf = nullptr;
static bool open_ssl_initialized = false;
+static DbgCtl dbg_ctl_ssl{"ssl"};
static DbgCtl dbg_ctl_ssl_load{"ssl_load"};
static DbgCtl dbg_ctl_ssl_session_cache{"ssl.session_cache"};
static DbgCtl dbg_ctl_ssl_error{"ssl.error"};
@@ -373,6 +375,53 @@ ssl_next_protos_advertised_callback(SSL *ssl, const
unsigned char **out, unsigne
return SSL_TLSEXT_ERR_NOACK;
}
+static bool
+is_http2_prohibited_cipher(const SSL_CIPHER *cipher)
+{
+ struct CipherRange {
+ uint16_t first;
+ uint16_t last;
+ };
+
+ // RFC 9113 Appendix A lists 276 prohibited TLS 1.2 cipher suites. The IANA
+ // identifiers for those suites form these 24 contiguous ranges.
+ static constexpr CipherRange prohibited_ranges[] = {
+ {0x0000, 0x001b},
+ {0x001e, 0x0046},
+ {0x0067, 0x006d},
+ {0x0084, 0x009d},
+ {0x00a0, 0x00a1},
+ {0x00a4, 0x00a9},
+ {0x00ac, 0x00c5},
+ {0x00ff, 0x00ff},
+ {0xc001, 0xc02a},
+ {0xc02d, 0xc02e},
+ {0xc031, 0xc051},
+ {0xc054, 0xc055},
+ {0xc058, 0xc05b},
+ {0xc05e, 0xc05f},
+ {0xc062, 0xc06b},
+ {0xc06e, 0xc07b},
+ {0xc07e, 0xc07f},
+ {0xc082, 0xc085},
+ {0xc088, 0xc089},
+ {0xc08c, 0xc08f},
+ {0xc092, 0xc09d},
+ {0xc0a0, 0xc0a1},
+ {0xc0a4, 0xc0a5},
+ {0xc0a8, 0xc0a9},
+ };
+
+ if (cipher == nullptr) {
+ return false;
+ }
+
+ const uint16_t cipher_id = SSL_CIPHER_get_protocol_id(cipher);
+
+ return std::any_of(std::begin(prohibited_ranges),
std::end(prohibited_ranges),
+ [cipher_id](const CipherRange &range) { return cipher_id
>= range.first && cipher_id <= range.last; });
+}
+
int
ssl_alpn_select_callback(SSL *ssl, const unsigned char **out, unsigned char
*outlen, const unsigned char *in, unsigned inlen,
void *)
@@ -381,6 +430,10 @@ ssl_alpn_select_callback(SSL *ssl, const unsigned char
**out, unsigned char *out
ink_assert(alpns);
if (alpns) {
+ if (const SSL_CIPHER *cipher = SSL_get_pending_cipher(ssl);
is_http2_prohibited_cipher(cipher)) {
+ Dbg(dbg_ctl_ssl, "disabling HTTP/2 for prohibited cipher %s",
SSL_CIPHER_get_name(cipher));
+ alpns->disableProtocol(TS_ALPN_PROTOCOL_INDEX_HTTP_2_0);
+ }
return alpns->select_next_protocol(out, outlen, in, inlen);
}
diff --git a/tests/gold_tests/tls/tls_h2_cipher_suite.test.py
b/tests/gold_tests/tls/tls_h2_cipher_suite.test.py
new file mode 100644
index 0000000000..9ab8ca5ca4
--- /dev/null
+++ b/tests/gold_tests/tls/tls_h2_cipher_suite.test.py
@@ -0,0 +1,94 @@
+"""Verify that HTTP/2 is not negotiated with prohibited TLS cipher suites."""
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+Test.Summary = __doc__
+
+Test.SkipUnless(Condition.HasOpenSSLVersion("1.1.1"))
+
+
+class TestH2CipherSuite:
+ """Verify HTTP/2 cipher suite restrictions."""
+
+ def __init__(self) -> None:
+ """Configure Traffic Server and the cipher suite test runs."""
+ self._ts = self._configure_traffic_server()
+ self._configure_allowed_cipher_test()
+
+ for description, cipher in (
+ ("non-ephemeral AEAD", "AES128-GCM-SHA256"),
+ ("ephemeral CBC", "ECDHE-RSA-AES128-SHA256"),
+ ):
+ self._configure_prohibited_cipher_test(description, cipher)
+
+ def _configure_traffic_server(self) -> 'Process':
+ """Configure Traffic Server with allowed and prohibited cipher
suites."""
+ ts = Test.MakeATSProcess("ts", enable_tls=True)
+ ts.addSSLfile("ssl/server.pem")
+ ts.addSSLfile("ssl/server.key")
+
+ ts.Disk.ssl_multicert_yaml.AddLines(
+ """
+ssl_multicert:
+ - dest_ip: "*"
+ ssl_cert_name: server.pem
+ ssl_key_name: server.key
+""".split("\n"))
+
+ ts.Disk.records_config.update(
+ {
+ "proxy.config.ssl.server.cert.path": ts.Variables.SSLDir,
+ "proxy.config.ssl.server.private_key.path":
ts.Variables.SSLDir,
+ "proxy.config.ssl.server.version.min": 2,
+ "proxy.config.ssl.server.version.max": 2,
+ "proxy.config.ssl.server.cipher_suite":
+ "ECDHE-RSA-AES128-GCM-SHA256:"
+ "AES128-GCM-SHA256:"
+ "ECDHE-RSA-AES128-SHA256:"
+ "@SECLEVEL=0",
+ })
+ return ts
+
+ def _configure_allowed_cipher_test(self) -> None:
+ """Verify that an ephemeral AEAD cipher can negotiate HTTP/2."""
+ tr = Test.AddTestRun("Allow HTTP/2 with an ephemeral AEAD cipher")
+ tr.Processes.Default.Command = (
+ "openssl s_client -tls1_2 -cipher ECDHE-RSA-AES128-GCM-SHA256 "
+ f"-alpn h2,http/1.1 -connect
127.0.0.1:{self._ts.Variables.ssl_port} </dev/null")
+ tr.Processes.Default.StartBefore(self._ts)
+ tr.Processes.Default.Streams.All += Testers.IncludesExpression("ALPN
protocol: h2", "HTTP/2 should be negotiated")
+ tr.ReturnCode = 0
+ tr.StillRunningAfter = self._ts
+
+ def _configure_prohibited_cipher_test(self, description: str, cipher: str)
-> None:
+ """Verify that a prohibited cipher falls back to HTTP/1.1.
+
+ :param description: A human-readable description of the cipher.
+ :param cipher: The OpenSSL cipher suite name.
+ """
+ tr = Test.AddTestRun(f"Fall back to HTTP/1.1 with a prohibited
{description} cipher")
+ tr.Processes.Default.Command = (
+ "printf 'GET / HTTP/1.1\\r\\nHost: example.com\\r\\nConnection:
close\\r\\n\\r\\n' | "
+ f"openssl s_client -ign_eof -tls1_2 -cipher {cipher} "
+ f"-alpn h2,http/1.1 -connect
127.0.0.1:{self._ts.Variables.ssl_port}")
+ tr.Processes.Default.Streams.All += Testers.IncludesExpression("ALPN
protocol: http/1.1", "HTTP/1.1 should be negotiated")
+ tr.Processes.Default.Streams.All +=
Testers.IncludesExpression("HTTP/1.1 404", "The HTTP/1.1 request should be
processed")
+ tr.ReturnCode = 0
+ tr.StillRunningAfter = self._ts
+
+
+TestH2CipherSuite()