Skip to content

feat(site_lock): optional site-wide password gate, off by default - #240

Merged
antosubash merged 8 commits into
mainfrom
site-lock
Aug 6, 2026
Merged

feat(site_lock): optional site-wide password gate, off by default#240
antosubash merged 8 commits into
mainfrom
site-lock

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Summary

Adds a site_lock module: an optional shared-password gate in front of the whole site — including the login page — so a staging or pre-launch deployment shows nothing to anyone without the password, not even that a login form exists.

  • Off by default. Installing the module changes nothing until an operator turns it on.
  • Configured entirely from Settings → Modules → SiteLock (enabled / password / message). No env vars; changes apply immediately with no restart.
  • Logged-in admins bypass the gate, which is the escape hatch if you enable it and mistype the password.
  • The gate page is a self-contained HTML document rendered by the middleware — no Inertia page, no React, no new routes. A locked site therefore still works with a broken frontend build.

How the ordering works

The middleware must run before AuthMiddleware, or an anonymous visitor gets redirected to the login page instead of the gate — leaking that a login exists. It gets there by declaring depends_on=["Settings", "Auth"]: modules install in topological order and Starlette's add_middleware is LIFO, so sorting after Auth makes this wrap outermost. framework/hosting/tests/test_middleware_order.py pins this, since inverting it breaks the feature without failing any unit test.

Verification

Browser-verified against a live server (API :8000 + Vite :5050), plus:

Check Result
make lint (ruff, ty, Biome, tsc, file-size, metadata, READMEs) exit 0
make test 1675 Python + 48 JS passed
npm run build exit 0
make doctor unchanged from baseline — findings predate this branch, none mention site_lock

Behaviour confirmed end-to-end in the browser and over HTTP:

  • gate off → app behaves normally; /__unlock is not exposed
  • gate on → / and /users/login both 302 to /__unlock; /health stays 200
  • correct password unlocks and returns the visitor to their original destination
  • wrong password → 401 with an inline error, site stays locked
  • open-redirect attempts on next (//evil.com, /\evil.com, https://evil.com) all fall back to /
  • /api/* returns 403 {"detail": "Site is locked"} rather than a redirect
  • 10 failed attempts → 429, and it stays 429 even for the correct password (fails closed)
  • password is masked in the settings UI and API; rotating it invalidates every unlocked session

Bug found and fixed during QA

Enabling the gate with a blank password looked like it worked. The backend correctly returned 422 and persisted nothing, but the admin saw no error — the toggle stayed on and the obvious reading was that the site was now locked. It was wide open.

A bare raise ValueError in a model_validator(mode="after") produces loc=[], and the shared settings form keys field errors by loc[-1] and drops anything it can't attach to a field. The error was discarded before render. Fixed by raising a validation error pinned to enabled, with a regression test asserting the loc rather than just that it raises.

Known limitation (by design)

The admin bypass only rescues a live session. If nobody is signed in and the password is forgotten, there is no in-app recovery — you clear the override in SQL (documented in the module README). This was the deliberate trade chosen over shipping a smpy site-lock disable CLI command.

Relatedly, while the gate is on there is no programmatic way in at all: CI, uptime monitors and mobile clients all get 403 regardless of credentials. /health stays open for probes. Now called out explicitly in the README.

Test plan

  • Reviewer enables the gate in Settings → Modules → SiteLock and confirms an anonymous browser sees /__unlock on every URL
  • Reviewer confirms enabling with a blank password shows an inline error and does not lock the site
  • CI is green

https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu

Staging/pre-launch gate behind one shared password, off by default.
Middleware-only module sorting after Auth so it wraps outermost; DB-backed
settings (admin UI only); logged-in admins bypass as the lockout escape hatch.

Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
Six TDD tasks: package scaffold + settings, attempt limiter, gate page,
middleware, admin-bypass coverage, host wiring and docs.

Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
DB-backed SiteLockSettings (enabled=False by default) with a validator that
refuses to enable the gate behind a blank password, plus an in-memory per-IP
attempt limiter with injectable clock for testing.

Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
Middleware runs before AuthMiddleware so anonymous visitors get the gate
rather than a login redirect. Session marker is a password fingerprint, so
rotating the password invalidates existing unlock sessions. Admins holding a
live session bypass, stamped once per session.

Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
Installs the module in the host, pins SiteLockMiddleware ahead of
AuthMiddleware in the middleware-order test, and adds the docs page + nav.

Renames the module's test files with a site_lock_ prefix: pytest resolves
test modules by basename with no __init__.py, so test_settings.py and
test_rate_limit.py collided with the users module's files of the same name.

Makes test_wizard_custom_picks_only_yes_answers derive its answer sequence
from CATALOG instead of hardcoding a count -- the hardcoded sequence shifted
onto the wrong module as soon as a new catalog entry landed.

Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
Exercises the real create_app pipeline: module state mounted, settings
registered for the admin UI, middleware ordered ahead of Auth, and the
gate's behaviour on a booted app both off and on.

Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
Browser QA found that enabling the gate without a password looked like it
worked. The backend correctly returned 422 and persisted nothing, but the
admin saw no error: the toggle stayed on, "Reset to default" appeared, and
the obvious reading was that the site was now locked. It was wide open.

Cause: a bare `raise ValueError` in a `model_validator(mode="after")`
produces `loc=[]`. `ModuleForm.onSave` keys field errors by `loc[-1]` and
drops anything it cannot attach to a field, so the message was discarded
before render. Raise a pydantic-core validation error pinned to `enabled`
instead, so it lands under the toggle the admin just flipped.

Worst possible failure mode for a security feature, so it gets a regression
test asserting the error's `loc`, not just that it raises.

Also add the Install/Usage sections `scripts/check_readmes.py` requires --
this was failing `make lint` since the module landed, missed because I had
been gating on `make ci-python-lint` -- and document that the gate returns
403 to the login API too, so no programmatic client can get in while it is
on.

Verified: make lint (exit 0), make test-py (1562 passed).

Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: f105a46
Status:⚡️  Build in progress...

View logs

@antosubash
antosubash merged commit ee330e9 into main Aug 6, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant