HOME / BLOG / I Audited My Own Website …
MARKDOWN

> I Audited My Own Website and Found DEBUG=True in Production

|
~5min READ
// Six findings from running a real security audit against this site. The worst one had been live for months and was caused by a single misspelled environment variable.

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.