# Security > Status: threat model and security posture for the engine as implemented. volumen is a small, file-based blog engine: a single Python process exposing a read-only public JSON API and a session-authenticated admin, behind nginx. This document describes the trust model, the controls in place, and the known limitations. If you find a security issue, please email **opensource@petrbalvin.org** rather than opening a public issue. ## Trust model - **Content authors are trusted.** Posts are written by authenticated admin users. python-markdown renders Markdown with a few pymdown-extensions extensions. The engine does **not** strip raw HTML from post bodies by default (authors are the trusted admin); if you need to accept posts from untrusted authors, add a sanitiser (e.g. `bleach`) before serving. - **The public API is read-only.** It exposes published (non-draft) posts, tags, a feed and a sitemap. No public endpoint mutates state. ## Authentication - Passwords are hashed with **scrypt** via `hashlib.scrypt` (`n=16384`, `r=8`, `p=1`, 32-byte key, 16-byte random salt). Verification uses `secrets.compare_digest` (constant-time) on the derived bytes. - Login takes a **username + password**. The error message is intentionally generic ("Invalid username or password.") to avoid user enumeration. - Sessions use **signed cookies** via Starlette's `SessionMiddleware` (`itsdangerous` TimestampSigner under the hood), marked **`HttpOnly`** and **`SameSite=Strict`**. The **`Secure`** attribute is added by `SecureCookieMiddleware` when `X-Forwarded-Proto: https` is observed (only honoured when `[server].trust_proxy = true`) so the cookie never travels over plain HTTP in production while local HTTP testing still works. The signing secret comes from `admin.session_key`; if it is shorter than 64 bytes a random secret is generated per start (sessions then reset on restart, so set a stable key in production). - A minimum password length of `[admin].min_password_length` (default `10`, must be `>= 8`) is enforced on the Settings page password-change form. ## Authorization (roles) - Two roles: **admin** (full access, manages users) and **author** (posts and own account only). - User management (`add`/`delete`/role change) is gated by `require_admin` **on the server**, not merely hidden in the UI. - Safeguards prevent lockout: the **last admin** cannot be deleted or demoted, and a user cannot delete their own account or change their own role. ## CSRF - Every state-changing form (`POST`) carries a per-session token (`secrets.token_hex(32)`), validated with `secrets.compare_digest`. Requests without a valid token get `403`. - `SameSite=Strict` cookies provide a second, independent layer. ## Content Security Policy (admin) All `/admin/*` responses send a strict **Content-Security-Policy** header (via `CSPMiddleware`): ``` default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none' ``` The inline allowances are required by the server-rendered admin (Jinja2 templates emit small inline `