feat: initial release of volumen — a file-based Markdown blog engine
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
- Upload content-type is **not** validated (the `accept="image/*"` attribute is
|
||||
only a UI hint); this is acceptable under the trusted-author model.
|
||||
|
||||
## 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`. 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.
|
||||
Reference in New Issue
Block a user