Drop support for OpenSSL <1.1.1
MODIFY
.readthedocs.yml
.readthedocs.yml
version: 2
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3"
+
python:
install:
- requirements: docs/requirements.txt

ADD
changelog/2168.removal.rst
+Removed support for OpenSSL versions earlier than 1.1.1 or that don't have SNI support.
+When an incompatible OpenSSL version is detected an ``ImportError`` is raised.
+
+Removed support for Python with an ``ssl`` module compiled with LibreSSL, CiscoSSL,
+wolfSSL, and all other OpenSSL alternatives. Python is moving to require OpenSSL with PEP 644.
+
+Changed ``urllib3.util.create_urllib3_context`` to not override the system cipher suites with
+a default value. The new default will be cipher suites configured by the operating system.
+
+Removed ``DEFAULT_CIPHERS``, ``HAS_SNI``, ``USE_DEFAULT_SSLCONTEXT_CIPHERS``, from the private module ``urllib3.util.ssl_``.
+
+Removed ``urllib3.exceptions.SNIMissingWarning``.

ADD
ci/0002-Stop-relying-on-removed-DEFAULT_CIPHERS.patch
+From dcc55a54fe2ba3b403923e95ab329009a9f430e2 Mon Sep 17 00:00:00 2001
+From: Quentin Pradet <quentin.pradet@gmail.com>
+Date: Fri, 19 Aug 2022 11:02:11 +0400
+Subject: [PATCH] Stop relying on removed DEFAULT_CIPHERS
+
+---
+ botocore/httpsession.py | 4 ++--
+ 1 file changed, 2 insertions(+), 2 deletions(-)
+
+diff --git a/botocore/httpsession.py b/botocore/httpsession.py
+index 29b210377..aaecb454b 100644
+--- a/botocore/httpsession.py
++++ b/botocore/httpsession.py
+@@ -19,7 +19,6 @@ from urllib3.exceptions import ReadTimeoutError as URLLib3ReadTimeoutError
+ from urllib3.exceptions import SSLError as URLLib3SSLError
+ from urllib3.util.retry import Retry
+ from urllib3.util.ssl_ import (
+- DEFAULT_CIPHERS,
+ OP_NO_COMPRESSION,
+ PROTOCOL_TLS,
+ OP_NO_SSLv2,
+@@ -99,7 +98,8 @@ def create_urllib3_context(
+
+ context = SSLContext(ssl_version)
+
+- context.set_ciphers(ciphers or DEFAULT_CIPHERS)
++ if ciphers:
++ context.set_ciphers(ciphers)
+
+ # Setting the default here, as we may have no ssl module on import
+ cert_reqs = ssl.CERT_REQUIRED if cert_reqs is None else cert_reqs
+--
+2.37.2
+

MODIFY
ci/requests.patch
ci/requests.patch
+diff --git a/tests/__init__.py b/tests/__init__.py
+index 04385be1..e69de29b 100644
+--- a/tests/__init__.py
++++ b/tests/__init__.py
+@@ -1,10 +0,0 @@
+-"""Requests test package initialisation."""
+-
+-import warnings
+-
+-from urllib3.exceptions import SNIMissingWarning
+-
+-# urllib3 sets SNIMissingWarning to only go off once,
+-# while this test suite requires it to always fire
+-# so that it occurs during test_requests.test_https_warnings
+-warnings.simplefilter("always", SNIMissingWarning)
diff --git a/tests/test_requests.py b/tests/test_requests.py
-index b77cba00..5a18d72c 100644
+index 5b4c3f53..2530b13b 100644
--- a/tests/test_requests.py
+++ b/tests/test_requests.py
-@@ -874,6 +874,7 @@ class TestRequests:
+@@ -974,6 +974,7 @@ class TestRequests:
r = requests.get(httpbin(), cert=".")
assert r.status_code == 200

MODIFY
docs/requirements.txt
docs/requirements.txt
-r ../dev-requirements.txt
sphinx>3.0.0
-requests>=2,<2.16
+requests
furo
sphinx-copybutton

MODIFY
noxfile.py
noxfile.py
@@ -92,12 +92,11 @@ def downstream_botocore(session: nox.Session) -> None:
session.cd(tmp_dir)
git_clone(session, "https://github.com/boto/botocore")
session.chdir("botocore")
- session.run(
- "git",
- "apply",
- f"{root}/ci/0001-Mark-100-Continue-tests-as-failing.patch",
- external=True,
- )
+ for patch in [
+ "0001-Mark-100-Continue-tests-as-failing.patch",
+ "0002-Stop-relying-on-removed-DEFAULT_CIPHERS.patch",
+ ]:
+ session.run("git", "apply", f"{root}/ci/{patch}", external=True)
session.run("git", "rev-parse", "HEAD", external=True)
session.run("python", "scripts/ci/install")

MODIFY
src/urllib3/__init__.py
src/urllib3/__init__.py
@@ -20,6 +20,26 @@ from .util.request import make_headers
from .util.retry import Retry
from .util.timeout import Timeout
+# Ensure that Python is compiled with OpenSSL 1.1.1+
+# If the 'ssl' module isn't available at all that's
+# fine, we only care if the module is available.
+try:
+ import ssl
+except ImportError:
+ pass
+else:
+ # fmt: off
+ if (
+ not ssl.OPENSSL_VERSION.startswith("OpenSSL ")
+ or ssl.OPENSSL_VERSION_INFO < (1, 1, 1)
+ ): # Defensive:
+ raise ImportError(
+ "urllib3 v2.0 only supports OpenSSL 1.1.1+, currently "
+ f"the 'ssl' module is compiled with {ssl.OPENSSL_VERSION}. "
+ "See: https://github.com/urllib3/urllib3/issues/2168"
+ )
+ # fmt: on
+
# === NOTE TO REPACKAGERS AND VENDORS ===
# Please delete this block, this logic is only
# for urllib3 being distributed via PyPI.
@@ -91,8 +111,6 @@ del NullHandler
warnings.simplefilter("always", exceptions.SecurityWarning, append=True)
# InsecurePlatformWarning's don't vary between requests, so we keep it default.
warnings.simplefilter("default", exceptions.InsecurePlatformWarning, append=True)
-# SNIMissingWarnings should go off only once.
-warnings.simplefilter("default", exceptions.SNIMissingWarning, append=True)
def disable_warnings(category: Type[Warning] = exceptions.HTTPWarning) -> None:

MODIFY
src/urllib3/connection.py
src/urllib3/connection.py
@@ -616,6 +616,9 @@ def _ssl_wrap_socket_and_match_hostname(
context.check_hostname = False
# Try to load OS default certs if none are given.
+ # We need to do the hasattr() check for our custom
+ # pyOpenSSL and SecureTransport SSLContext objects
+ # because neither support load_default_certs().
if (
not ca_certs
and not ca_cert_dir

MODIFY
src/urllib3/contrib/pyopenssl.py
src/urllib3/contrib/pyopenssl.py
@@ -75,14 +75,6 @@ if TYPE_CHECKING:
__all__ = ["inject_into_urllib3", "extract_from_urllib3"]
-# SNI always works.
-HAS_SNI = True
-
-# Use system TLS ciphers on OpenSSL 1.1.1+
-USE_DEFAULT_SSLCONTEXT_CIPHERS = util.ssl_._is_ge_openssl_v1_1_1(
- openssl_backend.openssl_version_text(), openssl_backend.openssl_version_number()
-)
-
# Map from urllib3 to PyOpenSSL compatible parameter-values.
_openssl_versions = {
util.ssl_.PROTOCOL_TLS: OpenSSL.SSL.SSLv23_METHOD, # type: ignore[attr-defined]
@@ -147,9 +139,7 @@ _openssl_to_ssl_maximum_version: Dict[int, int] = {
# OpenSSL will only write 16K at a time
SSL_WRITE_BLOCKSIZE = 16384
-orig_util_HAS_SNI = util.HAS_SNI
orig_util_SSLContext = util.ssl_.SSLContext
-orig_util_USE_SYSTEM_SSL_CIPHERS = util.ssl_.USE_DEFAULT_SSLCONTEXT_CIPHERS
log = logging.getLogger(__name__)
@@ -162,11 +152,8 @@ def inject_into_urllib3() -> None:
util.SSLContext = PyOpenSSLContext # type: ignore[assignment]
util.ssl_.SSLContext = PyOpenSSLContext # type: ignore[assignment]
- util.HAS_SNI = HAS_SNI
- util.ssl_.HAS_SNI = HAS_SNI
util.IS_PYOPENSSL = True
util.ssl_.IS_PYOPENSSL = True
- util.ssl_.USE_DEFAULT_SSLCONTEXT_CIPHERS = USE_DEFAULT_SSLCONTEXT_CIPHERS
def extract_from_urllib3() -> None:
@@ -174,11 +161,8 @@ def extract_from_urllib3() -> None:
util.SSLContext = orig_util_SSLContext
util.ssl_.SSLContext = orig_util_SSLContext
- util.HAS_SNI = orig_util_HAS_SNI
- util.ssl_.HAS_SNI = orig_util_HAS_SNI
util.IS_PYOPENSSL = False
util.ssl_.IS_PYOPENSSL = False
- util.ssl_.USE_DEFAULT_SSLCONTEXT_CIPHERS = orig_util_USE_SYSTEM_SSL_CIPHERS
def _validate_dependencies_met() -> None:

MODIFY
src/urllib3/contrib/securetransport.py
src/urllib3/contrib/securetransport.py
@@ -105,12 +105,7 @@ if TYPE_CHECKING:
__all__ = ["inject_into_urllib3", "extract_from_urllib3"]
-# SNI always works
-HAS_SNI = True
-
-orig_util_HAS_SNI = util.HAS_SNI
orig_util_SSLContext = util.ssl_.SSLContext
-orig_util_USE_SYSTEM_SSL_CIPHERS = util.ssl_.USE_DEFAULT_SSLCONTEXT_CIPHERS
# This dictionary is used by the read callback to obtain a handle to the
# calling wrapped socket. This is a pretty silly approach, but for now it'll
@@ -190,11 +185,8 @@ def inject_into_urllib3() -> None:
"""
util.SSLContext = SecureTransportContext # type: ignore[assignment]
util.ssl_.SSLContext = SecureTransportContext # type: ignore[assignment]
- util.HAS_SNI = HAS_SNI
- util.ssl_.HAS_SNI = HAS_SNI
util.IS_SECURETRANSPORT = True
util.ssl_.IS_SECURETRANSPORT = True
- util.ssl_.USE_DEFAULT_SSLCONTEXT_CIPHERS = True
def extract_from_urllib3() -> None:
@@ -203,11 +195,8 @@ def extract_from_urllib3() -> None:
"""
util.SSLContext = orig_util_SSLContext
util.ssl_.SSLContext = orig_util_SSLContext
- util.HAS_SNI = orig_util_HAS_SNI
- util.ssl_.HAS_SNI = orig_util_HAS_SNI
util.IS_SECURETRANSPORT = False
util.ssl_.IS_SECURETRANSPORT = False
- util.ssl_.USE_DEFAULT_SSLCONTEXT_CIPHERS = orig_util_USE_SYSTEM_SSL_CIPHERS
def _read_callback(

MODIFY
src/urllib3/exceptions.py
src/urllib3/exceptions.py
@@ -252,12 +252,6 @@ class InsecurePlatformWarning(SecurityWarning):
pass
-class SNIMissingWarning(HTTPWarning):
- """Warned when making a HTTPS request without SNI available."""
-
- pass
-
-
class DependencyWarning(HTTPWarning):
"""
Warned when an attempt is made to import a module with missing optional

MODIFY
src/urllib3/util/__init__.py
src/urllib3/util/__init__.py
@@ -5,7 +5,6 @@ from .response import is_fp_closed
from .retry import Retry
from .ssl_ import (
ALPN_PROTOCOLS,
- HAS_SNI,
IS_PYOPENSSL,
IS_SECURETRANSPORT,
SSLContext,
@@ -20,7 +19,6 @@ from .url import Url, parse_url
from .wait import wait_for_read, wait_for_write
__all__ = (
- "HAS_SNI",
"IS_PYOPENSSL",
"IS_SECURETRANSPORT",
"SSLContext",

MODIFY
src/urllib3/util/ssl_.py
src/urllib3/util/ssl_.py
@@ -7,17 +7,15 @@ from binascii import unhexlify
from hashlib import md5, sha1, sha256
from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union, cast, overload
-from ..exceptions import ProxySchemeUnsupported, SNIMissingWarning, SSLError
+from ..exceptions import ProxySchemeUnsupported, SSLError
from .url import _BRACELESS_IPV6_ADDRZ_RE, _IPV4_RE
SSLContext = None
SSLTransport = None
-HAS_SNI = False
HAS_NEVER_CHECK_COMMON_NAME = False
IS_PYOPENSSL = False
IS_SECURETRANSPORT = False
ALPN_PROTOCOLS = ["http/1.1"]
-USE_DEFAULT_SSLCONTEXT_CIPHERS = False
_TYPE_VERSION_INFO = Tuple[int, int, int, str, int]
@@ -25,19 +23,6 @@ _TYPE_VERSION_INFO = Tuple[int, int, int, str, int]
HASHFUNC_MAP = {32: md5, 40: sha1, 64: sha256}
-def _is_ge_openssl_v1_1_1(
- openssl_version_text: str, openssl_version_number: int
-) -> bool:
- """Returns True for OpenSSL 1.1.1+ (>=0x10101000)
- LibreSSL reports a version number of 0x20000000 for
- OpenSSL version number so we need to filter out LibreSSL.
- """
- return (
- not openssl_version_text.startswith("LibreSSL")
- and openssl_version_number >= 0x10101000
- )
-
-
def _is_openssl_issue_14579_fixed(
openssl_version_text: str, openssl_version_number: int
) -> bool:
@@ -117,7 +102,6 @@ try: # Do we have ssl at all?
from ssl import ( # type: ignore[misc]
CERT_REQUIRED,
HAS_NEVER_CHECK_COMMON_NAME,
- HAS_SNI,
OP_NO_COMPRESSION,
OP_NO_TICKET,
OPENSSL_VERSION,
@@ -130,9 +114,6 @@ try: # Do we have ssl at all?
TLSVersion,
)
- USE_DEFAULT_SSLCONTEXT_CIPHERS = _is_ge_openssl_v1_1_1(
- OPENSSL_VERSION, OPENSSL_VERSION_NUMBER
- )
PROTOCOL_SSLv23 = PROTOCOL_TLS
# Setting SSLContext.hostname_checks_common_name = False didn't work before CPython
@@ -167,43 +148,6 @@ except ImportError:
_TYPE_PEER_CERT_RET = Union["_TYPE_PEER_CERT_RET_DICT", bytes, None]
-# A secure default.
-# Sources for more information on TLS ciphers:
-#
-# - https://wiki.mozilla.org/Security/Server_Side_TLS
-# - https://www.ssllabs.com/projects/best-practices/index.html
-# - https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/
-#
-# The general intent is:
-# - prefer cipher suites that offer perfect forward secrecy (DHE/ECDHE),
-# - prefer ECDHE over DHE for better performance,
-# - prefer any AES-GCM and ChaCha20 over any AES-CBC for better performance and
-# security,
-# - prefer AES-GCM over ChaCha20 because hardware-accelerated AES is common,
-# - disable NULL authentication, MD5 MACs, DSS, and other
-# insecure ciphers for security reasons.
-# - NOTE: TLS 1.3 cipher suites are managed through a different interface
-# not exposed by CPython (yet!) and are enabled by default if they're available.
-DEFAULT_CIPHERS = ":".join(
- [
- "ECDHE+AESGCM",
- "ECDHE+CHACHA20",
- "DHE+AESGCM",
- "DHE+CHACHA20",
- "ECDH+AESGCM",
- "DH+AESGCM",
- "ECDH+AES",
- "DH+AES",
- "RSA+AESGCM",
- "RSA+AES",
- "!aNULL",
- "!eNULL",
- "!MD5",
- "!DSS",
- "!AESCCM",
- ]
-)
-
def assert_fingerprint(cert: Optional[bytes], fingerprint: str) -> None:
"""
@@ -282,22 +226,7 @@ def create_urllib3_context(
ssl_minimum_version: Optional[int] = None,
ssl_maximum_version: Optional[int] = None,
) -> "ssl.SSLContext":
- """All arguments have the same meaning as ``ssl_wrap_socket``.
-
- By default, this function does a lot of the same work that
- ``ssl.create_default_context`` does on Python 3.4+. It:
-
- - Disables SSLv2, SSLv3, and compression
- - Sets a restricted set of server ciphers
-
- If you wish to enable SSLv3, you can do::
-
- from urllib3.util import ssl_
- context = ssl_.create_urllib3_context()
- context.options &= ~ssl_.OP_NO_SSLv3
-
- You can do the same to enable compression (substituting ``COMPRESSION``
- for ``SSLv3`` in the last line above).
+ """Creates and configures an :class:`ssl.SSLContext` instance for use with urllib3.
:param ssl_version:
The desired protocol version to use. This will default to
@@ -371,8 +300,8 @@ def create_urllib3_context(
# Unless we're given ciphers defer to either system ciphers in
# the case of OpenSSL 1.1.1+ or use our own secure default ciphers.
- if ciphers is not None or not USE_DEFAULT_SSLCONTEXT_CIPHERS:
- context.set_ciphers(ciphers or DEFAULT_CIPHERS)
+ if ciphers:
+ context.set_ciphers(ciphers)
# Setting the default here, as we may have no ssl module on import
cert_reqs = ssl.CERT_REQUIRED if cert_reqs is None else cert_reqs
@@ -537,23 +466,10 @@ def ssl_wrap_socket(
context.load_cert_chain(certfile, keyfile, key_password)
try:
- if hasattr(context, "set_alpn_protocols"):
context.set_alpn_protocols(ALPN_PROTOCOLS)
except NotImplementedError: # Defensive: in CI, we always have set_alpn_protocols
pass
- if not HAS_SNI and server_hostname and not is_ipaddress(server_hostname):
- warnings.warn(
- "An HTTPS request has been made, but the SNI (Server Name "
- "Indication) extension to TLS is not available on this platform. "
- "This may cause the server to present an incorrect TLS "
- "certificate, which can cause validation failures. You can upgrade to "
- "a newer version of Python to solve this. For more information, see "
- "https://urllib3.readthedocs.io/en/latest/advanced-usage.html"
- "#tls-warnings",
- SNIMissingWarning,
- )
-
ssl_sock = _ssl_wrap_socket_impl(sock, context, tls_in_tls, server_hostname)
return ssl_sock

MODIFY
test/test_ssl.py
test/test_ssl.py
@@ -4,7 +4,7 @@ from unittest import mock
import pytest
-from urllib3.exceptions import ProxySchemeUnsupported, SNIMissingWarning, SSLError
+from urllib3.exceptions import ProxySchemeUnsupported, SSLError
from urllib3.util import ssl_
@@ -42,55 +42,11 @@ class TestSSL:
def test_is_ipaddress_false(self, addr: Union[bytes, str]) -> None:
assert not ssl_.is_ipaddress(addr)
- @pytest.mark.parametrize(
- ["has_sni", "server_hostname", "should_warn"],
- [
- (True, "www.google.com", False),
- (True, "127.0.0.1", False),
- (False, "127.0.0.1", False),
- (False, "www.google.com", True),
- (True, None, False),
- (False, None, False),
- ],
- )
- def test_sni_missing_warning_with_ip_addresses(
- self,
- monkeypatch: pytest.MonkeyPatch,
- has_sni: bool,
- server_hostname: Optional[str],
- should_warn: bool,
- ) -> None:
- monkeypatch.setattr(ssl_, "HAS_SNI", has_sni)
-
- sock = mock.Mock()
- context = mock.create_autospec(ssl_.SSLContext)
-
- with mock.patch("warnings.warn") as warn:
- ssl_.ssl_wrap_socket(
- sock, server_hostname=server_hostname, ssl_context=context
- )
-
- if should_warn:
- assert warn.call_count >= 1
- warnings = [call[0][1] for call in warn.call_args_list]
- assert SNIMissingWarning in warnings
- else:
- assert warn.call_count == 0
-
- @pytest.mark.parametrize(
- ["ciphers", "expected_ciphers"],
- [
- (None, ssl_.DEFAULT_CIPHERS),
- ("ECDH+AESGCM:ECDH+CHACHA20", "ECDH+AESGCM:ECDH+CHACHA20"),
- ],
- )
def test_create_urllib3_context_set_ciphers(
- self,
- monkeypatch: pytest.MonkeyPatch,
- ciphers: Optional[str],
- expected_ciphers: str,
+ self, monkeypatch: pytest.MonkeyPatch
) -> None:
+ ciphers = "ECDH+AESGCM:ECDH+CHACHA20"
context = mock.create_autospec(ssl_.SSLContext)
context.set_ciphers = mock.Mock()
context.options = 0
@@ -98,11 +54,8 @@ class TestSSL:
assert ssl_.create_urllib3_context(ciphers=ciphers) is context
- if ciphers is None and ssl_.USE_DEFAULT_SSLCONTEXT_CIPHERS:
- assert context.set_ciphers.call_count == 0
- else:
assert context.set_ciphers.call_count == 1
- assert context.set_ciphers.call_args == mock.call(expected_ciphers)
+ assert context.set_ciphers.call_args == mock.call(ciphers)
def test_create_urllib3_no_context(self) -> None:
with mock.patch("urllib3.util.ssl_.SSLContext", None):
@@ -172,24 +125,17 @@ class TestSSL:
assert context.post_handshake_auth == expected_pha
- @pytest.mark.parametrize("use_default_sslcontext_ciphers", [True, False])
def test_create_urllib3_context_default_ciphers(
- self, monkeypatch: pytest.MonkeyPatch, use_default_sslcontext_ciphers: bool
+ self, monkeypatch: pytest.MonkeyPatch
) -> None:
context = mock.create_autospec(ssl_.SSLContext)
context.set_ciphers = mock.Mock()
context.options = 0
monkeypatch.setattr(ssl_, "SSLContext", lambda *_, **__: context)
- monkeypatch.setattr(
- ssl_, "USE_DEFAULT_SSLCONTEXT_CIPHERS", use_default_sslcontext_ciphers
- )
ssl_.create_urllib3_context()
- if use_default_sslcontext_ciphers:
context.set_ciphers.assert_not_called()
- else:
- context.set_ciphers.assert_called_with(ssl_.DEFAULT_CIPHERS)
@pytest.mark.parametrize(
"kwargs",

MODIFY
test/test_util.py
test/test_util.py
@@ -13,12 +13,11 @@ from urllib.parse import urlparse
import pytest
-from urllib3 import add_stderr_logger, disable_warnings, util
+from urllib3 import add_stderr_logger, disable_warnings
from urllib3.connection import ProxyConfig
from urllib3.exceptions import (
InsecureRequestWarning,
LocationParseError,
- SNIMissingWarning,
TimeoutStateError,
UnrewindableBodyError,
)
@@ -1038,21 +1037,6 @@ class TestUtilSSL:
)
return mock_context, warn
- def test_ssl_wrap_socket_sni_hostname_use_or_warn(self) -> None:
- """Test that either an SNI hostname is used or a warning is made."""
- sock = Mock()
- context, warn = self._wrap_socket_and_mock_warn(sock, "www.google.com")
- if util.HAS_SNI:
- warn.assert_not_called()
- context.wrap_socket.assert_called_once_with(
- sock, server_hostname="www.google.com"
- )
- else:
- assert warn.call_count >= 1
- warnings = [call[0][1] for call in warn.call_args_list]
- assert SNIMissingWarning in warnings
- context.wrap_socket.assert_called_once_with(sock)
-
def test_ssl_wrap_socket_sni_ip_address_no_warn(self) -> None:
"""Test that a warning is not made if server_hostname is an IP address."""
sock = Mock()

MODIFY
test/with_dummyserver/test_socketlevel.py
test/with_dummyserver/test_socketlevel.py
@@ -84,8 +84,6 @@ class TestCookies(SocketDummyServerTestCase):
class TestSNI(SocketDummyServerTestCase):
def test_hostname_in_first_request_packet(self) -> None:
- if not util.HAS_SNI:
- pytest.skip("SNI-support not available")
done_receiving = Event()
self.buf = b""

Drop support for OpenSSL <1.1.1 (#2705)

Co-authored-by: Quentin Pradet <quentin.pradet@gmail.com>