ICVOSS DJANGO PACKAGE REGISTRY

The package index django-waf PRD: Site Password Gate

PRD: Site Password Gate

Documentation

Status: proposed, 2026-07-18. A middleware-level password wall that gates an entire site (and every subdomain it serves) behind a shared password, before any application view runs. For staging sites, private betas, holding pages, and internal tools that must not be publicly reachable or indexed.

1. Context and goals

Sites frequently need to be live (real TLS, real host, real app) but not public: a staging deploy, a private beta, an internal integration ground. Today that is done outside the app (nginx basic auth, a VPN), which is fine but not portable, not app-aware, and not part of the security surface django-waf already owns. django-waf already gates requests (block/challenge/throttle) in one middleware and already ships a noindex interstitial pattern (ChallengeView + NoIndexResponseMixin). A site-password gate is the same shape: intercept early, show an interstitial, verify, let verified visitors through.

Goal: a single setting turns a site into a password-gated site. Every request to every host the middleware serves is intercepted until the visitor submits the correct password; after that, a signed cookie lets them through for a configurable duration. Gated responses are noindex. It covers subdomains because it is middleware, not per-host config.

1.1 In scope

1.2 Out of scope

1.3 Non-goals

2. Architecture

2.1 One check in the existing middleware

The gate is a single check in WafMiddleware.__call__, placed AFTER the enabled/exempt/health short-circuits and BEFORE the threat evaluation (a locked site should prompt for the password before spending threat-scoring effort, and the prompt itself must be reachable). Mirrors the existing _check_country_block hook: a method returns an HttpResponse (the prompt or a redirect) to short-circuit, or None to continue.

Flow per request when DJANGO_WAF_SITE_PASSWORD is set: 1. If the request path is an exempt path (gate-exempt list), continue (no gate). 2. If the request carries a valid, unexpired verified-flag cookie, continue. 3. If this is a POST to the gate's verify path, check the submitted password; on success set the verified-flag cookie on the redirect response and redirect to the originally-requested URL (or next); on failure re-render the prompt with an error (and record a throttle hit). 4. Otherwise, render the password prompt interstitial (noindex, 401), preserving the originally-requested URL as next.

WafMiddleware is documented to sit before SessionMiddleware in the middleware stack (so it can block requests as early as possible), which means request.session does not exist when the gate runs. The verified flag is therefore the gate's own signed cookie (waf_site_password), not Django's session — a dependency on request.session here is a defect, not a design choice (see django_waf.services.site_password_service module docstring).

On correct password, the gate signs a marker with django.core.signing.TimestampSigner, keyed with the package's own signing key (DJANGO_WAF_SIGNING_KEY, falling back to a SECRET_KEY-derived value — the same convention every other signed artefact in this package uses) and sets it as a cookie on the redirect response, valid for DJANGO_WAF_SITE_PASSWORD_TTL seconds. The TTL is enforced live via the max_age passed to TimestampSigner.unsign() on every request, so a TTL change in settings takes effect for existing cookies on their next request. The cookie is httponly=True, samesite="Lax", and secure matching whether the request is served over HTTPS.

To cover subdomains, DJANGO_WAF_SITE_PASSWORD_COOKIE_DOMAIN (default None) falls back to settings.SESSION_COOKIE_DOMAIN at call time — a site that already sets SESSION_COOKIE_DOMAIN=".example.com" gets the same subdomain coverage on the gate's cookie without configuring it twice; set the gate setting explicitly only when its scope must differ from the session cookie's.

Comparison of the submitted password uses hmac.compare_digest (constant-time); django.core.signing's HMAC verification is inherently constant-time for the cookie signature itself. The stored password is read from settings (an env var in production), never rendered, never logged.

2.3 The interstitial

A SitePasswordView mirroring ChallengeView: NoIndexResponseMixin (so the prompt is never indexed), a minimal template (django_waf/site_password.html) with a password field and a hidden next, posting to the verify path. Status 401 on the prompt (unauthorised), so bots and scanners see a locked door, not content.

3. Behaviour rules

4. Settings

Setting Default Meaning
DJANGO_WAF_SITE_PASSWORD "" The shared password. Unset = gate off.
DJANGO_WAF_SITE_PASSWORD_ENABLED bool(DJANGO_WAF_SITE_PASSWORD) Explicit on/off; enabling with an empty password fails closed (BR-SP-002).
DJANGO_WAF_SITE_PASSWORD_TTL 43200 (12h) Verified-cookie lifetime, seconds.
DJANGO_WAF_SITE_PASSWORD_EXEMPT_PATHS health, /.well-known/, /robots.txt, WAF interstitials Paths that bypass the gate.
DJANGO_WAF_SITE_PASSWORD_VERIFY_PATH /waf/site-password/ Where the prompt posts.
DJANGO_WAF_SITE_PASSWORD_COOKIE_DOMAIN None (falls back to SESSION_COOKIE_DOMAIN) Domain scope of the gate's own verified-flag cookie.

Subdomain coverage is achieved by the operator setting Django's SESSION_COOKIE_DOMAIN to the parent domain (documented) — the gate's own cookie inherits it by default via DJANGO_WAF_SITE_PASSWORD_COOKIE_DOMAIN; the gate itself is host-agnostic (middleware runs on every host).

5. Acceptance criteria

6. Rollout

Additive and off by default, so it ships in a minor version (1.5.0). The vendablyconnect integration ground is the first consumer: its interim nginx basic auth is replaced by this gate (set DJANGO_WAF_SITE_PASSWORD from env, SESSION_COOKIE_DOMAIN=.vendablyconnect.com), keeping the lock inside django-waf where it is portable and app-aware.