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()

Reply via email to