> The Typo That Shipped DEBUG=True: Fail-Open Defaults and Env-Var Drift

Security Engineering 中级 2026-07-30 04:54 2026-07-30
#Django #configuration #environment variables #fail-safe design #DEBUG #SECRET_KEY #production incident

A production Django site served traceback pages for months. No bad decision caused it — three environment variables were spelled differently in two files, and every insecure default won by forfeit.

The Typo That Shipped DEBUG=True

This site ran with DEBUG=True in production long enough that I cannot date the start. Django was serving full traceback pages — source excerpts, settings keys, local variable values — to anyone who could trigger a 500.

Nobody decided this. It is worth walking through exactly how it happened, because the failure mode is invisible to code review.

The two files

# settings.py
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
# docker-compose.yml
environment:
  - DEBUG=False
  - SECRET_KEY=${SECRET_KEY:-change-me-in-production}

Read either file alone and it looks right. The compose file says DEBUG=False in plain text. A deployment checklist item reading "confirm DEBUG is False in production config" passes.

But settings.py reads DJANGO_DEBUG, and compose exports DEBUG. The variable Django looks for is never set, so os.environ.get returns the default — and the default was 'True'.

The guard that was supposed to catch this

The SECRET_KEY handling had the same drift, plus a safety net:

SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
if not SECRET_KEY:
    if os.environ.get('DJANGO_ENV') == 'production':
        raise ValueError("DJANGO_SECRET_KEY must be set in production")
    import secrets
    SECRET_KEY = secrets.token_urlsafe(50)   # dev convenience

Compose exported SECRET_KEY, not DJANGO_SECRET_KEY. So the first lookup failed. The raise should then have taken down the container loudly on the next deploy — except DJANGO_ENV was never set either. Unset ≠ 'production', so control fell into the development branch and generated a fresh random key on every restart, silently invalidating every session.

Three variables misnamed. The third one disabled the guard protecting the second.

A safety check gated on configuration shares the fate of that configuration.

Why review does not catch it

  • The insecure state produces no error, no warning, no log line.
  • Both files are individually correct-looking; the bug lives in the relationship between them, and nothing type-checks that relationship.
  • Grepping for DEBUG in either file finds a reassuring result.
  • The symptom (a traceback page) only appears when something else breaks.

The fix that matters

Accepting both spellings is the small fix:

DEBUG = (
    os.environ.get('DJANGO_DEBUG')
    or os.environ.get('DEBUG')
    or 'False'          # <- the part that matters
).lower() in ('true', '1', 'yes')

The important change is 'False'. A missing environment variable is a misconfiguration, and a misconfiguration must degrade toward safety. Any security-relevant default that fails open converts a typo into a vulnerability.

Ask of every os.environ.get(name, default) in a security path: if this variable disappeared tomorrow, would I be exposed or merely inconvenienced? Inconvenienced is the only acceptable answer.

Detecting it in five seconds

Do not read the config. Ask the running process what it actually believes:

docker exec <container> python -c \
  "from django.conf import settings; print('DEBUG =', settings.DEBUG)"

Config files describe intent. This prints reality. The gap between them is where this entire class of bug lives — see also audit the deployed set, not the lockfile.

A cheap follow-up is a startup assertion that refuses to boot in a state you never want:

if not DEBUG and SECRET_KEY_WAS_GENERATED:
    raise ImproperlyConfigured("refusing to start with an ephemeral SECRET_KEY")

Loud beats silent. This bug was silent for months.