Repository: urllib3/urllib3

Issue:
Usage of `md5` in `util/ssl_.py` breaks in FIPS mode
### Context

Python compiled for FIPS (built with FIPS compatible openssl) does not allow the use of the md5 algorithm. Typically, md5 is completely disabled by a custom patch to Python to prevent the usage of md5 for FIPS compliance, as Python may fallback to its own md5 implementation if openssl does not provide one, which is true for FIPS.

The import of `md5` from the `hashlib` module is problematic because urllib is widely used in Python, such as when installing `pip` and this completely breaks down when FIPS mode is enabled.
https://github.com/urllib3/urllib3/blob/main/src/urllib3/util/ssl_.py
```
$ python3 -m ensurepip --upgrade --default-pip
...
  File "/tmp/tmp052h8i17/pip-24.0-py3-none-any.whl/pip/_vendor/requests/__init__.py", line 43, in <module>
  File "/tmp/tmp052h8i17/pip-24.0-py3-none-any.whl/pip/_vendor/urllib3/__init__.py", line 13, in <module>
  File "/tmp/tmp052h8i17/pip-24.0-py3-none-any.whl/pip/_vendor/urllib3/connectionpool.py", line 12, in <module>
  File "/tmp/tmp052h8i17/pip-24.0-py3-none-any.whl/pip/_vendor/urllib3/connection.py", line 15, in <module>
  File "/tmp/tmp052h8i17/pip-24.0-py3-none-any.whl/pip/_vendor/urllib3/util/__init__.py", line 8, in <module>
  File "/tmp/tmp052h8i17/pip-24.0-py3-none-any.whl/pip/_vendor/urllib3/util/ssl_.py", line 8, in <module>
ImportError: cannot import name 'md5' from 'hashlib' (/opt/python-fips/lib/python3.12/hashlib.py)
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "/opt/python-fips/lib/python3.12/ensurepip/__main__.py", line 5, in <module>
    sys.exit(ensurepip._main())
             ^^^^^^^^^^^^^^^^^
  File "/opt/python-fips/lib/python3.12/ensurepip/__init__.py", line 284, in _main
    return _bootstrap(
           ^^^^^^^^^^^
  File "/opt/python-fips/lib/python3.12/ensurepip/__init__.py", line 200, in _bootstrap
    return _run_pip([*args, *_PACKAGE_NAMES], additional_paths)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/python-fips/lib/python3.12/ensurepip/__init__.py", line 101, in _run_pip
    return subprocess.run(cmd, check=True).returncode
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/python-fips/lib/python3.12/subprocess.py", line 571, in run
    raise CalledProcessError(retcode, process.args,
subprocess.CalledProcessError: Command '['/usr/local/bin/python3', '-W', 'ignore::DeprecationWarning', '-c', '\nimport runpy\nimport sys\nsys.path = [\'/tmp/tmp052h8i17/pip-24.0-py3-none-any.whl\'] + sys.path\nsys.argv[1:] = [\'install\', \'--no-cache-dir\', \'--no-index\', \'--find-links\', \'/tmp/tmp052h8i17\', \'--upgrade\', \'pip\']\n
```

As a result, the `urllib` module is unusable with Python compiled for FIPS. 

Would it be possible to provide a way to get around this problem, given that in such an environment I would like to disable the usage of md5?



Discussion:
Thanks for reporting this! We should make our usage of hashes lazy so that removed hashes don't block urllib3's usage in FIPS mode (or other scenarios where hashes are removed).
A fix will need to end up in 1.26.x to be available for pip. And one more option is creating `HASHFUNC_MAP` dynamically
```python
HASHFUNC_MAP = {length: getattr(hashlib, alg, None) for length, alg in ((32, "md5"), (40, "sha1"), (64, "sha256"))}
```

This should be more backwards compatible since `HASHFUNC_MAP` is kind of public API

We'll need a different error message for cases when a fingerprint is of a valid length, but a needed hash function is absent
https://github.com/urllib3/urllib3/blob/07f236ba64a635d01498cc6ec17d471cc594a100/src/urllib3/util/ssl_.py#L162-L164
Agreed @illia-v, since this is a non-trivial amount of work I've attached a bounty. Fix must go into both 2.x and 1.26.x
Besides the changes noted in https://github.com/urllib3/urllib3/issues/3432#issuecomment-2256669232, is there additional work involved?
@shashankram I don't think so, all that's needed is what's in the issue already :)
@sethmlarson I can help with this!



Patch:
diff --git a/changelog/3432.bugfix.rst b/changelog/3432.bugfix.rst
new file mode 100644
index 0000000000..2464d77220
--- /dev/null
+++ b/changelog/3432.bugfix.rst
@@ -0,0 +1,1 @@
+Fixed a crash where certain standard library hash functions were absent in restricted environments.
diff --git a/src/urllib3/util/ssl_.py b/src/urllib3/util/ssl_.py
index c46dd83e53..b16779f153 100644
--- a/src/urllib3/util/ssl_.py
+++ b/src/urllib3/util/ssl_.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import hashlib
 import hmac
 import os
 import socket
@@ -7,7 +8,6 @@
 import typing
 import warnings
 from binascii import unhexlify
-from hashlib import md5, sha1, sha256
 
 from ..exceptions import ProxySchemeUnsupported, SSLError
 from .url import _BRACELESS_IPV6_ADDRZ_RE, _IPV4_RE
@@ -21,7 +21,10 @@
 _TYPE_VERSION_INFO = typing.Tuple[int, int, int, str, int]
 
 # Maps the length of a digest to a possible hash function producing this digest
-HASHFUNC_MAP = {32: md5, 40: sha1, 64: sha256}
+HASHFUNC_MAP = {
+    length: getattr(hashlib, algorithm, None)
+    for length, algorithm in ((32, "md5"), (40, "sha1"), (64, "sha256"))
+}
 
 
 def _is_bpo_43522_fixed(
@@ -159,9 +162,13 @@ def assert_fingerprint(cert: bytes | None, fingerprint: str) -> None:
 
     fingerprint = fingerprint.replace(":", "").lower()
     digest_length = len(fingerprint)
-    hashfunc = HASHFUNC_MAP.get(digest_length)
-    if not hashfunc:
+    if digest_length not in HASHFUNC_MAP:
         raise SSLError(f"Fingerprint of invalid length: {fingerprint}")
+    hashfunc = HASHFUNC_MAP.get(digest_length)
+    if hashfunc is None:
+        raise SSLError(
+            f"Hash function implementation unavailable for fingerprint length: {digest_length}"
+        )
 
     # We need encode() here for py32; works on py2 and p33.
     fingerprint_bytes = unhexlify(fingerprint.encode())


Test patch:
diff --git a/test/with_dummyserver/test_https.py b/test/with_dummyserver/test_https.py
index b8353d758b..5d3194803a 100644
--- a/test/with_dummyserver/test_https.py
+++ b/test/with_dummyserver/test_https.py
@@ -990,6 +990,26 @@ def test_ssl_context_ssl_version_uses_ssl_min_max_versions(self) -> None:
         assert ctx.minimum_version == self.tls_version()
         assert ctx.maximum_version == self.tls_version()
 
+    def test_assert_missing_hashfunc(self, monkeypatch: pytest.MonkeyPatch) -> None:
+        fingerprint = "55:39:BF:70:05:12:43:FA:1F:D1:BF:4E:E8:1B:07:1D"
+        with HTTPSConnectionPool(
+            "localhost",
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_certs=DEFAULT_CA,
+            assert_fingerprint=(fingerprint),
+            ssl_minimum_version=self.tls_version(),
+        ) as https_pool:
+            digest_length = len(fingerprint.replace(":", "").lower())
+            monkeypatch.setitem(urllib3.util.ssl_.HASHFUNC_MAP, digest_length, None)
+            with pytest.raises(MaxRetryError) as cm:
+                https_pool.request("GET", "/", retries=0)
+            assert type(cm.value.reason) is SSLError
+            assert (
+                f"Hash function implementation unavailable for fingerprint length: {digest_length}"
+                == str(cm.value.reason)
+            )
+
 
 @pytest.mark.usefixtures("requires_tlsv1")
 class TestHTTPS_TLSv1(BaseTestHTTPS):