I Audited My Own Website and Found DEBUG=True in Production¶
This site — Django 5.2 and Wagtail behind Nginx on a small EC2 box — had never been audited. I ran a proper pass over it: bandit, semgrep, pip-audit, trivy, detect-secrets, plus live probing against the running host.
Six real findings. Here are the three that generalize.
1. DEBUG=True, in production, for months¶
Django was serving full traceback pages — source excerpts, settings keys, local variables — to anyone who could trigger a 500.
The cause was a name mismatch, not a bad decision:
# settings.py read this:
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
# docker-compose.yml exported this:
environment:
- DEBUG=False
DJANGO_DEBUG was never set, so the in-code default won — and the default was
'True'. The compose file looked correct in review. Every deployment checklist
that says "confirm DEBUG is False" would have passed, because the config said
DEBUG=False right there in the file.
The same bug had a second victim: SECRET_KEY was read from an equally unset
variable, so Django generated a fresh random key on every restart, silently
invalidating all sessions.
Two fixes, and the second matters more:
DEBUG = (
os.environ.get('DJANGO_DEBUG')
or os.environ.get('DEBUG')
or 'False' # fail secure
).lower() in ('true', '1', 'yes')
Defaults must fail closed. A missing environment variable is a misconfiguration, and a misconfiguration should degrade toward safety. Any security-relevant default that fails open converts a typo into a vulnerability.
2. The login page was the only real vulnerability — and it protected nothing¶
The site had a frontend login whose sole job was gating /notes/. It contained a
textbook open redirect (CWE-601): ?next= was passed to redirect()
unvalidated, so a genuine login URL on this domain could bounce a visitor to an
arbitrary external site immediately after authenticating.
The patch was three lines of url_has_allowed_host_and_scheme. The better fix
was deleting the feature.
Those notes are technical writing meant to be read. Gating them cost readers and
search indexing while protecting nothing — and it was already leaking anyway:
the search view ran Page.objects.live().search(query) with no privacy filter,
so "protected" notes were full-text searchable and their titles rendered to
anonymous users. Anyone could confirm arbitrary phrases inside them.
So the notes are public now and the whole login surface is gone: views, URLs, template, helper. Not patched — removed.
Access control should be proportional to asset value. An auth surface you do not need is an attack surface you did not have to have.
3. Nginx silently dropped every security header on /media/¶
Uploaded files were served with no X-Content-Type-Options: nosniff, which
turns any uploadable file into a potential stored-XSS vector via MIME sniffing.
The headers were configured. They were in the server block:
server {
add_header X-Content-Type-Options "nosniff" always;
location /media/ {
add_header Cache-Control "public, max-age=31536000";
# ^ this line silently discarded every inherited add_header
}
}
add_header does not merge across levels. A location block that declares
any add_header discards all of them from enclosing blocks. The fix is to
re-declare every header in each block that adds one of its own.
The scoreboard¶
| Finding | Severity |
|---|---|
DEBUG=True in production |
Critical |
| Open redirect on login (CWE-601) | Critical |
| 117 known CVEs across 15 packages | High |
/media/ served without security headers |
Medium |
| HTML sanitizer failed open if bleach was missing | Medium |
Gunicorn bound 0.0.0.0 past ufw (Docker writes its own iptables) |
Medium |
Now at zero manage.py check --deploy security warnings.
The finding the scanners missed¶
All 24 bandit hits were false positives — every mark_safe() was preceded by a
sanitize_html() call. Meanwhile the real sanitizer bug looked like this:
if not BLEACH_AVAILABLE:
return html_content # raw HTML, straight into mark_safe()
A missing optional dependency silently turned into stored XSS. No scanner flagged it, because nothing about it is syntactically wrong.
Scanners find patterns. They do not find intent. Prove each finding against the running system before you believe it, and read the code the scanner was happy with.