2026-06-21 13:15:06 +02:00
|
|
|
# Security
|
|
|
|
|
|
|
|
|
|
> Status: threat model and security posture for the engine as implemented.
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
volumen is a small, file-based blog engine: a single Python process exposing a
|
2026-06-21 13:15:06 +02:00
|
|
|
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
|
2026-07-26 18:15:29 +02:00
|
|
|
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.
|
2026-06-21 13:15:06 +02:00
|
|
|
- **The public API is read-only.** It exposes published (non-draft) posts, tags,
|
|
|
|
|
a feed and a sitemap. No public endpoint mutates state.
|
|
|
|
|
|
|
|
|
|
## Authentication
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
- Passwords are hashed with **scrypt** via `hashlib.scrypt` (`n=16384`, `r=8`,
|
2026-06-21 13:15:06 +02:00
|
|
|
`p=1`, 32-byte key, 16-byte random salt). Verification uses
|
2026-07-26 18:15:29 +02:00
|
|
|
`secrets.compare_digest` (constant-time) on the derived bytes.
|
2026-06-21 13:15:06 +02:00
|
|
|
- Login takes a **username + password**. The error message is intentionally
|
|
|
|
|
generic ("Invalid username or password.") to avoid user enumeration.
|
2026-07-26 18:15:29 +02:00
|
|
|
- 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.
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
## Authorization (roles)
|
|
|
|
|
|
|
|
|
|
- Two roles: **admin** (full access, manages users) and **author** (posts and
|
|
|
|
|
own account only).
|
2026-07-26 18:15:29 +02:00
|
|
|
- User management (`add`/`delete`/role change) is gated by `require_admin`
|
2026-06-21 13:15:06 +02:00
|
|
|
**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
|
2026-07-26 18:15:29 +02:00
|
|
|
(`secrets.token_hex(32)`), validated with `secrets.compare_digest`.
|
2026-06-21 13:15:06 +02:00
|
|
|
Requests without a valid token get `403`.
|
|
|
|
|
- `SameSite=Strict` cookies provide a second, independent layer.
|
|
|
|
|
|
2026-07-12 14:03:14 +02:00
|
|
|
## Content Security Policy (admin)
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
All `/admin/*` responses send a strict **Content-Security-Policy** header
|
|
|
|
|
(via `CSPMiddleware`):
|
2026-07-12 14:03:14 +02:00
|
|
|
|
|
|
|
|
```
|
|
|
|
|
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'
|
|
|
|
|
```
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
The inline allowances are required by the server-rendered admin (Jinja2
|
|
|
|
|
templates emit small inline `<script>` blocks and inline `style=""` attributes);
|
2026-07-12 14:03:14 +02:00
|
|
|
`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.
|
|
|
|
|
|
2026-06-21 13:15:06 +02:00
|
|
|
## 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.
|
2026-07-26 18:15:29 +02:00
|
|
|
- volumen is intended to run behind nginx, which controls the `Host` header.
|
|
|
|
|
Do not expose the Uvicorn port directly to the internet.
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
## 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.
|
2026-07-26 18:15:29 +02:00
|
|
|
- `GET /media/*` resolves names with `Path(...).name` and an explicit
|
2026-06-21 13:15:06 +02:00
|
|
|
containment check, so `../` traversal cannot read files outside the media
|
|
|
|
|
directory.
|
2026-07-26 18:15:29 +02:00
|
|
|
- Uploads are **rejected (HTTP 413)** when the file exceeds the
|
|
|
|
|
`[admin].max_upload_bytes` ceiling (default **10 MB**, `10485760` bytes).
|
2026-07-12 14:03:14 +02:00
|
|
|
- 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.
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
## 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
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
- Bind to `::1` (loopback) and terminate TLS at nginx. The browser ↔ nginx hop
|
|
|
|
|
is HTTPS; the nginx ↔ Uvicorn hop is local.
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
## Dependencies
|
|
|
|
|
|
2026-07-26 18:15:29 +02:00
|
|
|
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`.
|
2026-06-21 13:15:06 +02:00
|
|
|
|
|
|
|
|
## Known limitations / non-goals
|
|
|
|
|
|
2026-07-12 14:03:14 +02:00
|
|
|
- **No rate limiting or lockout** on `/admin/login` at the application layer
|
2026-07-26 18:15:29 +02:00
|
|
|
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.
|
2026-06-21 13:15:06 +02:00
|
|
|
- **No 2FA** and **no audit log**.
|
|
|
|
|
- **Raw HTML in posts is trusted** (see the trust model). There is no built-in
|
2026-07-26 18:15:29 +02:00
|
|
|
HTML sanitiser.
|