Files
petrbalvin 3b8ed31f67
Test / test (push) Successful in 1m7s
Release / release (push) Successful in 1m11s
chore: prepare release v0.4.0
2026-07-26 18:15:29 +02:00

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 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 <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 Host header. Do not expose the Uvicorn port directly to the internet.

File uploads and path traversal

  • Uploaded media is stored under content_dir/media with a sanitised, randomised filename (<hex>-<slug>.<ext>), so uploads cannot overwrite each other or escape the directory.
  • GET /media/* resolves names with Path(...).name and 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_bytes ceiling (default 10 MB, 10485760 bytes).
  • Upload MIME type is whitelisted to image/webp and image/avif (ALLOWED_UPLOAD_TYPES); anything else returns HTTP 415 with a conversion hint pointing at cwebp / 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.toml and users.toml may contain password hashes — keep them readable only by the service user (chmod 600).
  • volumen hash-password reads 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, and python-multipart are pulled in directly too — see pyproject.toml.)
  • Crypto: Python standard library (hashlib, secrets, hmac).
  • Dev: pytest, httpx, ruff.

Known limitations / non-goals

  • No rate limiting or lockout on /admin/login at the application layer beyond an in-memory per-IP sliding window (MAX_LOGIN_ATTEMPTS = 10 per 60s in __init__.py). nginx is the durable line of defence — see docs/deployment.md. Put nginx limit_req in 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.