Files
volumen/docs/security.md
T

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 html is 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 uses OpenSSL.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) marked HttpOnly and SameSite=Strict. The Secure attribute is added automatically on HTTPS requests (detected via request.ssl? / X-Forwarded-Proto), so the cookie never travels over plain HTTP in production. 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).

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 (SecureRandom.hex(32)), validated with Rack::Utils.secure_compare. 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:

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::HostAuthorization is disabled (permitted_hosts: []) because volumen is intended to run behind nginx, which controls the Host header. Do not expose the Puma 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 File.basename and 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/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 127.0.0.1 and 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/login at the application layer (the engine does have an in-memory rate limiter, but nginx is the durable line of defence — see docs/deployment.md). Put nginx limit_req in 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.