The controls this application implements (section 53). For the findings of the last review pass, see SECURITY_REVIEW.md.
- Passwords — bcrypt at cost 12 over a SHA-256 pre-hash, so passphrases longer than bcrypt's 72-byte limit keep their full entropy.
- Access tokens — short-lived JWTs (30 minutes by default), held in memory by
the SPA so a stolen
localStorageentry cannot be replayed. - Refresh tokens — opaque, stored hashed, rotated on every use, and revoked on logout, on password reset, and on "log out everywhere". A replayed refresh token is rejected.
- Cookies — httpOnly,
SameSite=Lax, andSecurein production.
Login returns an identical status and body for a wrong password and an unknown account. Password reset always answers 202 whether or not the address exists.
Every learner-scoped route re-checks ownership and returns 404 rather than
403 for someone else's resource, so an ID is never confirmed to an attacker.
Admin routes require is_admin; tests assert each one rejects anonymous (401)
and non-admin (403) callers.
An admin cannot remove their own admin flag — an install can never be left with no administrator.
- Pydantic bounds every string length and numeric range at the edge.
- Audio uploads are capped at 25 MB (and again in nginx).
- Prompt names are restricted to
^[a-z0-9_]{1,64}$before any path is built, so the admin prompt editor cannot write outside its directory. - All database access goes through SQLAlchemy constructs; there is no string-built SQL anywhere in the codebase.
Per-IP fixed window, Redis-backed with an in-process fallback: 10/min on auth, 60/min on speech and session turns, 20/min on destructive privacy actions.
Without REDIS_URL the limiter is per-process — correct for a single worker,
but set Redis in multi-worker production.
Every response carries X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: no-referrer and
Permissions-Policy: microphone=(self); HSTS is added in production. nginx
repeats these and forwards X-Forwarded-* so the app sees the real scheme and
client IP.
AI and speech keys are backend environment variables. They are never sent to the
browser and never returned by the admin API — a test asserts the string
api_key never appears in the provider status response.
SECRET_KEY signs access tokens. Rotating it invalidates every session by
design; keep it in a secret store, not in the image.
Registration, login, logout, password reset, every privacy action and every admin change are recorded with IP and user agent.
Audio is stored only on explicit opt-in and deleted after the retention window; account deletion requires the password and cascades to every owned row. See PRIVACY.md.
- CSRF — the SPA uses bearer tokens and cookies are
SameSite=Lax, which blocks cross-site POSTs. No CSRF token is issued. A future client that relies on cookie auth for cross-origin state changes would need double-submit tokens. - Password reset delivery is a pluggable notifier; in non-production the token is returned in the response for testing.
- Content Security Policy is not yet set. Adding a strict CSP in nginx is the natural next hardening step.
Security issues should go to the maintainer privately rather than in a public issue.