Information Disclosure (UI exposure of password hash details)
Description
The commit changes the rendering of the password hash details in the admin UI from exposing the raw password hash components (algorithm, iterations, salt, and hash) to using a dedicated template tag that masks sensitive parts. Previously ReadOnlyPasswordHashWidget iterated over hasher.safe_summary(value) and rendered the resulting keys and values, which could reveal the hashing parameters and the hash itself. The fix introduces a new templatetag render_password_as_hash that masks the salt and hash values (and gracefully handles invalid formats) and updates the template to use this tag. This reduces information disclosure from the UI, mitigating the risk of offline password cracking or leakage when an admin UI is accessible. Tests were added to cover valid, invalid, and no-password cases.
Proof of Concept
Proof-of-concept (Poc) to demonstrate the disclosure before the fix and the mitigation after the fix:
1) Reproduce the disclosure (pre-patch behavior):
- Prereq: Run a Django instance with the admin site available and a User with a known password.
- Log in to the Django admin as a staff user and navigate to the User change page (e.g., /admin/auth/user/1/change/).
- In the ReadOnlyPasswordHashWidget area, the HTML would render a summary including:
- algorithm: pbkdf2_sha256
- iterations: 100000
- salt: a6Pucb1qSFcD
- hash: WmCkn9Hqidj48NVe5x0FEM6A9YiOqQcl/83m2Z5udm0=
- This exposes the hashing parameters and the hash value directly in the UI, enabling offline cracking attempts if an attacker has admin access or can scrape the page.
2) PoC code to demonstrate offline cracking with the disclosed data (Python, offline):
# Given values scraped from the admin UI before this patch
import base64, hashlib
salt = b"a6Pucb1qSFcD"
iterations = 100000
target_hash = "WmCkn9Hqidj48NVe5x0FEM6A9YiOqQcl/83m2Z5udm0=" # base64 of the derived key
# Simple dictionary attack attempt
candidates = ["password", "letmein", "secret", "admin"]
for pw in candidates:
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, iterations)
if base64.b64encode(dk).decode() == target_hash:
print("Cracked password:", pw)
break
else:
print("Password not found in dictionary (demo, offline attack).")
3) Mitigation (post-patch behavior):
- After applying this commit, the admin UI renders the password hash area via the new render_password_as_hash template tag.
- The tag returns a masked representation for salt and hash (e.g., salt values shown as asterisks and the hash truncated/masked), or a message for invalid formats or when no password is set.
- As a result, the same attack vector shown above would no longer expose workable hashing material in the UI.
Commit Details
Author: Sarah Boyce
Date: 2025-04-16 18:44 UTC
Message:
Refs #35959 -- Added render_password_as_hash auth template tag for password rendering.
Triage Assessment
Vulnerability Type: Information Disclosure
Confidence: HIGH
Reasoning:
The commit replaces direct rendering of password hash summaries with a dedicated template tag that masks sensitive parts (salt, hash) and gracefully handles invalid formats. This reduces exposure of password hash details in the UI, mitigating information disclosure risks associated with displaying raw hash components.
Verification Assessment
Vulnerability Type: Information Disclosure (UI exposure of password hash details)
Confidence: HIGH
Affected Versions: stable/5.1.x before this commit (pre-patch)
Code Diff
diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py
index cd028870270a..7b0b3833b8e1 100644
--- a/django/contrib/auth/forms.py
+++ b/django/contrib/auth/forms.py
@@ -3,7 +3,7 @@
from django import forms
from django.contrib.auth import authenticate, get_user_model, password_validation
-from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX, identify_hasher
+from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX
from django.contrib.auth.models import User
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.shortcuts import get_current_site
@@ -13,7 +13,6 @@
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.text import capfirst
-from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.views.decorators.debug import sensitive_variables
@@ -40,24 +39,6 @@ class ReadOnlyPasswordHashWidget(forms.Widget):
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
usable_password = value and not value.startswith(UNUSABLE_PASSWORD_PREFIX)
- summary = []
- if usable_password:
- try:
- hasher = identify_hasher(value)
- except ValueError:
- summary.append(
- {
- "label": gettext(
- "Invalid password format or unknown hashing algorithm."
- )
- }
- )
- else:
- for key, value_ in hasher.safe_summary(value).items():
- summary.append({"label": gettext(key), "value": value_})
- else:
- summary.append({"label": gettext("No password set.")})
- context["summary"] = summary
context["button_label"] = (
_("Reset password") if usable_password else _("Set password")
)
diff --git a/django/contrib/auth/templates/auth/widgets/read_only_password_hash.html b/django/contrib/auth/templates/auth/widgets/read_only_password_hash.html
index b48204b7ebdc..102ceb30eb7d 100644
--- a/django/contrib/auth/templates/auth/widgets/read_only_password_hash.html
+++ b/django/contrib/auth/templates/auth/widgets/read_only_password_hash.html
@@ -1,8 +1,5 @@
+{% load auth %}
<div{% include 'django/forms/widgets/attrs.html' %}>
- <p>
- {% for entry in summary %}
- <strong>{{ entry.label }}</strong>{% if entry.value %}: <bdi>{{ entry.value }}</bdi>{% endif %}
- {% endfor %}
- </p>
+ {% render_password_as_hash widget.value %}
<p><a role="button" class="button" href="{{ password_url|default:"../password/" }}">{{ button_label }}</a></p>
</div>
diff --git a/django/contrib/auth/templatetags/__init__.py b/django/contrib/auth/templatetags/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/django/contrib/auth/templatetags/auth.py b/django/contrib/auth/templatetags/auth.py
new file mode 100644
index 000000000000..7cdbad859530
--- /dev/null
+++ b/django/contrib/auth/templatetags/auth.py
@@ -0,0 +1,25 @@
+from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX, identify_hasher
+from django.template import Library
+from django.utils.html import format_html, format_html_join
+from django.utils.translation import gettext
+
+register = Library()
+
+
+@register.simple_tag
+def render_password_as_hash(value):
+ if not value or value.startswith(UNUSABLE_PASSWORD_PREFIX):
+ return format_html("<p><strong>{}</strong></p>", gettext("No password set."))
+ try:
+ hasher = identify_hasher(value)
+ hashed_summary = hasher.safe_summary(value)
+ except ValueError:
+ return format_html(
+ "<p><strong>{}</strong></p>",
+ gettext("Invalid password format or unknown hashing algorithm."),
+ )
+ items = [(gettext(key), val) for key, val in hashed_summary.items()]
+ return format_html(
+ "<p>{}</p>",
+ format_html_join(" ", "<strong>{}</strong>: <bdi>{}</bdi>", items),
+ )
diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py
index 0f8b48286af2..df91f100f562 100644
--- a/tests/auth_tests/test_forms.py
+++ b/tests/auth_tests/test_forms.py
@@ -1445,6 +1445,29 @@ def test_render(self):
"</div>",
)
+ def test_render_no_password(self):
+ widget = ReadOnlyPasswordHashWidget()
+ self.assertHTMLEqual(
+ widget.render("name", None, {}),
+ "<div><p><strong>No password set.</p><p>"
+ '<a role="button" class="button" href="../password/">Set password</a>'
+ "</p></div>",
+ )
+
+ @override_settings(
+ PASSWORD_HASHERS=["django.contrib.auth.hashers.PBKDF2PasswordHasher"]
+ )
+ def test_render_invalid_password_format(self):
+ widget = ReadOnlyPasswordHashWidget()
+ value = "pbkdf2_sh"
+ self.assertHTMLEqual(
+ widget.render("name", value, {}),
+ "<div><p>"
+ "<strong>Invalid password format or unknown hashing algorithm.</strong>"
+ '</p><p><a role="button" class="button" href="../password/">Reset password'
+ "</a></p></div>",
+ )
+
def test_readonly_field_has_changed(self):
field = ReadOnlyPasswordHashField()
self.assertIs(field.disabled, True)
diff --git a/tests/auth_tests/test_templatetags.py b/tests/auth_tests/test_templatetags.py
new file mode 100644
index 000000000000..5cd5f558bd32
--- /dev/null
+++ b/tests/auth_tests/test_templatetags.py
@@ -0,0 +1,37 @@
+from django.contrib.auth.hashers import make_password
+from django.contrib.auth.templatetags.auth import render_password_as_hash
+from django.test import SimpleTestCase, override_settings
+
+
+class RenderPasswordAsHashTests(SimpleTestCase):
+ @override_settings(
+ PASSWORD_HASHERS=["django.contrib.auth.hashers.PBKDF2PasswordHasher"]
+ )
+ def test_valid_password(self):
+ value = (
+ "pbkdf2_sha256$100000$a6Pucb1qSFcD$WmCkn9Hqidj48NVe5x0FEM6A9YiOqQcl/83m2Z5u"
+ "dm0="
+ )
+ hashed_html = (
+ "<p><strong>algorithm</strong>: <bdi>pbkdf2_sha256</bdi> "
+ "<strong>iterations</strong>: <bdi>100000</bdi> "
+ "<strong>salt</strong>: <bdi>a6Pucb******</bdi> "
+ "<strong>hash</strong>: <bdi>WmCkn9**************************************"
+ "</bdi></p>"
+ )
+ self.assertEqual(render_password_as_hash(value), hashed_html)
+
+ def test_invalid_password(self):
+ expected = (
+ "<p><strong>Invalid password format or unknown hashing algorithm.</strong>"
+ "</p>"
+ )
+ for value in ["pbkdf2_sh", "md5$password", "invalid", "testhash$password"]:
+ with self.subTest(value=value):
+ self.assertEqual(render_password_as_hash(value), expected)
+
+ def test_no_password(self):
+ expected = "<p><strong>No password set.</strong></p>"
+ for value in ["", None, make_password(None)]:
+ with self.subTest(value=value):
+ self.assertEqual(render_password_as_hash(value), expected)