﻿id	summary	reporter	owner	description	type	status	component	version	severity	resolution	keywords	cc	stage	has_patch	needs_docs	needs_tests	needs_better_patch	easy	ui_ux
37362	"check_password() raises unhandled AssertionError when stored hash starts with ""default$"" (identify_hasher/get_hasher sentinel collision)"	Harsh Parmar		"Django version: 6.2.dev (main, commit 446d9cf602a5c42862da118b714a5679bb61cf27)
Python version: 3.13.12

= Summary =

`get_hasher(algorithm=""default"")` treats the literal string `""default""` as
a sentinel meaning ""no algorithm was specified, use the first configured
hasher"":

{{{#!python
def get_hasher(algorithm=""default""):
    if hasattr(algorithm, ""algorithm""):
        return algorithm
    elif algorithm == ""default"":
        return get_hashers()[0]
    else:
        hashers = get_hashers_by_algorithm()
        try:
            return hashers[algorithm]
        except KeyError:
            raise ValueError(...)
}}}

`identify_hasher(encoded)` derives the algorithm name purely by splitting
the encoded hash string on its first `$`:

{{{#!python
def identify_hasher(encoded):
    if (len(encoded) == 32 and ""$"" not in encoded) or (...):
        algorithm = ""unsalted_md5""
    elif len(encoded) == 46 and encoded.startswith(""sha1$$""):
        algorithm = ""unsalted_sha1""
    else:
        algorithm = encoded.split(""$"", 1)[0]
    return get_hasher(algorithm)
}}}

If an encoded hash string's own algorithm segment (the text before the
first `$`) happens to literally be the string `""default""`,
`identify_hasher()` passes `""default""` straight into `get_hasher()`, which
matches the sentinel case and returns the **first configured hasher** --
regardless of whether that hasher's own `.algorithm` actually matches the
string. That mismatched hasher's `.decode(encoded)` then hits:

{{{#!python
algorithm, iterations, salt, hash = encoded.split(""$"", 3)
assert algorithm == self.algorithm
}}}

`algorithm` here is `""default""`, parsed straight from the string, which
never equals the real hasher's `.algorithm` (e.g. `""pbkdf2_sha256""`), so
the assertion fails with a bare, unhandled `AssertionError` -- not the
`ValueError` that `identify_hasher()`'s own docstring promises (""Raise
ValueError if algorithm cannot be identified"").

This is reachable through `check_password()` -- the exact function
`User.check_password()` / `ModelBackend.authenticate()` call on every
login attempt, given the user's *stored* password hash as `encoded`.

= Reproduction =

{{{#!python
import django
from django.conf import settings
settings.configure(
    SECRET_KEY=""x"",
    PASSWORD_HASHERS=[""django.contrib.auth.hashers.PBKDF2PasswordHasher""],
)
django.setup()

from django.contrib.auth.hashers import check_password
check_password(""some guess"", ""default$$$$"")
}}}

Traceback:
{{{
  File ""django/contrib/auth/hashers.py"", line 83, in check_password
    is_correct, must_update = verify_password(password, encoded, preferred=preferred)
  File ""django/contrib/auth/hashers.py"", line 62, in verify_password
    must_update = hasher_changed or preferred.must_update(encoded)
  File ""django/contrib/auth/hashers.py"", line 362, in must_update
    decoded = self.decode(encoded)
  File ""django/contrib/auth/hashers.py"", line 339, in decode
    assert algorithm == self.algorithm
AssertionError
}}}

Independently reproduced twice via a clean `pip install` of Django
straight from GitHub main into a fresh virtualenv (most recently commit
446d9cf6), confirming this is not an artifact of a locally modified
checkout:

{{{
$ pip install --upgrade ""django @ git+https://github.com/django/django.git@main""
Resolved https://github.com/django/django.git to commit 446d9cf602a5c42862da118b714a5679bb61cf27
$ python3 -c ""
import django
from django.conf import settings
settings.configure(SECRET_KEY='x', PASSWORD_HASHERS=['django.contrib.auth.hashers.PBKDF2PasswordHasher'])
django.setup()
from django.contrib.auth.hashers import check_password
check_password('some guess', 'default\$\$\$\$')""
Traceback (most recent call last):
  ...
  File "".../site-packages/django/contrib/auth/hashers.py"", line 339, in decode
    assert algorithm == self.algorithm
AssertionError
}}}

= Reachability =

This requires a `User.password` value that literally starts with
`""default$""`. Under fully standard Django usage this cannot occur
organically -- `set_password()`/`make_password()` always produce a hash
prefixed with the real hasher's algorithm name (`pbkdf2_sha256$...`,
`argon2$...`, `bcrypt$...`), never the literal word `""default""`.

Realistic ways this string could still end up in `password`:

* Constructing a user object without going through
  `set_password()`/`create_user()` (e.g.
  `User.objects.create(username=..., password=""default$whatever"")`) --
  a well-known footgun in data-import scripts, test fixtures, and
  migrations from other systems.
* Any bulk-import / legacy-system-migration path that copies a `password`
  column verbatim without passing it through `make_password()`.

Impact if triggered: every subsequent login attempt for that account
crashes with an unhandled `AssertionError` instead of failing cleanly
with ""invalid credentials"" -- denial of service against that account's
ability to authenticate, plus noisy 500s on every attempt against it
(including from routine automated credential-stuffing traffic).

= Not a duplicate of #18144 =

Trac search for ""check_password AssertionError"" returns one result,
#18144 (closed 2012) -- a different assertion in
`MD5PasswordHasher.encode()`'s empty-salt backward-compatibility handling,
unrelated to this `""default""` sentinel collision. Confirmed via Trac's
own search before filing.

= Suggested fix =

`identify_hasher()` should reject the literal strings `""default""`,
`""unsalted_md5""`, and `""unsalted_sha1""` explicitly before calling
`get_hasher()` (these are internal sentinels, not real encoded-hash
prefixes), or `get_hasher()`'s `""default""` special-case should only apply
when the argument truly wasn't supplied by the caller (e.g. a dedicated
sentinel object instead of overloading the string `""default""`, which is
also a syntactically-possible-if-never-real encoded-hash prefix).

= AI disclosure =

This report was found and drafted with assistance from Claude Code
(Anthropic). Specifically: coverage-guided fuzzing (Google's atheris/
libFuzzer bindings) was used to fuzz `identify_hasher()` and each
hasher's `.decode()`, restricted to the realistic call pattern (only
ever decoding with the hasher `identify_hasher()` itself selected, matching
how `verify_password()` calls it internally) -- this surfaced the crash.
It was then manually reduced to the minimal repro above, independently
re-verified from scratch against a fresh `pip install` of Django from
GitHub main (twice, on two separate commits), and checked against Trac's
own search for duplicates before filing."	Bug	new	contrib.auth	dev	Normal		hashers get_hasher identify_hasher check_password assertionerror default	Harsh Parmar	Unreviewed	0	0	0	0	0	0
