v1.46.0
secure-code-auditor v1.46.0
A hardening release. Eight controls the corpus named nowhere now have an owner:
the proxy and static-serving edge, tar extraction, three response headers, mail
transport, index resolution, secret scanning, and release artifacts. Five
reference files change, no reference file is added, no heading is renamed, and
no script is touched.
Every version, RFC, and default claim below was verified against its primary
source on 20 August 2026 — Django release notes and installed 6.0.7 source, the
Python documentation for each version named, nginx's own directive reference,
and RFC 8460, RFC 8461, and RFC 9116. Two claims were verified by execution
rather than by reading. The library index date does not move: no PyPI sweep ran
for this release.
The Nginx off-by-slash traversal
deployment-and-runtime.md carried no occurrence of the word alias. It now
carries the trap. A location prefix written without a trailing slash, beside
an alias value that has one, also matches /static../, and nginx joins the
remainder onto the alias path — so GET /static../config/settings.py reads
one directory above the static root. root inside a prefix location does not
have this trap, which is why nginx's own documentation recommends root where
the URL prefix mirrors the directory name. The Write-time rule is to put the
slash on both sides in the same edit.
USE_X_FORWARDED_HOST gets an owner
The setting appeared once in the whole repository, as an aside in
api-drf-specific.md versioning, while settings_scan.py already reported it
at INFO and pointed at the deployment file. It now has the bullet the scanner
points to: it moves host trust to the proxy, and it is safe only where the
proxy sets X-Forwarded-Host on every request and strips the client's copy.
ALLOWED_HOSTS still validates the forwarded host.
One correction to the finding as written. A password-reset link does not
trust the forwarded host unconditionally. PasswordResetForm.save() takes its
domain from get_current_site(request), which returns the Site row when
django.contrib.sites is installed and falls back to
RequestSite(request).get_host() only when it is not. The bullet is scoped to
that condition.
Tar extraction filters
file-uploads.md named zipfile and never tarfile, so the archive section
taught the ZIP half of a boundary that also exists for tar — where, unlike ZIP,
the standard library supplies the answer. TarFile.extractall() without a
filter obeys the archive's own metadata: absolute names, .. components,
symlinks, hardlinks, device nodes, and setuid modes. filter="data" rejects
every one of them.
Four version claims, each read off that version's own documentation: Python
3.12 adds the filters; the backports are 3.9.17, 3.10.12, and 3.11.4; 3.12
warns when the caller gives no filter; and 3.14 makes "data" the default. The
last two were executed rather than read — 3.12.10 emits the
DeprecationWarning from _get_filter_function, and 3.14.4 returns
data_filter from the same function. The section also carries the
documentation's own advice to test with hasattr(tarfile, "data_filter")
rather than with a version number, because a micro version below the backport
has no filter API at all.
Four new sections in A02
SECURE_CROSS_ORIGIN_OPENER_POLICY appeared nowhere in the corpus while
settings_scan.py already reported None and "unsafe-none" at LOW. It is
now a settings-matrix row and a gotcha: SecurityMiddleware has served the
"same-origin" default since Django 4.0, and the correct relaxation for an
OAuth or payment popup that calls window.opener.postMessage is
"same-origin-allow-popups" — not None, and not "unsafe-none".
The CSP rollout. The passage settled the middleware choice and stopped
there. A policy now goes out through SECURE_CSP_REPORT_ONLY first, read
against real pages, before it moves to SECURE_CSP.
Compression and BREACH. GZipMiddleware and BREACH appeared nowhere in the
file. A compressed response that reflects attacker-controlled input beside a
secret leaks that secret by length. Django masks the CSRF token it renders, and
GZipMiddleware has added up to 100 random bytes per response since Django 4.2
— the Heal The Breach mitigation, which narrows the channel rather than
removing it. Compress static assets at the proxy instead.
Fetch Metadata as a second wall. Sec-Fetch appeared nowhere in the
repository. A small middleware that rejects a cross-site request unless it is
a top-level navigation GET blocks cross-site request forgery, cross-site
inclusion, and some XS-Leaks probes before view code runs — beside Django's
CSRF protection, never instead of it, with an absent header treated as allowed
so a non-browser client is not locked out.
Mail transport, and security.txt
SPF, DKIM, and DMARC authenticate the message; nothing in the file covered the
transport it rides on. SMTP sends in cleartext when STARTTLS fails, and an
attacker on the network can force that failure. MTA-STS (RFC 8461) closes it,
TLS-RPT (RFC 8460) reports the failures, and an absent policy on a domain that
sends password-reset mail is rated LOW. Every record name and the mode
vocabulary come from the two RFCs.
security.txt (RFC 9116) is disclosure support rather than a control, so its
absence is rated INFO. Contact: and Expires: are both mandatory, HTTPS is
mandatory, and the common failure is an expired file.
Dependency confusion, and stopping the commit
--extra-index-url appeared nowhere in a03-software-supply-chain.md. pip
treats every configured index as one pool, so an attacker who registers an
internal package name on PyPI at a higher version can win the install. One
--index-url pointed at a proxy that mirrors PyPI is the rule; hash pinning is
the backstop for when resolution still goes wrong.
service-identity-and-secrets.md named secret scanning twice, both times
inside a runbook step, and owned no section. "Stopping the commit" now puts the
three layers — a pre-commit scan, host push protection, and a scheduled history
scan — above the leak response, and states that a hit in history is a leak
rather than a lint.
Release artifacts
The reviewed archive of this repository carried __pycache__/, .DS_Store,
and __MACOSX/ entries, and git ls-files shows none of them tracked — so
they entered through a zip of the working directory rather than through git.
.gitignore gains __MACOSX/ and ._*, and the README records that GitHub
builds each tag download with git archive. The packaging manifest, validator
script, and allowlist release job the same finding proposed are rejected and
are absent.
Also in this release
Three review checklists gain one line each — the opener policy,
GZipMiddleware on a secret-bearing response, and MTA-STS. AGENTS.md,
GEMINI.md, and the .cursor rule gain MTA-STS and TLS-RPT in the
DNS-published list they already enumerate item by item, and the .cursor
rule's A02 routing trigger gains them too. The description is unchanged at
1013 of 1024 characters.
Full Changelog: v1.45.0...v1.46.0