Skip to content

v1.47.0

Choose a tag to compare

@n-shadloo n-shadloo released this 20 Aug 18:01
· 38 commits to main since this release
v1.47.0
e566259

secure-code-auditor v1.47.0

An identity and admin release. Eight controls the corpus named nowhere now have
an owner: REMOTE_USER authentication, the email change, purpose-bound tokens,
the device cookie, peppering, custom admin URLs, Markdown as an output context,
and the restore that resurrects an erased subject. bleach gets a disposition.
Six reference files change, no reference file is added, no heading is renamed,
and no script is touched.

Every claim below was verified against its primary source on 20 August 2026 —
installed Django 6.0.7 source, the projects' own source for markdown-it-py
and mistune, and PyPI and the GitHub API for bleach and nh3. Three claims
were settled by execution rather than by reading, and each of those three
corrected the finding as written. The library index date does not move: no PyPI
sweep ran for this release, so the one new package row carries its own dates.

REMOTE_USER and header authentication

a07-authentication-failures.md carried no occurrence of REMOTE_USER. The
new section states the mapping that makes the base middleware safe and a
subclass dangerous. RemoteUserMiddleware reads request.META["REMOTE_USER"],
which under WSGI the server's own authentication module sets; a client-sent
Remote-User header arrives under the different key HTTP_REMOTE_USER.
Django's own source comment says so at the header attribute. The trap is a
subclass whose header names an HTTP_ key, and it is safe only behind a
proxy that overwrites that header on every request and strips every inbound
copy.

Two defaults come off 6.0.7 source. RemoteUserBackend.create_unknown_user is
True, so every name the header carries becomes a row through get_or_create.
PersistentRemoteUserMiddleware sets force_logout_if_no_header = False, so
the session survives the header's disappearance.

The email change, and a correction to the finding

The finding stated that default_token_generator binds a token to the password
hash, last_login, and the token time, and instructed the reader to invalidate
outstanding password-reset tokens when the address changes.

_make_hash_value() hashes five values, not three: the primary key, the
password hash, last_login, the token time, and the email address. A scratch
project on 6.0.7 confirmed the consequence — make a token, change the address,
and check_token() returns False for the same user. The default generator
therefore already invalidates every outstanding reset token on an address
change. Only a custom generator or a stored token row needs that invalidation
written down, and the section says that instead.

The rest of the control stands: an address change re-authenticates, completes
on confirmation at the new address, notifies the old one with a revert path,
and never carries the new address inside a client-held signed token.

Purpose-bound tokens

Reused for email confirmation, a reset token's validity starts depending on
unrelated logins, and the same user state produces the same token in both
flows. The recipe is a PasswordResetTokenGenerator subclass per purpose with
its own key_salt and its own _make_hash_value(). The key_salt half is
in the release because the same run showed it is sufficient on its own: a
subclass that changes nothing but key_salt rejects a reset token, and the
default generator rejects its token in return. The _make_hash_value() half
binds the normalized target address.

The device cookie

Per-account lockout gives an attacker a denial-of-service method, which the
"Brute force and enumeration" section already named and left unanswered. The
device-cookie pattern splits the limits: a signed cookie set on each successful
login names the account, and (account, device-cookie) pairs are throttled
separately from attempts carrying no valid cookie. The cookie is signed under
its own django.core.signing salt and carries a version that a credential
change increases.

Peppering

a04-cryptographic-failures.md carried no occurrence of pepper. The new
subsection sits after the wrapped-hasher migration, because the supported
spelling in Django is that same wrapper: subclass the Argon2 hasher and HMAC
the password with a key from the secret manager before the hash. Rotation is a
new wrap version, not a mass reset.

It carries its own severity ruling. A missing pepper is no finding. Name it
as available hardening where the threat model is a database-only dump, and
never as a substitute for the Argon2 parameters above it.

Custom admin URLs, and two more corrections

authorization-architecture.md carried no occurrence of admin_view or
get_urls, and the finding was right about the gap. Execution made it sharper
than "runs with no admin gate": on Django 6.0.7 an unwrapped view returned from
get_urls() answered an anonymous request in full, while the wrapped one
redirected to login. admin_site.admin_view() adds the has_permission()
check — is_active and is_staff — plus never_cache and csrf_protect, and
cacheable=True drops the cache decorator only, never the check.

The finding also stated that the POST running an admin action is a plain
request an attacker can craft directly, implying no re-check.
response_action() re-resolves through get_actions(request), so the
permission filter does apply on POST. The real gap is inside
_filter_actions_by_permissions(): an action with no allowed_permissions
attribute is appended unconditionally. The same scratch project proved both
halves — an action declaring permissions=["delete"] did not run for a staff
user holding only view_widget, and an action declaring nothing ran on every
selected row. The section's existing action bullet already said the rest
correctly, so it gains that one clause rather than a new paragraph.

Markdown is an output context

a05-injection.md carried no occurrence of Markdown. It now has a sink-table
row and a passage beside the existing rich-HTML paragraph, which already
carries the nh3 disposition, so the passage points at it rather than
repeating it.

Both renderer facts come from the projects' own source. markdown-it-py sets
html to True in the commonmark preset its constructor selects, and in
both gfm-like presets; MarkdownIt("js-default") or an explicit
{"html": False} is the safe construction. mistune.create_markdown() escapes
raw HTML by default, but the module-level mistune.html renderer is built with
escape=False. Escaping is not sanitization: rich text still needs the
sanitizer over the rendered result.

A restore resurrects an erased subject

data-lifecycle-and-privacy.md already routed crypto-erasure for backups and
listed backups among the copies an erasure must reach, but no rule made the
restore path replay them. A backup taken before an erasure still holds the
subject, so a restore undoes the erasure silently. The restore procedure — and
point-in-time recovery with it — replays every completed erasure against the
restored data before that data serves traffic, and the test is concrete: erase
a fixture subject, restore yesterday's backup, prove the subject stays gone.

The passage builds on the opaque subject reference the section already requires
rather than restating it, and hands the backup mechanism itself to
data-layer-and-database.md.

bleach gets a disposition

The package appeared nowhere in the repository. It is now a row in the
existing-install-audit-only table, rejected for new use. Both dates are
verified: the 6.4.0 release of 5 June 2026 carries the
Development Status :: 7 - Inactive classifier, and mozilla/bleach is
archived with its last push on the same day. The cadence before that was
roughly one release a year since 6.1.0 in 2023. The successor is the nh3
entry already in the index — bindings to the Rust ammonia crate, still
releasing, most recently 0.3.6 on 22 June 2026. Migrating bleach.clean() to
nh3.clean() needs every allowlist argument re-verified, because the defaults
differ.

Router and cascade

Four review checklists gain a line each. The .cursor rule enumerates routing
triggers file by file, so three of its lists gain the new entry points: an
email-change flow and RemoteUserMiddleware for A07, a restore or
point-in-time recovery for the data-lifecycle file, and a Markdown renderer for
the A05 sink list. AGENTS.md and GEMINI.md are unchanged — both summarize
scope in prose rather than enumerating triggers, and every control added here
falls inside a phrase they already carry.

The description is unchanged at 1013 of 1024 characters, measured with
CI's own fold. A query naming REMOTE_USER is an authentication query and
lands on authentication, with X-Forwarded-For already in the field carrying
the header-trust half; a query naming an email change lands on authentication
and password hashing. Adding REMOTE_USER would cost thirteen characters
against eleven of headroom, and no term in the field is covered by a broader one
already there, so nothing is traded.

Known deviation

a04-cryptographic-failures.md was exactly at the repository's 1100-line
convention and is now 1115. Reclaiming fifteen lines would mean rewriting prose
that the previous release verified and that this change set does not own, so
the file grows and the tightening is left for the final checkpoint.

Full Changelog: v1.46.0...v1.47.0