Files
selfpost/docs/security.md
T
mix 236cb07769 docs: translate security.md and the last Russian source comments
security.md is linked from the README documentation table and now from
SECURITY.md, so a reader following either link landed in a Russian document
while everything around it was English. Translated in full; the requirements,
the accepted risks, and the CSRF ADR are unchanged in substance.

The reviewing model is no longer named in the text — that the pre-release
review ran, and when, is what a reader needs; who ran it is process detail
kept in development.md.

extract-cert.sh keeps its spec 10.3 quotation, translated. In sasl.go the
quotation from the closed plan is dropped rather than translated: rendered in
English it restated the sentence it hung off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 22:39:53 +03:00

9.9 KiB

Security

What is here. (1) Mandatory requirements — the checklist v1.0 has to meet; the full v1.0 audit passed. The pre-release review (plan § D, 2026-08-06) covered the whole diff from the v1.0 audit (Phase 11) to HEAD and the checklist in full: no exploitable findings; one defence-in-depth change — -- before the login in the saslpasswd2 argv (internal/app/sasl.go). (2) Accepted risks — deliberate departures beyond the mandatory, recorded so the decision is not lost.

Hardening beyond the mandatory (security headers, origin checking, __Host- cookie with duplicate detection — Phase 14) is done; the history is in CHANGELOG.md and git log.

Product boundaries: product.md. As-built design: architecture.md.


Mandatory requirements

The panel is exposed to the internet — the items below are not optional.

First-run administrator setup

  • A one-time secret link /setup/<token>, not an env variable holding a ready-made password hash.
  • Token ≥128 bits (crypto/rand); mirrored to /data/setup-token.
  • Token lifetime — 10 minutes; after expiry, or after a restart with setup unfinished, it is regenerated and logged again.
  • Rate limiting on /setup/<token> per IP, separate from login.
  • Token comparison is constant-time (subtle.ConstantTimeCompare).
  • Failed attempts do not invalidate the token early (protects setup from being DoS-ed).
  • Once the administrator exists the token is void forever, /setup/* → 404.
  • The administrator password is bcrypt (or argon2) in SQLite only; no plaintext and no MD5.
  • PANEL_USERNAME / PANEL_PASSWORD_HASH in env are not used.

Application SASL passwords

  • The panel generates the password on creation or reissue and shows it once.
  • In sasldb2 it is stored in the form SASL requires (not plaintext held by the panel); a lost password can only be reissued.

Input and configuration

  • Server-side validation of addresses and domains (character whitelist); client-side validation does not count as protection.
  • In address-list mode every address is checked to belong to the application's domain before it is written.
  • postfix reload and any exec run without shell interpolation of user input; arguments are passed as separate elements.
  • Writes to config files are escaped (no injection of Postfix directives).

Authentication and sessions

  • Rate limiting on login (per IP, with lockout or delay).
  • Sessions: cryptographically random token; cookie HttpOnly, Secure, SameSite.
  • Sessions live in SQLite (SHA-256 of the token, not the token itself); sliding idle timeout (PANEL_SESSION_IDLE_DAYS).

Output and process

  • Rendering goes through html/template with auto-escaping (queue, log, journal, themes).
  • The panel process is not root (user=panel in supervisord); path access is granted through the selfpost group with minimal permissions.

Mail path (security-relevant)

  • Not an open relay — SASL only; reject_unauth_destination; smtpd_sender_login_maps + reject_sender_login_mismatch.
  • TLS is mandatory before credentials are transmitted (465 wrapper / 587 encrypt).
  • TRUSTED_PROXY_CIDR — only explicitly trusted proxies may supply X-Forwarded-For for login rate limiting; empty means XFF is ignored.

Backup and domain export

  • Both files are secrets: a full backup carries DKIM keys, sasldb2, and the administrator's password hash; a domain export carries the DKIM key and working application passwords in the clear (otherwise a transfer without recreating credentials would be impossible).
  • Both downloads can be encrypted with a password (a checkbox on the form): scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each authenticated with the header, the chunk number, and an end-of-stream flag — a truncated or substituted file fails to open instead of silently restoring a partial "tail". Format and wrapper: internal/secretfile.
  • Extensions: .spbk (SelfPost backup — full backup), .spde (SelfPost domain export — domain export); unencrypted files stay .tar.gz / .json. Domain import detects encryption by the file's magic bytes, not by extension.
  • The password is never stored: without it the file cannot be recovered. In the CLI the password comes only from SELFPOST_BACKUP_PASSWORD or -password-file, never as an argument (the process list is readable by any process in the container).
  • Minimum password length matches the administrator password (12): the file sits offline and can be attacked without a time limit.

Accepted risks

An accepted risk is a decision with a condition for revisiting it, not a deferred item from the roadmap.

  • A POST with neither Sec-Fetch-Site nor Origin is allowed through. A client that sends neither — a genuinely old browser, or a webview with a frozen engine — stays vulnerable to CSRF from any site. Accepted deliberately: the panel is single-user, the administrator picks the browser, and a strict mode would not "protect" such a client, it would simply break the panel in it. Tightening is one line in originAllowed (internal/web/security.go): return false instead of true in the "neither header present" branch.
  • Session-bound CSRF tokens are not implemented. The origin check closes the neighbouring-subdomain case but depends on browser behaviour; a token does not. The price is a hidden field in roughly two dozen forms. The trigger to revisit is a requirement for protection that holds regardless of the browser. A token would not save the panel from XSS inside it either: code executing in the panel's origin sends the request itself — against that, html/template auto-escaping and CSP do the work, which is why templates must contain no inline scripts and no inline styles.
  • Encrypting backups and exports is an option, not the default. With the checkbox cleared the file downloads in the clear, as in 1.0. Otherwise an operator with nowhere to keep a password would lose the ability to take a backup at all, and a permanently undecryptable archive is worse than an unencrypted one: SelfPost does not store the password. The trigger to make encryption mandatory is a second administrator (at which point "who downloaded it" stops being one person).
  • A journal row left without delivery lines is closed as bounced rather than left as it is. The "forever queued" risk is gone: mail.log moved to /data/log/ and survives container recreation, and the log tailer keeps its read position (logtail_state, migration 0003), so the tail is read after a start. What remains are rows whose delivery lines are lost for good (the log rotated past 14 files while the panel was down, or was deleted): the reconciliation against postqueue -p sees the message is not in the queue and after a 2-minute grace marks it bounced. If the message did in fact go out, the status is a false negative. Accepted deliberately: a delivery the panel cannot confirm must not be shown as confirmed, and a permanent queued is indistinguishable from "in flight right now". Reconciliation does not run until the tailer has read the log to the end, and touches nothing if postqueue is unreadable. See architecture.md § Log tailer.
  • Access to mail.log from the unprivileged panel. The /data/log directory is 2750 postfix:selfpost and the file is 0640: postlogd (user postfix) writes, the panel reads through the shared selfpost group, and the file is inaccessible to others. The log holds envelope addresses and client IPs, but neither message bodies nor headers; it is excluded from backups (log/ is skipped) so that a dump stays state rather than diagnostics.

ADR: CSRF via origin checking, without tokens

Context. The panel is forms (POST) with a cookie session — the classic CSRF surface. What is needed is a way to tell a request from the panel's own page apart from one initiated by a third-party site in the logged-in administrator's browser.

Decision. originAllowed in internal/web/security.go checks Sec-Fetch-Site (when the browser sends it) or Origin (fallback) against the panel's host; a request carrying neither header is allowed through rather than rejected. There are no session-bound tokens embedded in forms.

Why not tokens. The panel is single-user (one administrator per instance) — the threat model does not include cross-user CSRF inside the panel itself, only an external site making the administrator's browser send a request. The origin check covers that without touching a single template: a token would need a hidden field in roughly two dozen forms and synchronisation with every new form, and it would still not protect against XSS inside the panel — code executing in the panel's origin reads the token and sends the request itself. XSS is handled by html/template auto-escaping and CSP, so that is a separate line of defence, not a CSRF token.

Trade-off. A client that sends neither Sec-Fetch-Site nor Origin (a genuinely old browser, or a webview with a frozen engine) stays vulnerable — see "Accepted risks" above. This is a deliberate choice not to break the panel in such a client, at the price of a narrow residual surface.

Revisit if: a requirement appears for protection that does not depend on browser behaviour, or the panel becomes multi-user.

How this list grows

The pre-release vulnerability review (history — CHANGELOG [0.5.0] Security) closes every finding in one of two ways: a fix before the tag, or an entry here with its rationale and its condition for revisiting, like the items above. There is no third option ("we looked at it and moved on").