Repository: wagtail/wagtail

Issue:
Admin cannot be accessed on FIPS-mode system
### Issue Summary

When accessing the Wagtail admin on a security-restricted system (such as on SELinux with FIPS mode enabled), "Server Error (500)" is produced. This prevents Wagtail from being used on new U.S. Government systems, for example.

The issue stems from this code (dealing with the Gravatar handling for the sidebar on the admin UI):

https://github.com/wagtail/wagtail/blob/b3b53c8b708aed6af26e28b21272a0509c152b9e/wagtail/users/utils.py#L43

On security-restricted systems, the MD5 hash algorithm is disabled and produces an error such as:

    [digital envelope routines: EVP_DigestInit_ex] disabled for FIPS

Starting with Python 3.9, it's possible to pass a flag `usedforsecurity=False` to MD5 to indicate that it's not being used for security but for fingerprinting or other non-secure purposes.

### Steps to Reproduce

```
$ cat /etc/redhat-release 
Red Hat Enterprise Linux release 8.7 (Ootpa)
$ fips-mode-setup --check
FIPS mode is enabled.
$ python3 -c 'import hashlib; hashlib.md5(b"")'
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ValueError: [digital envelope routines: EVP_DigestInit_ex] disabled for FIPS
$ python3 -m venv venv
$ cd venv
$ . bin/activate
(venv) $ pip install --quiet wagtail
(venv) $ wagtail start fips
Creating a Wagtail project called fips
Success! fips has been created
(venv) $ cd fips
(venv) $ ./manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, home, sessions, taggit, wagtailadmin, wagtailcore, wagtaildocs, wagtailembeds, wagtailforms, wagtailimages, wagtailredirects, wagtailsearch, wagtailusers
Running migrations:
  Applying contenttypes.0001_initial... OK
…
Applying wagtailusers.0011_userprofile_dismissibles... OK
(venv) $ ./manage.py createsuperuser --email x@y.com --username admin 
Password: 
Password (again): 
Superuser created successfully.
(venv) $ ./manage.py runserver 8080
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
March 03, 2023 - 15:36:51
Django version 4.1.7, using settings 'fips.settings.dev'
Starting development server at http://127.0.0.1:8080/
Quit the server with CONTROL-C.
```
At this point, visit http://localhost:8080/admin and log in. The console then displays:
```
[03/Mar/2023 15:40:22] "POST /admin/login/ HTTP/1.1" 302 0
Internal Server Error: /admin/
Traceback (most recent call last):
  File "venv/lib/python3.9/site-packages/telepath/__init__.py", line 318, in build_node
    existing_node = self.nodes[obj_id]
KeyError: 139742870267072

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "venv/lib/python3.9/site-packages/telepath/__init__.py", line 318, in build_node
    existing_node = self.nodes[obj_id]
KeyError: 139742896011872

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "venv/lib/python3.9/site-packages/django/core/handlers/exception.py", line 56, in inner
    response = get_response(request)
  File "venv/lib/python3.9/site-packages/django/core/handlers/base.py", line 220, in _get_response
    response = response.render()
  File "venv/lib/python3.9/site-packages/django/template/response.py", line 114, in render
    self.content = self.rendered_content
  File "venv/lib/python3.9/site-packages/django/template/response.py", line 92, in rendered_content
    return template.render(context, self._request)
  File "venv/lib/python3.9/site-packages/django/template/backends/django.py", line 61, in render
    return self.template.render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 175, in render
    return self._render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 167, in _render
    return self.nodelist.render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in render
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in <listcomp>
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 966, in render_annotated
    return self.render(context)
  File "venv/lib/python3.9/site-packages/django/template/loader_tags.py", line 157, in render
    return compiled_parent._render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 167, in _render
    return self.nodelist.render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in render
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in <listcomp>
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 966, in render_annotated
    return self.render(context)
  File "venv/lib/python3.9/site-packages/django/template/loader_tags.py", line 157, in render
    return compiled_parent._render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 167, in _render
    return self.nodelist.render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in render
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in <listcomp>
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 966, in render_annotated
    return self.render(context)
  File "venv/lib/python3.9/site-packages/django/template/loader_tags.py", line 157, in render
    return compiled_parent._render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 167, in _render
    return self.nodelist.render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in render
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in <listcomp>
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 966, in render_annotated
    return self.render(context)
  File "venv/lib/python3.9/site-packages/django/template/loader_tags.py", line 157, in render
    return compiled_parent._render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 167, in _render
    return self.nodelist.render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in render
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in <listcomp>
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 966, in render_annotated
    return self.render(context)
  File "venv/lib/python3.9/site-packages/django/template/loader_tags.py", line 63, in render
    result = block.nodelist.render(context)
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in render
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 1005, in <listcomp>
    return SafeString("".join([node.render_annotated(context) for node in self]))
  File "venv/lib/python3.9/site-packages/django/template/base.py", line 966, in render_annotated
    return self.render(context)
  File "venv/lib/python3.9/site-packages/django/template/library.py", line 237, in render
    output = self.func(*resolved_args, **resolved_kwargs)
  File "venv/lib/python3.9/site-packages/wagtail/admin/templatetags/wagtailadmin_tags.py", line 944, in sidebar_props
    "modules": JSContext().pack(modules),
  File "venv/lib/python3.9/site-packages/telepath/__init__.py", line 220, in pack
    return ValueContext(self).build_node(obj).emit()
  File "venv/lib/python3.9/site-packages/telepath/__init__.py", line 321, in build_node
    node = self._build_new_node(val)
  File "venv/lib/python3.9/site-packages/telepath/__init__.py", line 354, in _build_new_node
    return ListNode([self.build_node(item) for item in items])
  File "venv/lib/python3.9/site-packages/telepath/__init__.py", line 354, in <listcomp>
    return ListNode([self.build_node(item) for item in items])
  File "venv/lib/python3.9/site-packages/telepath/__init__.py", line 321, in build_node
    node = self._build_new_node(val)
  File "venv/lib/python3.9/site-packages/telepath/__init__.py", line 339, in _build_new_node
    return adapter.build_node(obj, self)
  File "venv/lib/python3.9/site-packages/telepath/__init__.py", line 167, in build_node
    constructor, args = self.pack(obj, context)
  File "venv/lib/python3.9/site-packages/telepath/__init__.py", line 164, in pack
    return (self.js_constructor, self.js_args(obj))
  File "venv/lib/python3.9/site-packages/wagtail/telepath.py", line 68, in <lambda>
    "js_args": lambda self, obj: obj.js_args(),
  File "venv/lib/python3.9/site-packages/wagtail/admin/ui/sidebar.py", line 211, in js_args
    "avatarUrl": avatar_url(self.user, size=50),
  File "venv/lib/python3.9/site-packages/wagtail/admin/templatetags/wagtailadmin_tags.py", line 714, in avatar_url
    gravatar_url = get_gravatar_url(user.email, size=size)
  File "venv/lib/python3.9/site-packages/wagtail/users/utils.py", line 43, in get_gravatar_url
    hash=hashlib.md5(email.lower().encode("utf-8")).hexdigest(),
ValueError: [digital envelope routines: EVP_DigestInit_ex] disabled for FIPS
[03/Mar/2023 15:40:22] "GET /admin/ HTTP/1.1" 500 340554
```

- I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes ✅


### Workaround

Patch `wagtail/users/utils.py` changing 43 to read:
```python
hash=hashlib.md5(email.lower().encode("utf-8"), usedforsecurity=False).hexdigest(),
```
👉 **Note:** This requires Python 3.9 or higher.


### Additional Uses

@laymonage points out that there's a second call to `md5` in https://github.com/wagtail/wagtail/blob/b3b53c8b708aed6af26e28b21272a0509c152b9e/wagtail/embeds/embeds.py#L68-L69

### Technical details

-   Python version: Run `python --version`: `Python 3.9.12`
-   Django version: Look in your requirements.txt, or run `pip show django | grep Version`: `Version: 4.1.7`
-   Wagtail version: Look at the bottom of the Settings menu in the Wagtail admin, or run `pip show wagtail | grep Version:`: `Version: 4.2`
-   Browser version: You can use https://www.whatsmybrowser.org/ to find this out: `You're using Lynx 2. If you believe this is incorrect, then please email us to let us know!`






Discussion:

Resolving patch:
diff --git a/wagtail/coreutils.py b/wagtail/coreutils.py
--- a/wagtail/coreutils.py
+++ b/wagtail/coreutils.py
@@ -20,6 +20,7 @@
 from django.utils.text import capfirst, slugify
 from django.utils.translation import check_for_language, get_supported_language_variant
 from django.utils.translation import gettext_lazy as _
+from hashlib import md5
 
 if TYPE_CHECKING:
     from wagtail.models import Site
@@ -421,6 +422,27 @@ def get_dummy_request(*, path: str = "/", site: "Site" = None) -> HttpRequest:
     return RequestFactory(SERVER_NAME=server_name).get(path, SERVER_PORT=server_port)
 
 
+def safe_md5(data=b"", usedforsecurity=True):
+    """
+    Safely use the MD5 hash algorithm with the given ``data`` and a flag
+    indicating if the purpose of the digest is for security or not.
+
+    On security-restricted systems (such as FIPS systems), insecure hashes
+    like MD5 are disabled by default. But passing ``usedforsecurity`` as
+    ``False`` tells the underlying security implementation we're not trying
+    to use the digest for secure purposes and to please just go ahead and
+    allow it to happen.
+    """
+
+    # Although ``accepts_kwarg`` works great on Python 3.8+, on Python 3.7 it
+    # raises a ValueError, saying "no signature found for builtin". So, back
+    # to the try/except.
+    try:
+        return md5(data, usedforsecurity=usedforsecurity)
+    except TypeError:
+        return md5(data)
+
+
 class BatchProcessor:
     """
     A class to help with processing of an unknown (and potentially very
diff --git a/wagtail/embeds/embeds.py b/wagtail/embeds/embeds.py
--- a/wagtail/embeds/embeds.py
+++ b/wagtail/embeds/embeds.py
@@ -1,9 +1,8 @@
 from datetime import datetime
-from hashlib import md5
 
 from django.utils.timezone import now
 
-from wagtail.coreutils import accepts_kwarg
+from wagtail.coreutils import accepts_kwarg, safe_md5
 
 from .exceptions import EmbedUnsupportedProviderException
 from .finders import get_finders
@@ -66,8 +65,7 @@ def finder(url, max_width=None, max_height=None):
 
 
 def get_embed_hash(url, max_width=None, max_height=None):
-    h = md5()
-    h.update(url.encode("utf-8"))
+    h = safe_md5(url.encode("utf-8"), usedforsecurity=False)
     if max_width is not None:
         h.update(b"\n")
         h.update(str(max_width).encode("utf-8"))
diff --git a/wagtail/users/utils.py b/wagtail/users/utils.py
--- a/wagtail/users/utils.py
+++ b/wagtail/users/utils.py
@@ -1,10 +1,10 @@
-import hashlib
-
 from django.conf import settings
 from django.utils.http import urlencode
 from django.utils.translation import gettext_lazy as _
 
 from wagtail.compat import AUTH_USER_APP_LABEL, AUTH_USER_MODEL_NAME
+from wagtail.coreutils import safe_md5
+
 
 delete_user_perm = "{0}.delete_{1}".format(
     AUTH_USER_APP_LABEL, AUTH_USER_MODEL_NAME.lower()
@@ -38,9 +38,11 @@ def get_gravatar_url(email, size=50):
     if (not email) or (gravatar_provider_url is None):
         return None
 
+    email_bytes = email.lower().encode("utf-8")
+    hash = safe_md5(email_bytes, usedforsecurity=False).hexdigest()
     gravatar_url = "{gravatar_provider_url}/{hash}?{params}".format(
         gravatar_provider_url=gravatar_provider_url.rstrip("/"),
-        hash=hashlib.md5(email.lower().encode("utf-8")).hexdigest(),
+        hash=hash,
         params=urlencode({"s": size, "d": default}),
     )
 


Test patch: