diff --git a/src/iocore/net/SSLUtils.cc b/src/iocore/net/SSLUtils.cc index e09df82dfe2..2086798f4ef 100644 --- a/src/iocore/net/SSLUtils.cc +++ b/src/iocore/net/SSLUtils.cc @@ -71,6 +71,7 @@ #endif #include +#include #include #include #include @@ -95,6 +96,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"}; @@ -369,6 +371,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 *) @@ -377,6 +426,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 00000000000..9ab8ca5ca45 --- /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} 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()