Skip to content

Security: iplweb/django-dev-helpers

Security

docs/security.md

Security

The autologin endpoint is, by design, an admin backdoor. The package's defaults make sure that backdoor never opens unintentionally — and never opens in production.

Activation precedence (kill switch)

  1. settings.DJANGO_DEV_HELPERS["enabled"] = Falsealways off, even if env says otherwise. A teammate who never wants the helpers can set this in settings.local.py and forget about it.
  2. settings.DJANGO_DEV_HELPERS["enabled"] = True → always on.
  3. DJANGO_DEV_HELPERS_ENABLED=1 env var → on, when settings hasn't decided.
  4. None of the above → off.

DEBUG=False + serving HTTP → hard fail

When the package is active and settings.DEBUG=False and the process is a serving HTTP runner (runserver / WSGI / ASGI), AppConfig.ready() raises ImproperlyConfigured. Django doesn't start. There is no override.

Detection is fail-closed: management commands like migrate, test, shell, dbshell, collectstatic, dev_helpers_* are recognised as non-serving and don't trigger the check. Anything elsegunicorn, uwsgi, daphne, uvicorn, custom WSGI runners, unknown commands — is treated as serving. This biases towards false-positives in unusual contexts (better than letting a backdoor leak).

To extend the safe list for a project-specific command:

DJANGO_DEV_HELPERS = {
    "safety": {"non_serving_commands": ["my_long_running_worker"]},
}

To force the check to run inside an otherwise-safe command (CI pre-flight that asserts a DEBUG=False profile has enabled=False):

DJANGO_DEV_HELPERS_FORCE_SAFETY_CHECK=1 python manage.py check

The variable name is deliberate: it forces the check to execute, not to pass.

Defense-in-depth in the view

Even with enabled=True and DEBUG=True, the autologin view still:

  • accepts only GET (POST/PUT/DELETE → 405),
  • requires DEV_HELPERS_AUTOLOGIN_TOKEN to be set,
  • compares it timing-safely (hmac.compare_digest),
  • restricts the request hostname to localhost, 127.0.0.1, [::1] plus any explicit globs in autologin.allowed_hosts,
  • returns 404 (not 401/403) on every refusal — the URL appears to not exist.

Token

secrets.token_urlsafe(32) — 256 bits, URL-safe. Generated by AppConfig at runserver start when DEV_HELPERS_AUTOLOGIN_TOKEN isn't already set, written to .dev_helpers_token (mode 0600).

The token persists in os.environ so Django's autoreload child process inherits it; on full process exit a fresh token is minted next time.

URL pattern caching

autologin_urlpatterns() evaluates is_active() and autologin.enabled at URLconf import time. Django caches the URLconf for the lifetime of the process. If you flip the flag at runtime, the URL pattern doesn't appear/disappear until you restart Django.

The view itself still calls cfg.refuse_if_inactive() at request time, so toggling the flag still 404s the endpoint — the URL just remains routed until restart.

AutologinMiddleware

When autologin.middleware_autoinstall=True (the default), the package appends django_dev_helpers.middleware.AutologinMiddleware to settings.MIDDLEWARE during AppConfig.ready(). The middleware checks request.path against the configured autologin path on every request; for non-matching paths it calls get_response(request) and exits. For the matching path it delegates to the view, which performs the same set of checks documented above (token compare, host allowlist, user lookup, inactive guard).

The middleware's __init__ raises ImproperlyConfigured when settings.DEBUG=False. This is a fail-loud-and-early defense: even if someone copies the dev MIDDLEWARE list into a production-style configuration, the process refuses to start rather than expose the token-gated login backdoor. To opt out of the auto-install entirely, set autologin.middleware_autoinstall=False and either wire autologin_urlpatterns() in your URLconf or add the middleware manually.

The middleware also handles three convenience query-string toggles (?__autologin__=tmp_off|logout|log_in). The token is not required for these: the trust signal is the same host allowlist (refuse_if_unsafe_host) that gates the autologin URL itself. The rationale: an attacker outside the allowlist cannot reach the toggles in the first place; an attacker on localhost can already read .dev_helpers_token and use the URL flow. The toggles are convenience, not a new attack surface. Disable them with autologin.query_param = None (or "") if your threat model is stricter — for example if you have other users on the localhost loopback or share a tmux session.

Sanity warnings (informational)

These don't block startup, just emit RuntimeWarning:

  • ALLOWED_HOSTS containing entries other than localhost, 127.0.0.1, * (your dev box might be configured for staging-via-/etc/hosts; worth flagging).
  • SECRET_KEY looks production-like (length ≥ 50 and no django-insecure- prefix).

What the package never does

  • It never logs the token to stdout.
  • It never reads request bodies for autologin — token is query-string-only, GET-only.
  • It never persists a session or cookie outside of what Django's login() does. The autologin flow is one round-trip.

There aren't any published security advisories