5.3 KiB
Security
Status: threat model and security posture for the engine as implemented.
volumen is a small, file-based blog engine: a single Ruby 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. kramdown renders Markdown and passes raw HTML through on purpose,
so an author can embed HTML. The stored/served
htmlis therefore only as safe as the people who can log in. If you need to accept posts from untrusted authors, add an HTML sanitiser 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
OpenSSL::KDF(N=16384,r=8,p=1, 32-byte key, 16-byte random salt). Verification usesOpenSSL.fixed_length_secure_compare(constant-time). - Login takes a username + password. The error message is intentionally generic ("Invalid username or password.") to avoid user enumeration.
- Sessions use signed cookies (
Rack::Session::Cookie) markedHttpOnlyandSameSite=Strict. TheSecureattribute is added automatically on HTTPS requests (detected viarequest.ssl?/X-Forwarded-Proto), so the cookie never travels over plain HTTP in production. 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).
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_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 (SecureRandom.hex(32)), validated withRack::Utils.secure_compare. 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:
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 (ERB 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. Rack::Protection::HostAuthorizationis disabled (permitted_hosts: []) because volumen is intended to run behind nginx, which controls theHostheader. Do not expose the Puma 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 withFile.basenameand an explicit containment check, so../traversal cannot read files outside the media directory.- Uploads are rejected (HTTP 413) when the file exceeds 10 MB
(
MAX_UPLOAD_BYTES). - 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
127.0.0.1and terminate TLS at nginx. The browser ↔ nginx hop is HTTPS; the nginx ↔ Puma hop is local.
Dependencies
A small, audited set: sinatra, kramdown (+ kramdown-parser-gfm),
toml-rb, puma, rackup. Cryptography uses the Ruby standard library
(openssl, securerandom).
Known limitations / non-goals
- No rate limiting or lockout on
/admin/loginat the application layer (the engine does have an in-memory rate limiter, but nginx is the durable line of defence — seedocs/deployment.md). Put nginxlimit_reqin front of it, 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.