Files
selfpost/docs/security.md
T
mixandClaude Opus 5.5 f1d90a2a1b
test / test (push) Canceled after 0s
go-milter: build it patched not to keep every header
The security documentation's last open item of panel-dkim-signing (the
owner's decision, 2026-10-16): go-milter v0.4.1 added every header of a
session to a map of its own, emptied only on ABORT, and handed it to
Headers - which no milter of the panel reads. Postfix bounds one header,
not their number, so a message of nothing but headers up to the message
size limit was held whole by the library in the sender check, the signer
and the journal milter on 465/587 and in the inbound journal milter on port
25, where strangers send; the signer's 1 MiB refusal came after it was all
in.

third_party/go-milter is v0.4.1 from the module cache (BSD 2-Clause,
LICENSE kept) with session.go changed: CodeHeader hands the header to
Header and keeps nothing, CodeEOH hands Headers an empty map. PATCHES.md
says what and why, and how to take a new upstream version. go.mod replaces
the module with it; the Dockerfile copies third_party before "go mod
download". TestTheLibraryKeepsNoHeaders holds it through the library's own
client over two messages of one connection. Comments, security.md (the
accepted risk becomes a note), development.md's library table, the plan and
CHANGELOG follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:57 +03:00

855 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../internal/app/sasl.go)). The 2026-08-14 review
(code-review plan § P7, Fable; reviewer ≠ author) covered the P0–P1 diff of the
2026-08-13 full-tree review against this document — send-log authorization for
domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed
session creation, and application-delete ordering: no findings, and nothing
needed adding to the accepted risks. The 2026-08-19 review (Fable) covered the
whole diff from 1.3.0 (`7052395`) to HEAD past 1.9.1 — the inbound-relay path
(port 25, maps, panel), DMARC aggregate-report ingestion, automatic rate
limiting, application auth-IP allow-lists, and the new panel surface
(handlers, templates, settings): no exploitable findings; two deliberate
behaviours the pass examined are now recorded under Accepted risks
(unauthenticated DMARC ingest, fail-open auth-IP enforcement). The 2.0 review
(Fable, 2026-10-10; fixed and re-checked by other models) covered the route
rename, the `/server/` subtree guard, inbound delegation and the new pages: no
Critical or High finding; sessions were keyed by user name (now by user id),
a password change ended other users' sessions, a password set for a user on
the user form did not end that user's sessions, internal errors reached forms a
`domain` user can open, and `/static/` listed its files — all fixed
(see [Authentication and sessions](#authentication-and-sessions) and [Errors
shown to users](#errors-shown-to-users)); two deliberate behaviours are
recorded under Accepted risks (upstream of a delegated inbound domain, profile
saved without the password). The 2026-10-11 review (Fable) covered the DMARC
report path from port 25 to the report pages, which ran for the first time in
2.0.0 - the 2026-08-19 pass read code that Postfix could not yet start: no
Critical or High finding; a report's size was bounded only as bytes (now 5 000
records, 255 bytes a field), the retention cap was one for the whole server
(now per domain), the delivery command could run a hundred at once (now one),
and the reason logged for a dropped message carried the sender's bytes as they
came (now cut and reduced to printable ASCII). The 2026-10-12 reviews of
inbound-antispam-panel (`2.1.0`: the inbound journal milter, refusals read from
`mail.log`, the filter lists and their pages) were two, by different models
over the same diff. The technical one (Opus) found, outside the feature and
there since `1.9.0`, that **the level-2 rate limit and an application's
client-address list held only for the first message of a connection** - see
[Mail path](#mail-path-security-relevant) - which was reproduced on the test
stand and fixed. The security one (Fable) found no Critical or High: a client's
HELO name could supply the sender, recipient and reply of a journal row, as the
parser then read the line, and so make a refusal look like the deny list's
(Postfix itself logs a HELO name with a space, `<>()\";@` and non-printable
characters turned into `?`; the reply now ends at Postfix's own `; from=<`);
the journal's row cap was applied only every six hours, between which
refusals were written at whatever rate a stranger sent recipients (the
tailer now cuts to the cap every 500 rows); and two behaviours are recorded
under Accepted risks (the allow list keyed on an address anyone can claim, the
list files readable by anyone). A third it named - with the filter down, a
sender's own verdict headers were recorded as the filter's - was closed the
same day at the owner's suggestion: the filter signs each message and a
verdict is read only next to its signature. The 2026-10-16 security review of
panel-dkim-signing (`2.8.0`: the panel's own DKIM signer, OpenDKIM removed)
found no path by which an application gets another tenant's key or the
server's, or a valid signature over a `From:` a mail program reads as another
domain; no Critical or High. The signer held a message's whole header section,
bounded only by `message_size_limit` (now 1 MiB, refused above it); the old
key tree stayed readable by group `selfpost` and went into every backup (now
the panel's alone and left out); a hostname with capitals deferred every
notification (the server's name is kept in lower case for DKIM); only `From`
was over-signed (now the shown headers too) - all fixed. `Sender:`, which
the review found neither held nor signed (F1), is now both: signed, and held
to the login like `From:` (the owner's decision, 2026-10-16). What the header
cap leaves is under Accepted risks.
(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](../CHANGELOG.md) and `git log`.
Product boundaries: [product.md](product.md). As-built design:
[architecture.md](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 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 plaintext - Cyrus keeps `userPassword` as given
(Cyrus SASL 2.1.28, lib/server.c `sasl_setpass()`), and a domain export reads
it back from there. The panel never shows it again: a lost password is
reissued. `sasldb2` is as secret as the exports (§ Backup and domain export).
### 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).
- The panel's HTTP server bounds every phase a client could hold it in: the
header 10 s, the request 30 s, the answer 60 s, an idle kept-alive
connection 2 min; a full backup download moves its own write limit to
two hours (`2.5.4`).
### Authentication and sessions
- Rate limiting on login (per IP, with lockout or delay). The Account form's
current-password check has a limit of its own, ten in 15 minutes per user,
so guesses there do not lock the address out of signing in (`2.5.4`).
- An unknown username is refused in the time a wrong password is: the sign-in
compares a bcrypt hash of the panel's work factor either way, so the delay
does not tell which names exist (`2.5.4`).
- 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`).
- **A session belongs to a user by id** (`sessions.user_id`, deleted with the
user). The user is loaded on every request and a session is renewed only
after it was found, so the cookie of a deleted user is dead and cannot become
the session of whoever is created under the same name later; renaming a user
leaves their sessions alone.
- **What ends a user's sessions:** deleting the user (all); a password change on
Account (that user's *other* sessions — never another user's); a password set
for them by a global user on the user form (all of theirs, except the current
one when they set their own); signing out (the current one only); signing
in (the session the browser presented, if any - a token planted before the
sign-in is worth nothing after it, `2.5.4`). There is no
"sign out everywhere", and a restored backup brings back the session rows it
held.
- Polling by the monitoring screens (`HX-Request` `GET`s) does not renew a
session.
### Roles
Everyone who signs in to the panel is an administrator, so the two roles name a
*reach*, never a rank: `global` and `domain` (in the users list, the role
select, the page headers and these documents — not "admin"). A `domain` user is
assigned outbound domains and inbound domains **separately**, each list with
*All* (which includes domains added later and widens the list, not the role).
| | `global` | `domain` |
|---|---|---|
| Overview, Server (Health, System log, Backup, Users, Settings, kit page) | yes | no — 404 |
| Outbound queue | yes | no — 404 (a queue is not filtered by domain) |
| Outbound: domain page, applications, Domain settings (report address, rate limit, export), log | every domain | assigned outbound domains |
| Outbound › DMARC reports | every report, with the server's ingest statistics | reports of assigned domains, without the ingest box |
| Inbound: domain page, upstream, recipients | every inbound domain | assigned inbound domains |
| Inbound › Log | every row, including those of a domain deleted since | rows of assigned inbound domains (by id: not those of an earlier domain of the same name), without the links into the filter lists |
| Inbound › Filter lists; the inbound log's retention | yes | no — 404 |
| Add or delete a domain, either direction; import a domain | yes | no |
| Account, Help | yes | yes |
- A path a user may not reach, a domain or application that does not exist,
and one that belongs to another tenant answer the **same 404**, so the panel
does not confirm what exists. A group with nothing assigned is absent from
the user's menu.
- `/server/` is guarded once, by `globalOnly` where its routes are registered
(a route outside the subtree cannot be added through that helper), and every
handler checks again. Outbound and inbound handlers check per object
(`lookupDomain`, `requireInboundDomain`) and lists are filtered by assignment
in the query. Each denied path has a test for a `domain` user, including
another tenant's id.
- The panel will not remove or demote the last `global` user. The guard is
inside the transaction that deletes or demotes: the write comes first and
the count after it, so two administrators deleting or demoting each other
at the same moment cannot both succeed; first-user setup is one statement
that inserts only into an empty table. A user's name, password, e-mail,
role and assignments are saved in one transaction, so a failure part way
leaves no domain user with nothing assigned.
- A log row - outbound or inbound - belongs to a domain by its id, written
when the row is: the outbound row's from the application that sent it. A
domain deleted and added again starts with an empty log for whoever it is
assigned to; the rows of the deleted one are the global role's alone.
- A `domain` user can export an assigned domain, and an export carries the
working application passwords (encrypted unless the box is unticked): assign
domains with that in mind.
### State-changing requests
- Every state change is a `POST`; a `GET` never changes state (the delete
confirmation pages only render a form).
- Every non-read request first passes the origin check
(`originAllowed`, [internal/web/security.go](../internal/web/security.go)):
`Sec-Fetch-Site` must be `same-origin`, or `Origin` must name the panel's own
host (the panel's `Referrer-Policy` is `same-origin`, so a browser puts the
panel's own origin on a form post of the panel); the session cookie is
`SameSite` as well.
- There are **no CSRF tokens**. The decision, its price and the conditions
for revisiting it are in the accepted risks and the ADR below.
### Errors shown to users
A page shows a validation message (what to fix) and nothing else of an error. A
database, `saslpasswd2` or Postfix-reload failure is logged and the page says
only that the change could not be saved and to check the logs. This holds on
every form a `domain` user can open. The global-only Backup page (full backup,
domain import) still shows the underlying message of an import failure, so the
operator can act on it.
### 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. The
supervisord control socket, which can stop or restart any program, is the
`supervisor` group's, whose one member is the panel; Postfix shares
`selfpost` for files and does not reach it (`2.6.0`; OpenDKIM shared
`selfpost` too until `2.8.0`).
- **Group `selfpost` (gid 10998) has two members, `panel` and `postfix`.**
Through it Postfix reads what the panel writes - `sasldb2` and the maps
under `/data/postfix` (2750, files 0640) - and connects to the panel's
milter sockets in `/run/selfpost` (2750, sockets 0660); the panel reads
`mail.log`, which `postfix` writes, through it the other way
([build/Dockerfile](../build/Dockerfile), `build/entrypoint.sh`). The DKIM
keys are not in it (§ Mail path).
- **The container keeps `KILL`** (`deploy/docker-compose.yml`, `cap_add`;
every other capability not listed there is dropped). Without it root
cannot signal a process of another uid (kill(2), capabilities(7); measured
on the test server, 2026-10-16), and two root processes must: supervisord
stops and restarts the panel, and Postfix's master ends its children,
which run as `postfix`. Until `2.8.0` it also carried OpenDKIM's `USR1`
reload through supervisord, which went with OpenDKIM; the plan had taken
that for its only use, wrongly.
### Mail path (security-relevant)
- **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **A recipient does not learn where an application runs (`2.7.0`).** The
`Received:` header Postfix adds on 465 and 587 carries no client name,
address, HELO name, TLS details or login (`smtpd_hide_client_session=yes`,
the owner's decision of 2026-10-15): before, every message told its
recipients the address and the internal host name of the machine the
application sent from. What a complaint needs stays with the operator - the
queue id in the header, the panel's log and smtpd's `client=` line in
mail.log. Port 25 keeps the full header, as RFC 5321 requires of an MTA.
- **The panel signs, with the key of the login's domain (`2.8.0`).** The
DKIM signer is a role of the panel process (`internal/milter/sign.go`,
`cmd/panel/signer.go`) on two sockets of its own, both attached strictly
(`default_action=tempfail`, `build/postfix-config.sh`). `sign.sock`,
second in `smtpd_milters` after the sender check, signs what applications
send on 465/587 with the key of the domain the SASL login belongs to:
`{auth_authen}`, looked up in the `sender_login_maps` file Postfix itself
reads (`Bindings.DomainOf`). `sign-local.sock`, the whole of
`non_smtpd_milters`, signs what is submitted with sendmail(1) with the key
of the server's own name. Nothing in a message chooses the key, and the
socket a message comes through - not a missing login - decides that the
server's key may sign it. What cannot be signed (a login with no domain, a
name with no key, a read error) is deferred, never sent unsigned. Port 25
has no signer. `relaxed/simple`, rsa-sha256; `h=` names every header of
RFC 6376 § 5.4.1's list the message has, and `Sender` and
`List-Unsubscribe-Post`, then once more each of the shown ones present
(Subject, Date, To, Cc, Reply-To, Sender) and `From` last, so one of those
added after signing breaks the signature. Until `2.8.0` OpenDKIM signed - a
2018 beta with three 2026 CVEs open and nobody upstream to fix them
([own-mta-builds](own-mta-builds-2026-10-15.md) § 1) - and chose the key
by the `From:` header; it left the image, and with it the syslog relay and
`/dev/log`, the port of its `From:` parser, its tables and their reload by
a signal across uids. Architecture § DKIM signing.
- **The DKIM keys are the panel's alone (`2.8.0`).** The key tree and the
selector table the signer reads, `/data/dkim/keys/<name>/<selector>.private`
and `/data/dkim/selectors` (`internal/domain`, `Keys`), are owned by
`panel`, directories 0700 and files 0600 - no group, no other user; the
signer reads them and never the database. The entrypoint sets these modes
at every start (e2e `checkDKIMTree` holds the tree to them). Until `2.8.0`
the keys were under `/data/opendkim`, 2750/0640 in group `selfpost` so
that OpenDKIM could read them - and so could every `postfix` process. That
tree is kept for a rollback to 2.7.x until the next release; since `2.8.0`
the entrypoint makes it the panel's alone as well (0700/0600, panel:panel)
at every start, copies its keys as the panel user (`setpriv`) while
`/data/dkim` holds none, and a backup leaves it out (§ Backup and domain
export). A root step of the entrypoint follows no symbolic link the panel
could plant in either tree (`drop_links`; `cp -RP`; `chown -R` and `find`
do not follow one).
- **An application signs only as itself (`2.4.0`).** The signature's `d=` is
the login's domain, and a receiver holds it to the domain of the `From:`
line (DMARC, RFC 7489 § 3.1.1): without a check of that line an
application could still send under the server's signature as another
domain of the server, or as the server itself. The panel's sender check
holds the header to the table the envelope is held to and refuses anything
else with `550 5.7.1`, a domain the server does not sign for included (the
owner's rule: the header may say what the envelope may, nothing more). It
is the first milter of the chain and strict: a message it could not judge
is deferred, and one it refuses never reaches the signer. The `From:`
line must be one field holding one address in RFC 5322's grammar as Go's
`net/mail` reads it - a mail program's reading; a line a lenient program
could show as another address (`ceo@bank.com <me@mine.com>`,
`<b@evil.com, a@mine.com>`) is refused - and that address, its local
part unquoted as Postfix unquotes the envelope's, is held against the
application's bindings; its domain is compared in ASCII lower case only,
so a look-alike such as the Kelvin sign for `k` is no letter of a
registered domain (`internal/milter/sender.go`, `judge`, `asciiLower`).
The unit test reads a list of lines naming another sender of the server in a quoted string, a
comment, a quoted pair, capitals or an encoded word
(`internal/milter/testdata/from-lines-other.txt`) and wants each refused
or read as the application's own address; the e2e sends every line of a
second list the check passes (`from-lines-passed.txt`, shared with the
unit test) and wants it signed for the application's domain and no other.
What this does not promise is what a display name looks like to a person:
a name of another sender in it passes, signed with the application's own
key - a forged display name is as old as mail, and no key of another's is
involved. Since `2.8.0` a `Sender:` line - shown beside `From:` as "on
behalf of" - is held to the same rule when there is one, and refused as
`550 5.7.1 Sender: ...`; `Resent-From:` is signed but not judged
(Accepted risks). What the client
wrote reaches the reply, the log and the send log only as printable ASCII,
cut, and a refused message is one row of the log whatever its recipient
count. Until `2.4.0` nothing held the header: OpenDKIM chose the key by it
and Postfix holds only the envelope sender against the login, so an
application of one sending domain could write another registered domain -
or, since `2.3.0`, the server's own name - into `From:` and have the
message signed with that name's key (security review of
panel-notifications, 2026-10-12). From `2.5.2` to `2.8.0` the check read
the line with OpenDKIM's own parser, ported line for line, and refused an
address over the 256 bytes OpenDKIM matched its `SigningTable` against;
with the key chosen by the login both went. Mail the panel submits itself
does not pass the check: only root, `postfix` and the panel can submit
locally (Accepted risks). Architecture § Sender check.
- **Inbound relay (optional, `INBOUND_RELAY_ENABLE`)** — port 25 is not an
open relay either: SASL is off; `smtpd_relay_restrictions` /
`smtpd_recipient_restrictions` are `reject_unauth_destination` and
`reject_unlisted_recipient`; maps list only configured domains and
recipients. Domains with no upstream host are omitted from the maps so mail
is never accepted with nowhere to send it. Prefer recipient mode `list` to
refuse unknown addresses at RCPT (no backscatter). Nothing signs on port
25. An optional antispam milter is inbound-only; default action is
fail-open (`accept`). Since `2.6.1` the listener ends DATA only at the
standard `<CR><LF>.<CR><LF>` (`smtpd_forbid_bare_newline=normalize`, the
2023 SMTP-smuggling defence, which Postfix 3.7 left off by default): a
`.` line next to a bare newline is body, and what a smuggler would put
behind it is delivered as part of the one message, not read as a second
transaction. The submission ports keep the default. Since `2.7.0` the
image runs Postfix 3.10.13, whose default is `normalize` too (Postfix 3.9
and later; `src/global/mail_params.h`, `DEF_SMTPD_FORBID_BARE_LF`), so
they end DATA the same way; the port-25 setting stays explicit. Before
`2.7.0` a smuggled transaction on a submission port was still the
authenticated session's, under its sender bindings and milters
([own-mta-builds](own-mta-builds-2026-10-15.md) § 5).
- **The panel's milters judge every message of a connection.** Postfix
introduces a client to a milter once per connection, and the milter library
replaces the milter's session after any reply that is not "continue". Until
`2.1.0` the journal milter knew the client's address only from that
introduction, and both of its checks passed a message that came without one:
a second `MAIL FROM` after a refused one, or any message after the first on
one connection, got past the level-2 limit and past an application's
client-address list. Now the address comes with every `MAIL FROM`
(`milter_mail_macros` carries `{client_addr}`), the level-2 limit does not
ask for it at all, an application with a client-address list is refused when
no address is known, and neither milter replies anything but "continue" to a
message it lets through. `test/e2e` sends two `MAIL FROM` on one connection.
- **The inbound journal is written from what strangers send.** The inbound
journal milter is the last on port 25, read-only and attached fail-open; it
keeps of the filter's verdict only what its patterns recognise. **A verdict
is read only next to the filter's signature.** The filter is attached so
that mail flows when it is down, and then nothing removes verdict headers a
sender wrote himself. Nor would rspamd itself by default for a client it
counts as local - a private address, which every client has when Docker's
proxy relays port 25 - or an authenticated one: `skip_local` and
`skip_authenticated` (rspamd 3.11.1, `milter_headers.lua`, `skip_wanted()`)
leave the sender's headers in place, while the signing routine below runs
regardless; both are turned off in `milter_headers.conf` (assumptions
C-R7, tried on 2026-10-14). So rspamd adds a third header, `X-SelfPost-Filter`:
the HMAC-SHA256 of the message's queue id under a key SelfPost generates
(`data/antispam/filter.key`) and the sidecar reads from the directory it
mounts. The journal milter recomputes it at end-of-message; with no
signature, or one that does not fit, the row is that of mail nobody judged.
The signature goes on to the upstream with the mail and is of no use there:
it fits one queue id. Refusals are
parsed from `mail.log` from the head of the line, with the reply ending at
Postfix's own `; from=<` so that the HELO name after it - the client's text,
which Postfix logs with non-printable characters and ` <>()\";@` turned into
`?` (Postfix 3.10.13, src/smtpd/smtpd.c `helo_cmd()`) - cannot stand in for
the sender, the recipient or the reply; an address with a quote, a bracket or a space in it is not kept.
Only a recipient at an inbound domain makes a row. The journal is cut to
10 000 rows every 500 rows the tailer writes, and by age every six hours.
- **Filter lists** reach another container as files: values are whitelisted on
the way in (address, domain, canonical IP or network; no `/0`, no
IPv4-mapped network), the writer refuses a stored value with whitespace, a
newline or `#`, and a change of the table and the rewrite of the six files
are one step under a lock.
- 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`, the
panel users' password hashes, `docker-compose.yml`, `.env`, and the TLS
private key from `certs/` when present; a domain export carries the DKIM key
and **working** application passwords in the clear (otherwise a transfer
without recreating credentials would be impossible). The keys a backup
carries are those of `/data/dkim`; `/data/opendkim`, the tree 2.7.x read
and kept for a rollback, is left out (`internal/backup`,
`excludedFromArchive`, `2.8.0`): an archive restores into its own version
alone, and a second copy of every key - one that outlives a deleted
domain's - has no place in it.
- Links under `certs/` (and `certs` itself as a link) are followed, in the
container's view: the archive carries whatever regular file they lead to
that the panel's user may read. `certs/` is the operator's, mounted
read-only; only someone who can write the project directory places a link
there, and only a global user can download the archive (`2.5.2`).
What a backup still leaves out - a link under `data/`, a link under
`certs/` to a directory - it names in a warning, and a `certs/` with no
file in it is refused rather than archived empty (`2.5.3`).
- A backup is made only of the database that is there: a wrong
`SELFPOST_DB_PATH` is refused, and the snapshot opens the source
read-only. An archive of an empty database would restore a server that
offers its first-run setup - a new global administrator - while it holds
live DKIM keys and `sasldb2` (`2.5.3`).
- The first start after a restore opens nothing until `/data` matches the
archive's last entry, `data/contents.json` (size and SHA-256 of each file
under `data/`): an archive unpacked in part - its manifest there, half its
database - is refused (`2.5.3`). The check is integrity, not authenticity:
whoever can write the project directory can write that record too. It
reads only regular files at the recorded names (a link or a pipe there is
refused, not followed), refuses a database journal file beside the
restored database, and is made once per restore: a start after one that
passed it and failed is not compared again while the manifest is the same.
- Since 2.2.0 a full backup also carries, in the database, what Postfix logged
about each message still in the send log (`send_log_lines`): the submitting
client's address and SASL login, the message id, the receiving server and
its reply. `mail.log` itself stays out of a backup; these lines are state
the panel shows, kept for the send log's retention and no longer.
- Both downloads are encrypted with a password unless the box on the form is
unticked (it is ticked by default):
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", and bytes after the last chunk are refused (`2.5.3`). The
key-derivation parameters travel in the header, so a reader is bounded in
what a header may ask before the password is checked: scrypt's array at
most 128 MiB (four times what SelfPost writes), r ≤ 64 so the rest stays
under 32 KiB, p ≤ 2 - *Import a domain* reads an uploaded file, and the
old cap let a header ask for 4 GiB (`2.5.3`). Format and wrapper:
[internal/secretfile](../internal/secretfile/secretfile.go).
- Extensions: `.spbk` (**S**elf**P**ost **b**ac**k**up — full backup), `.spde`
(**S**elf**P**ost **d**omain **e**xport — 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. The panel's forms and,
since `2.5.3`, the CLI hold an encrypting password to it; decrypting takes
any.
- The CLI's `-o FILE` is written beside its name and takes it only when
whole, so a failure - a wrong password to `-decrypt` among them, which is
checked on the first chunk before the output is touched - leaves the file
that was there (`2.5.3`).
---
## 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: every panel user (role `global` or `domain`) is an operator who
picks their own browser, not an untrusted party the panel needs to defend
against, 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](../internal/web/security.go)): return `false`
instead of `true` in the "neither header present" branch. That tightening
is agreed (owner, 2026-10-13) as
[origin-strict](roadmap.md#origin-strict); this bullet goes when it ships.
- **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,
or a population of `domain` users the global user does not fully trust
(see the ADR below). 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. Decided by
the owner on 2026-10-13: no tokens; the roadmap item that asked for them is
dropped and the origin check is made strict instead
([origin-strict](roadmap.md#origin-strict)). This bullet and the ADR below
are the single record of the decision, and the ADR's **Revisit if** says
when tokens are built after all.
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
Delete, regenerate-password, and clear-rate-limit forms carry a
`data-confirm` prompt handled entirely in
[panel.js](../internal/web/view/static/panel.js); with JavaScript disabled
or blocked the form submits immediately, exactly as it did before the
prompts existed. Accepted deliberately: the prompt is a mis-click guard,
not an authorization boundary — the same origin check and session/RBAC
gate every one of these `POST`s whether or not JavaScript ran. Progressive
enhancement means the panel must work with JavaScript off; a
server-rendered confirmation step would need a second page (or a `?confirm=1`
round trip) for every one of these forms, which is what the delete pages
([`user_delete.html`](../internal/web/view/templates/user_delete.html),
[`out_domain_delete.html`](../internal/web/view/templates/out_domain_delete.html),
[`in_domain_delete.html`](../internal/web/view/templates/in_domain_delete.html))
already do for the highest-blast-radius deletes.
- **Encrypting backups and exports can be switched off.** The panel's
*Encrypt with a password* box is ticked by default, but with it cleared the
file downloads in the clear, and the `selfpost-backup` CLI writes a plain
archive unless it is given a password. 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`), 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](architecture.md) § Log
tailer.
- **DMARC aggregate reports are accepted without authentication.** Anyone on
the internet can mail a fabricated report (or displace a genuine one — the
`UNIQUE(reporter, report_id, domain)` upsert is delete-then-insert) and skew
the alignment statistics the panel shows. Inherent to how `rua` works:
reporters are arbitrary third parties and the mailbox address is published in
DNS. The ingest already constrains the meaningful part — a report cannot be
filed under a domain this instance does not host, and the claimed domain is
cross-checked against the `+tag` of the delivery address (effective since
2.0.0: before it the recipient never reached the command) — and the data is
advisory statistics only; nothing enforces off it. What a stranger can do is
limited to a domain that chose a SelfPost-hosted address, through that
address: file reports under it, replace one whose reporter and id they know,
and fill that domain's 500 — not another domain's. A message that is not a
report the relay keeps is dropped, not bounced (the envelope sender is the
author's to write); a genuine report the parser cannot read is lost the same
way, with a count and a line in the mail log. The trigger to revisit is
any feature that lets report contents drive an automatic action (policy
changes, rate limiting, alerting an operator into a config change).
- **The application auth-IP allow-list is enforced fail-open.** The check runs
in the journal milter (`internal/milter/authip.go`) with
`default_action=accept` and skips on store errors, so if the journal milter
does not answer the restriction is not applied; the SASL login itself
always succeeds. (While the milter is up the list holds for every message,
and an unknown client address is refused - see Mail path; what is accepted
here is the milter not answering. Since `2.4.0` a panel that is down
altogether defers mail through the sender check, which shares its process;
what is left of this window is a journal milter that hangs or a database
that errs while the check beside it still answers.) This
is documented in [guide.md](guide.md) as **not an authentication boundary** —
it is a blast-radius limiter for a leaked application password, on the same
fail-open path as level-2 rate limiting, and a milter outage already stops
the journal from recording sends at all. The trigger to revisit is a
requirement for the allow-list to hold as a security boundary — at which
point it must move to the SASL/smtpd layer and fail closed.
- **The key the spam filter signs with is readable by anyone on the host.**
The inbound journal reads the filter's verdict only next to the filter's
signature (see Mail path), and the key for it, `data/antispam/filter.key`,
is 0644 like the list files beside it: the filter is another user in another
container. Someone with a shell on the host could therefore sign a verdict
of his own - into a monitoring page, on a host where he could do far more.
Senders on the internet, whom the signature is for, cannot read it. Trigger
to revisit: the same as for the list files - a shared host.
- **An allow entry for a sender address or domain trusts what the sending
server says.** The filter matches the envelope sender (only that: a
bounce's `From:` header is not looked at, `extract_from = "smtp"`), which
is not authenticated: anyone who knows or guesses an allowed address can send as it
and is given the entry's weight, which no spam score outweighs. The page's
Help and the guide say so and recommend an entry for the client's address or
network instead. Deny entries have no such problem - claiming to be a denied
sender only gets mail refused - and deny wins over allow. Trigger to
revisit: allow entries being used as the main way to receive mail from a
sender, at which point the rule should also require the sender's SPF or
DKIM to pass.
- **The filter lists are files readable by anyone on the host.**
`/data/antispam` is 0755 and its six files 0644, unlike the rest of `/data`:
the filter runs under another user in another container (rspamd as
`_rspamd`, uid 11333, in `rspamd/rspamd:3.11.1-3`, measured on a live
server, 2026-10-15; the panel is uid 10999 since `2.7.0`). They hold sender
addresses, domains and networks - who the operator blocks and whom he lets
through - but no message data. The inbound journal itself is in the
database, which is 0600 since `2.7.0` (0644 before). Trigger to revisit: a shared host, where the sidecar should
instead be given the files through a group it shares with the panel.
- ~~**An application can learn which domains are senders of this server.**~~
Struck in `2.5.2` (audit 2026-10-12, stage 2c; L8 closed): the sender
check read a `From:` line with OpenDKIM's own parser, ported, and decided
by the address OpenDKIM signed for; what a display name, a comment or a
quoted local part names no longer mattered, so refused or passed said
nothing about other domains of the server. Since `2.8.0` the check judges
the one address `net/mail` reads and the signer takes the login's key, so
the same holds without the port.
- ~~**A row of the inbound log belongs to a domain by name.**~~ Struck in
`2.5.0` (audit 2026-10-12, L12): both logs now hold a domain by id, see
§ Roles.
- **Level-2 in-flight reservations can outlive a broken SMTP session until TTL.**
Postfix tells the milters ABORT when a client drops the connection
mid-transaction (Postfix 3.10.13, src/smtpd/smtpd.c `mail_reset()` ->
`milter_abort()`), and the journal milter releases the reservation then.
When that never arrives - smtpd killed, the milter connection lost - go-milter
has no close callback, and the in-memory reservation is released by TTL
expiry, not immediately. The map is
bounded by key and TTL (currently 10 minutes), so the effect is temporary
over-counting on that app/domain key under churn. Accepted deliberately:
immediate cleanup would require protocol hooks the current milter interface
does not expose; the TTL bound keeps it finite. Trigger to revisit: sustained
high-volume false throttling attributable to orphaned reservations.
- **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 and SASL logins, the `Message-ID` of each message and the replies
given - for a refused `From:` line the reply quotes it (printable ASCII,
cut) - but no message bodies and no other headers; it is excluded from
backups (`log/` is skipped) so that a dump stays state rather than
diagnostics. Since 2.2.0 the tailer copies the lines of queued outbound
messages into the database for the message's page, and those are in a
backup (§ Backup and domain export); mail offered on port 25 is not copied.
- **The panel submits mail of its own.** Notifications leave through
`sendmail(1)` run by the unprivileged panel user: a fixed argument list, the
two addresses after `--`, no shell. The recipient is a panel user's account
e-mail, validated when it was saved and checked again before it reaches a
header or the command line; the subject and body are composed by the panel
from its own readings. Two of those readings can carry text from outside - a
DNS check's finding, and a queued message's sender address - and both are
reduced to printable ASCII and cut before they are printed, so a
notification's body can contain an address a stranger chose, as a few plain
characters and nothing else. A refused-mail notification names the domain
and the applications from the registry, never the domain a client wrote in
`MAIL FROM`: that string is not yet held against the login when the milter
refuses, and the first build took it for the subject - an application over
its limit could have made the panel write a message per made-up domain, to
another tenant's users among others (security review, 2026-10-12; fixed
before release). The panel's membership in `postdrop` is what lets it
submit: under `no-new-privileges` the setgid bit of `postdrop` does not
take, so only root, `postfix` and the panel can. Notifications are signed
with a key of the server's own name and bypass the level-1 and level-2
limits. What bounds them: the subjects are the registered domains, the
certificate and the queue; a domain's DNS can be reported and cleared about
a dozen times a day at most by someone who flaps the zone; a domain's
limits once a day; and no more than 60 messages leave in any hour whatever
the rules arrive at.
- **Whatever is submitted with sendmail(1) is signed as the server.** The
signer's local socket, the whole of `non_smtpd_milters`, signs every
message submitted locally with the key of the server's own name, whatever
its `From:` says, and no sender check stands on that path (`2.8.0`; under
OpenDKIM a local message was signed for its `From:` domain). Who can submit
is what bounds it: root, `postfix`, and the panel through `postdrop` (the
item above) - so any code running as `panel`, the panel process and
`dmarc-ingest`, which pipe(8) runs as `panel`, can have a message signed as
the server; the same code can read every key anyway, as it could in 2.7.
Mail from the Internet never reaches the socket: smtpd runs its own milter
list, and one whose list is empty (port 25 with DMARC reports on and the
inbound relay off, `-o smtpd_milters=`) turns `non_smtpd_milters` off for
what it receives
(Postfix 3.10.13, src/smtpd/smtpd.c, `setup_milters()`, "Safety: disable
non_smtpd_milters when not sending our own mail filter list"), and the
applications on 465/587 have no code in the container. Trigger to revisit:
anything else able to run as `panel` or `postfix` in the container, or a
local submitter that is not the panel.
- **The sender check and the signer stop mail while the panel does not
answer.** Both are roles of the panel process, attached with
`default_action=tempfail`, so a panel that is down or slower than the
milter timeouts (15 s to connect and for a command, 30 s for the verdict
at the end of the headers) defers every submission and its own
notifications: availability of sending now depends on the panel, which it
did not before `2.4.0` (until `2.8.0` the signer was OpenDKIM, attached as
strictly, a process of its own). Chosen by the owner over the alternative,
a check that is absent whenever the panel is - that one could be switched
off by whoever can make the panel slow. Supervisord starts a panel that
died again at once; one that dies within a second of starting is tried
four times in a few seconds, and then SelfPost's `crashexit` listener takes
the container down (supervisor 4.2.5, `startretries=3`); since `2.6.0` it
does so too when the panel exits unexpectedly five times in ten minutes,
however long each run lasted - so five malformed packets on a milter
socket (the next item) restart the whole container. The outage is
seconds or the whole instance; Server › Health counts the
downtime and the mail deferred for it. Trigger to revisit: those numbers
not staying at nothing - then the check moves into a process of its own
([plans/from-header-binding.md](plans/from-header-binding.md)).
- **A malformed packet on a milter socket of the panel ends the panel.** The
milter library indexes into an empty packet and nobody recovers the panic,
so the process exits and supervisord starts it again. The sockets are
reachable only inside the container, by the group Postfix is in (mode
0660); the journal milter always had this, and since `2.4.0` the seconds
of the restart defer mail instead of passing it unjournalled (technical
review, 2026-10-12). Trigger to revisit: anything untrusted able to open
a Unix socket in the container.
- ~~**The container's `/dev/log` takes datagrams from any user in it.**~~
Struck in `2.8.0`: the syslog relay that read it from `2.6.0` existed for
OpenDKIM's log, and left the image with OpenDKIM, as did the `/dev/log`
link; the signer logs to the panel's own output (the container log).
- **logrotate's state file is under `/data/log`,** the `postfix` user's
directory: that user could stop rotation through it - as through the log
itself, which it owns. Trigger to revisit: anything untrusted able to run
in the container.
- **When the hostname is itself a sending domain,** the server's mail is
signed with that domain's key. Whoever may export the domain has the key,
and its applications may send as `postmaster@<hostname>` by right. The
guide says so; a hostname that is nobody's sending domain avoids it.
- **`Resent-From:` is signed but not held to the login.** Since `2.8.0`
the sender check holds `Sender:` to the rule `From:` is held to (the
owner's decision, 2026-10-16; security review F1), and the signer signs
both `Sender:` and `Resent-From:`. `Resent-From:` - written when a
message is passed on again (RFC 5322 § 3.6.6) - is not judged: an
application of `mine.com` could write `Resent-From: ceo@bank.com` beside
its own `From:`, signed `d=mine.com`. Few mail programs show it, and no
key of another's is involved, as with a display name. Holding it too is a
few lines in `internal/milter/sender.go` (`judge()`); the trigger to do it
is a mail program that shows it in place of `From:`.
- **A header section costs the signer up to 1 MiB.** The signer counts a
message's headers as Postfix hands them over and refuses a section of
more than 1 MiB with `552 5.3.4` (`internal/milter/sign.go`,
`headerLimit`; `2.8.0`): it keeps no more than that and drops its list
once the block it signs is built. No mail program writes such a section,
and the refusal is permanent, so a sender is told rather than retried.
The other milters keep a few headers each (From, Sender, Subject).
Until `2.8.0` the milter library itself added every header of a message
to a map of its own in each milter - up to the port's message size limit,
from strangers on port 25 - which none of the milters read; the panel
builds go-milter v0.4.1 patched not to keep it
(`third_party/go-milter/PATCHES.md`; the owner's decision, 2026-10-16).
`deploy/docker-compose.yml` sets no memory limit: what is left - many
messages at once, each held to the bounds above - is the host's to
size.
- **A message's page can show another message's log lines after a queue-id
collision.** Postfix's short queue ids repeat: the id is the microsecond of
arrival and the queue file's inode, and inodes come round within seconds.
The page takes the stored lines of the row's queue id dated within the
row's own queue lifetime (about six days), so a second message has to get
the same microsecond on the same inode in that span - about one in a
million per reuse of the inode, and not something a sender can aim. When it
happens, a `domain` user could read the log lines of a message that is not
theirs. The search of the current `mail.log` that the page did before
2.2.0 had the same exposure over a day. Trigger to revisit: a report of
foreign lines on a page, or a reason to turn on `enable_long_queue_ids`,
which ends it.
- **A `domain` user may point an inbound domain's upstream at any host and
port.** The upstream form checks that the host is a well-formed name or IP
and the port is 1–65535; loopback, private and link-local addresses are
allowed, so a `domain` user with an assigned inbound domain can make this
server's Postfix open SMTP connections to any address it can reach and hand
mail for that domain to it. Accepted deliberately: delegation is within the trusted-operator model — a `domain`
user is an operator the `global` user chose to give that domain, and the
upstream of a backup-MX is by nature a host on someone's private network.
The connection is made by Postfix and carries only mail for that domain; the
panel itself makes no request to the upstream. Trigger to revisit: `domain`
users who are not trusted operators — the same condition as for CSRF tokens
— at which point an allow-list of upstream networks, or restricting the
field to the global role, is the fix. Since `2.5.4` an upstream has one TLS
mode for every inbound domain forwarded to it (Postfix keys the policy by
the next hop): a `domain` user saving one that another tenant's domain
uses with another mode is refused and told that mode - not the domain -
so the form tells whether some domain forwards to a given host and port.
Accepted with the rest of this item.
- **Account's profile (username, e-mail) is saved without the password.**
Saving the profile does not ask for the current password, as changing the
password does. Since 2.3.0 the e-mail is where notifications go, so a stolen
session can turn them to another address: the thief then learns what they
say - which domain's DNS is wrong, which application is refused, when the
certificate expires, sender addresses of old queued mail - and the user
stops hearing it. Nothing in a notification gives access. A `domain` user
can likewise point signed mail of this server at a third party's address,
a dozen messages a day per assigned domain at most. The profile form also
says when a username is taken, so a signed-in user can tell which
usernames exist; signing in does not (`2.5.4`). It
becomes a requirement to ask for the current password when changing
`users.email` as soon as [password-reset](roadmap.md#password-reset) mails a
link to it — from then on whoever can change the address could take the
account over.
## 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 a logged-in user's
browser. The panel is multi-user since 1.2.0 (a `global` user plus
zero or more `domain` users, each scoped to their assigned domains), but
that is an authorization boundary (who can see or change what), not a change
to the CSRF threat: the attacker in scope here is still an external site
riding a legitimate user's cookie, not one panel user attacking another
through the browser.
**Decision.** `originAllowed` in
[internal/web/security.go](../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. The check applies the same
way regardless of the requesting user's role.
**Why not tokens.** Cross-user CSRF is not the threat model here: a
`domain` user's browser sending a request still needs that user's own
cookie, so a token would not add a boundary between roles that the
authorization checks (`Principal.CanAccessDomain`,
[internal/web/auth/principal.go](../internal/web/auth/principal.go); route
gating in [internal/web/handlers/authz.go](../internal/web/handlers/authz.go))
don't already enforce. The remaining case is an external site making a
logged-in user's browser send a request, which the origin check covers 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 `domain` accounts stop being trusted operators (for
example, if a future release lets a `global` user invite `domain` users
whose browsers/devices are not vetted) — at that point cross-role request
forgery inside the panel would need its own analysis, separate from the
external-site case this ADR covers.
**Decision of 2026-10-13 (owner).** Asked to choose between this ADR, tokens,
and a strict origin check, the owner chose the ADR with the strict check
([origin-strict](roadmap.md#origin-strict)) and no tokens. Session-bound tokens
are the answer to return to - a hidden field in every state-changing form and
htmx path, checked against the session, on top of the origin check - when
either condition above comes true, and also when: a browser the panel must
support is found to send neither header or to send them wrongly; the panel
gains a state-changing request that a browser sends without these headers; or
an outside review requires a defence that the server alone holds.
## 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").