A demo visitor could change the admin password and lock everyone out.
The chain: demo mode refuses TOTP enrollment (api/user/otp.go), so
userNeedsSecureSession() is false, so RequireSecureSession() passes
through, and POST /api/user/password had no demo guard of its own.
Everything else with an effect outside the container was open for the
same reason: ACME issuance against a real CA, backup archives carrying
the crypto secret, outbound webhooks, DNS provider mutations, service
token minting, and a site health check whose request body chooses the
outbound destination. Until now the only thing preventing any of it was
that docker-compose-demo.yml disables outbound NAT.
Add RejectInDemo()/DemoReadOnly() and attach them to those routes. Local
CRUD stays open: the demo runs on ephemeral disk and restores itself, so
creating a site is a feature rather than a risk.
Note these guards are not redundant with RequireSecureSession — that
middleware is inert for a user with no OTP, 2FA or passkey, which on a
demo node is every user.
Also move the middleware error scope into its own errors.go: the errdef
generator resolves a scope per file, so errors declared alongside a
scope defined elsewhere were silently missing from the generated
frontend constants.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>