ICVOSS DJANGO PACKAGE REGISTRY

The package index django-waf Design proposal: signed trusted-user cookie so the WAF recognises staff without AuthenticationMiddleware

Design proposal: signed trusted-user cookie so the WAF recognises staff without AuthenticationMiddleware

Documentation

Status: implemented in 1.8.0 (2026-08-01). Owner-requested from an icvlocal/vendablyconnect integration finding. This is the durable brief; file it as an issue with the gh command at the bottom.

Problem

WafMiddleware.process_request has a staff/superuser bypass (BR-RATE-003, middleware.py:111 -> _is_staff_user, middleware.py:508) that reads request.user. request.user is only populated by django.contrib.auth.middleware.AuthenticationMiddleware. So the bypass only works if the WAF runs AFTER auth middleware, and system check django_waf.W004 (checks.py:168) warns when it does not.

But the WAF is deliberately placed EARLY (after SecurityMiddleware, before session/auth) on every real deployment, because a WAF's whole value is cheap rejection of hostile traffic BEFORE the site pays for session decode, user resolution, and DB work. Running the WAF after auth to satisfy the staff bypass inverts that: every request, including a flood of malicious ones, runs session + auth first. That is the wrong security ordering.

Result: a genuine three-way tension. On vendablyconnect (2026-07-29) the WAF sits before auth (security-first), so W004 fires and staff are WAF-evaluated like anonymous traffic. It FAILS SAFE (_is_staff_user guards with hasattr(request, "user"), so no crash, the bypass just does not fire), but it is a real operability bug: a staff member can be PoW-challenged or rate-limited.

Key observation: the WAF already solved this everywhere ELSE

The staff bypass is the ONLY part of the WAF that reaches for request.user. Everything else that needs per-request trust state is carried in a signed cookie the WAF verifies ITSELF, no session, no auth, no DB:

So the codebase already knows it must not depend on session/auth at WAF-time, and has a clean, reusable signed-cookie pattern. The staff bypass is simply the one place that was not aligned to it.

Proposed fix

Carry "this request is from a trusted (staff) user" in a WAF-owned signed cookie, exactly mirroring site_password_service:

  1. New service trusted_user_service.py (mirror site_password_service): TimestampSigner with the same get_signing_key, a distinct salt ("django_waf.trusted_user") and cookie name (e.g. waf_trusted).
  2. set_trusted_cookie(response, request): signs a minimal claim (a marker plus, to limit theft value, a binding such as the client IP the way waf_pass binds IP) and sets the cookie with a SHORT max_age.
  3. has_valid_trusted_cookie(request): unsigns with max_age=TTL, checks the IP binding, returns bool. No DB, no session, no auth.
  4. Set the cookie on login. A receiver on Django's user_logged_in signal (django-waf already has signals.py), gated so it only fires for staff/ superusers (or a configurable trust level, see Open questions), sets the cookie on the login response. Because the cookie is set on the RESPONSE after auth ran, the WAF recognises the user from the NEXT request onward, which is correct: the login request itself already passed through auth successfully. This is exactly how waf_pass and the site-password cookie already behave. Provide the receiver as opt-in wiring (connect it in the consumer, or an AppConfig ready() gated by a setting) so sites that do not want it are unaffected.
  5. _is_staff_user prefers the cookie, falls back to request.user. Change it to: return True if has_valid_trusted_cookie(request) OR (the existing request.user check, preserved so it still works when the WAF IS placed after auth). Now the bypass works in BOTH orderings.
  6. W004 stops warning when the cookie mechanism is enabled. Update the checks.py:168 check: if the trusted-user-cookie feature is enabled (its setting is on), the WAF no longer depends on auth-middleware order, so W004 is not raised. Keep raising it when the feature is off and the order is wrong (unchanged behaviour for existing users).

Why this is the right shape

Honest caveats to spec, not blockers

Open questions for the implementer

Decisions taken (1.8.0):

Acceptance criteria

Files that change (all in django-waf)


To file:

gh issue create --repo icvoss/django-waf \
  --title "Signed trusted-user cookie so the WAF recognises staff without AuthenticationMiddleware (fixes W004 ordering tension)" \
  --body-file docs/DESIGN-trusted-user-cookie.md