test / test (push) Canceled after 0s
Steps 11 and 12 of docs/plans/inbound-antispam-panel.md: 12/12. Two
read-only reviews of d65d632..HEAD by different models (Opus,
technical; Fable, security), each given the code and questions, not
conclusions. Nothing Critical or High in the feature; the High the
technical review found outside it is the commit before this one.
Log tailer (internal/logtail/inbound.go):
- the reply of a refusal ends at the first "; from=<", Postfix's own,
and the HELO name after it is whatever is left. Taking the longest
reply let a HELO name that spelt "; from=<..> to=<..> proto=.."
supply the row's sender, recipient and reply - and so make a
refusal read as the deny list's;
- an address with a quote, a bracket or a space is not kept: a quoted
sender could otherwise supply the recipient;
- the journal is cut to its row cap every 500 rows written, not only
every six hours;
- a queue id is forgotten when the message is queued or the id is
opened by another listener, and a stale place in the queue of
arrivals no longer drops a live id;
- milter-hold lines are not read: the held message reaches the
journal milter, which writes its row - it was written twice.
Lists (internal/inbound/lists.go): a change of the table and the
rewrite of the files are one step under a mutex; a failed rewrite puts
a removed entry back and rewrites after taking a new one out.
Store: the row cap removes the oldest by time, as the page orders them.
Page: on a sheet a refusal's text is a Reply whatever it starts with.
Help and the guide say that an allow entry for a sender address trusts
an address anyone can claim (design commit before this one).
docs/security.md: the two reviews; the roles table gains Inbound > Log
and Filter lists; Mail path describes the milters, the tailer and the
lists; four accepted risks - the filter's verdict while it is down, an
allow entry by sender address, the list files' mode, log rows held by
domain name. The plan's stale lines (size and rate refusals, who reads
the journal) are corrected.
Left as found, told to the owner: the journal's rows after an unclean
stop may be written twice (the tailer saves its place every five
seconds); a "Deny sender" link is offered for an address the form then
refuses; 1.2.3.4 and 1.2.3.4/32 are two entries.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
476 lines
29 KiB
Markdown
476 lines
29 KiB
Markdown
# 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 and so
|
||
make a refusal look like the deny list's (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 three behaviours are recorded
|
||
under Accepted risks (verdict headers while the filter is down, the allow list
|
||
keyed on an address anyone can claim, the list files readable by anyone).
|
||
(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 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`).
|
||
- **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). 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 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.
|
||
- 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 session cookie is `SameSite` as well.
|
||
- There are **no CSRF tokens yet**. 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.
|
||
|
||
### Mail path (security-relevant)
|
||
|
||
- **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`;
|
||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||
- **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). OpenDKIM is not attached
|
||
on inbound. An optional antispam milter is inbound-only; default action is
|
||
fail-open (`accept`).
|
||
- **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. 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,
|
||
logged as written - 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).
|
||
- 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". 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.
|
||
|
||
---
|
||
|
||
## 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.
|
||
- **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. Tracked as
|
||
[csrf-tokens](roadmap.md#csrf-tokens): implementing that item replaces this
|
||
bullet and the ADR below, and dropping it leaves them as the single record of
|
||
the decision. The two must not disagree.
|
||
- **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 milter is down
|
||
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 being down.) 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.
|
||
- **With the spam filter down, a sender can write his own verdict into the
|
||
inbound journal.** The filter is attached fail-open by default, and the
|
||
inbound journal milter reads its verdict from two headers. Up, rspamd removes
|
||
headers of those names that a sender wrote and adds its own (seen on the
|
||
test server: a forged pair was replaced). Down, the sender's pair is all
|
||
there is: the row shows his action and score, a forged `quarantine` shows a
|
||
hold that never happened, and a forged allow symbol counts as a match of an
|
||
allow entry that fits him in Filter lists. Mail is not affected - the
|
||
journal decides nothing - and the page that says the filter is down is
|
||
Server › Health. Accepted because the alternative is a live check of the
|
||
filter on every message for the sake of a monitoring page. Trigger to
|
||
revisit: the journal or the match counts being used to decide something
|
||
(alerts, automatic removal of entries), or `INBOUND_ANTISPAM_MILTER_ACTION`
|
||
staying `accept` on an installation whose filter is often down.
|
||
- **An allow entry for a sender address or domain trusts what the sending
|
||
server says.** The filter matches the envelope sender, 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. 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, 0640. Trigger to revisit: a shared host, where the sidecar should
|
||
instead be given the files through a group it shares with the panel.
|
||
- **A row of the inbound log belongs to a domain by name.** A `domain` user
|
||
sees the rows whose inbound domain has the name of one assigned to them, so
|
||
an inbound domain that is deleted and added again shows its earlier rows to
|
||
whoever it is assigned to next. Accepted: the journal is of a mail domain,
|
||
and the rows age out with the retention.
|
||
- **Level-2 in-flight reservations can outlive a dropped SMTP session until TTL.**
|
||
The journal milter cannot observe connection-close directly through
|
||
go-milter, so if a client disconnects without an explicit `Abort`, its
|
||
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, but neither message bodies nor headers; it is excluded from
|
||
backups (`log/` is skipped) so that a dump stays state rather than
|
||
diagnostics.
|
||
- **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.
|
||
- **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. Today the e-mail is only the address the *Use my e-mail*
|
||
button fills in, so a stolen session gains nothing it did not have. 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.
|
||
|
||
## 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").
|