5.9 KiB
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 usessecrets.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(itsdangerousTimestampSigner under the hood), markedHttpOnlyandSameSite=Strict. TheSecureattribute is added bySecureCookieMiddlewarewhenX-Forwarded-Proto: httpsis 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 fromadmin.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(default10, 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 byrequire_adminon 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 withsecrets.compare_digest. Requests without a valid token get403. SameSite=Strictcookies 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 <script> blocks and inline style="" attributes);
frame-ancestors 'none' prevents clickjacking via iframe embedding. The public
JSON API does not set a CSP — it returns no HTML, so none is needed.
CORS and Host handling
- The public read API sends
Access-Control-Allow-Origin: *(read-only, no credentials) so any front-end may consume it. The admin sends no CORS headers and is same-origin only. - volumen is intended to run behind nginx, which controls the
Hostheader. Do not expose the Uvicorn port directly to the internet.
File uploads and path traversal
- Uploaded media is stored under
content_dir/mediawith a sanitised, randomised filename (<hex>-<slug>.<ext>), so uploads cannot overwrite each other or escape the directory. GET /media/*resolves names withPath(...).nameand an explicit containment check, so../traversal cannot read files outside the media directory.- Uploads are rejected (HTTP 413) when the file exceeds the
[admin].max_upload_bytesceiling (default 10 MB,10485760bytes). - Upload MIME type is whitelisted to
image/webpandimage/avif(ALLOWED_UPLOAD_TYPES); anything else returns HTTP 415 with a conversion hint pointing atcwebp/avifenc. This matches the WebP/AVIF-only posture the engine serves content in and rejects binaries (PHP, executable, …) outright. - Per-user profile photos go through the same upload validation and storage pipeline as post media.
Secrets and files
config.tomlandusers.tomlmay contain password hashes — keep them readable only by the service user (chmod 600).volumen hash-passwordreads the password from the terminal without echo; never pass passwords as command-line arguments.
Transport
- Bind to
::1(loopback) and terminate TLS at nginx. The browser ↔ nginx hop is HTTPS; the nginx ↔ Uvicorn hop is local.
Dependencies
A small, audited set declared in pyproject.toml:
- Runtime:
fastapi[standard](FastAPI, Uvicorn, itsdangerous, Jinja2 transitively),python-markdown,pymdown-extensions,tomli-w,python-multipart. (itsdangerous,Jinja2, andpython-multipartare pulled in directly too — seepyproject.toml.) - Crypto: Python standard library (
hashlib,secrets,hmac). - Dev:
pytest,httpx,ruff.
Known limitations / non-goals
- No rate limiting or lockout on
/admin/loginat the application layer beyond an in-memory per-IP sliding window (MAX_LOGIN_ATTEMPTS = 10per 60s in__init__.py). nginx is the durable line of defence — seedocs/deployment.md. Put nginxlimit_reqin front of/admin/login, or use fail2ban, to slow brute-force attempts. - No 2FA and no audit log.
- Raw HTML in posts is trusted (see the trust model). There is no built-in HTML sanitiser.