panel-notifications: findings of the technical and security reviews
test / test (push) Canceled after 0s

Neither review (read-only agents, Opus and Fable, over 972a9f8..400aa76) has
a Critical finding. Both found the same serious one, and more:

- The notification about refused mail was built on what a rejected row says
  of itself. Its domain is the one in MAIL FROM, which the client typed and
  Postfix has not yet held against the login when the milter refuses: an
  application over its limit could have had the panel write a message per
  made-up domain, to another tenant's users among others. And the same
  status is written when an application is refused for its client address,
  which is no limit. Refusals are now counted by the domain the login is
  registered under, only for logins that exist and only while a limit is set
  on the login or its domain; one subject per sending domain. They are read
  through a partial index of their own (migration 0005): the query walked
  the whole send log every minute.
- Account and user e-mails were checked with the DMARC report validator,
  which refuses Gmail and the like - the owner's own kind of address. A new
  validate.AccountEmail accepts them and refuses a local part beginning "-".
- "Said" now means sent: what arises while nobody can be told, or while
  Postfix does not take the message, is told at a later check. Its ending
  with nobody left to tell is simply over.
- Two DNS checks must be 45 minutes apart as well as two: the last check
  before a restart and the first after it were minutes apart.
- The notifier's own mail waiting in the queue, and its bounce coming back
  to postmaster@ this server, are not the queue's subject. A listing cut at
  its size limit or with a line too long to read is no listing; postqueue -j
  gets 30 seconds.
- The tables must name the selector the panel shows, not only have the line.
- An a/mx lookup that did not answer inside the SPF check made the record
  "not authorising"; it is "cannot tell" now, for the pages too.
- A ceiling of 60 messages an hour; a pass stops between domains when the
  process is asked to stop; sendmail gets a WaitDelay; days are rounded as a
  person counts; a failed read of the switch shows it on, not off.

Recorded as accepted in security.md, with a roadmap candidate
(from-header-binding): an application can have a message signed as the
server's own name - or, as always, as another registered domain - by writing
it into the From: header, which nothing holds against the login.

Left as found: a recipient whose copy failed while another's went out is not
tried again; a certificate that lives under 14 days is reported at each
renewal.

On the test server, for this tree: go vet clean, go test ./... 23 packages
ok, make e2e ok (326 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-12 17:00:01 +03:00
co-authored by Claude Opus 5.5
parent 400aa76737
commit f09b60aaa2
30 changed files with 808 additions and 184 deletions
+34 -9
View File
@@ -159,10 +159,16 @@ by the panel. Socket `/run/opendkim/opendkim.sock`.
The server's own name (`SELFPOST_HOSTNAME`) has a key and a line in both
tables too, without being a row of the registry
(`domain.Service.SetServerName`): the panel's notifications are sent as
`postmaster@` that name and signed with it. No application can send as it -
the sender map is built from the registry. A sending domain of the same name
shares its key and selector with the server, and deleting that domain leaves
the key in place.
`postmaster@` that name and signed with it. No application can use it as an
envelope sender - the sender map is built from the registry - but the
`From:` header is not held against the login, so an application can still
have a message signed as the server ([security.md](security.md) § Accepted
risks). A sending domain of the same name shares its key and selector with
the server; deleting that domain leaves the server signed under the default
selector, which is the same key only if that was the domain's. At its start
the notifier makes sure the tables have the line and name the selector the
panel shows (`EnsureServerSigned`): tables are otherwise rebuilt only when a
domain is added or deleted.
### Panel binary ([cmd/panel](../cmd/panel))
@@ -219,13 +225,32 @@ is sent; the operator's view is guide § Notifications.
forced past the cache (`cmd/panel/notifier.go` builds the same query a
domain's page does); the certificate file; `postqueue -j` for the arrival
time of each queued message. Once a minute: the send log's `rejected` rows
of the last day, per domain and application.
of the last day, through a partial index of their own.
- **A refused row is not believed whole.** Its domain is the one in
`MAIL FROM`, which the client typed and Postfix has not yet held against
the login when the milter refuses; the same status is written when an
application is refused for its client address. So refusals are counted by
the domain the login is registered under, only for logins that exist, and
only while a level-2 limit is set on the login or its domain
(`Store.LimitRefusalsSince`). One subject per sending domain, whichever of
its applications are refused.
- **State** is a row per subject in `notify_state` (`dns:<domain>`, `cert`,
`queue`, `limit:<domain>:<login>`), which is what makes each thing be said
`queue`, `limit:<domain>`), which is what makes each thing be said
once and a restart say nothing again. A DNS subject goes *pending* on the
first bad check and *open*, with a message, on the second; back the same
way. The certificate's row remembers which threshold was said for which
expiry. A deleted domain's row goes with it.
first bad check and *open*, with a message, on a later one at least 45
minutes after it - a count alone would let the last check before a restart
and the first after it stand for "an hour apart"; back the same way. The
certificate's row remembers which threshold was said for which expiry. A
deleted domain's row goes with it.
- **Said means sent.** A problem that arises while no user can be told, or
while Postfix does not take the message, stays unsaid and is told at a
later check. Its ending, with nobody left to tell, is simply over. If one
recipient's copy fails and another's goes out, the subject is said.
- **The notifier's own mail is not a subject.** A notification waiting for a
mailbox that does not answer, or its bounce coming back to
`postmaster@<hostname>` - which receives no mail - is left out of the
queue check.
- **A ceiling** of 60 messages an hour stands over all of it.
- **No information is not a finding.** A lookup that did not answer, a queue
that cannot be listed, a certificate that cannot be read move no state.
- **Recipients** come from the store at the moment of sending: global users
+8 -3
View File
@@ -445,7 +445,7 @@ reported, each once:
| What | When a message is sent | A second message |
|---|---|---|
| A sending domain's DKIM, SPF or DMARC record stops matching | after two hourly checks in a row found it wrong. A resolver that did not answer is not a mismatch; a missing DMARC record is advice on the domain's page, not a notification | when two checks in a row find the records right again |
| A level-2 rate limit refuses mail | within a minute of the first refusal | at most once a day for the same domain and application, while it goes on |
| A level-2 rate limit refuses mail of a sending domain's applications | within a minute of the first refusal | at most once a day for the same domain, while it goes on |
| The TLS certificate is about to expire | 14 days before | 3 days before, if it is still the same certificate |
| A message has waited in the queue for more than three days | at the next hourly check | when no message that old is left |
@@ -475,8 +475,13 @@ publish under **Server › Settings › Notification mail**:
card. Without them notifications are still sent, and a receiver may file them
as spam or, if the parent domain publishes a strict DMARC policy, refuse them.
Mail of your sending domains depends on neither. If the hostname is itself one
of your sending domains, the records are that domain's and are already
published.
of your sending domains, the DKIM record is that domain's and is already
published - and whoever administers that domain can then send mail that
looks like the panel's; a hostname that is nobody's sending domain is the
better choice.
The e-mail on Account may be at a public mail host (Gmail and the like); only
a domain's DMARC report address may not.
A relay that cannot send cannot say so: none of the four is "the relay is
down". Watch that from outside ([Container health](#container-health)).
+3 -3
View File
@@ -80,7 +80,7 @@ A background check once an hour for DNS, certificate and queue.
|---|---|---|
| DNS mismatch | after two checks in a row found it (it has held for an hour). A resolver that did not answer is not a mismatch | after two good checks in a row |
| Certificate | 14 days before expiry, and again 3 days before if it is still the same certificate | none |
| Level-2 limit refused mail | at the first refusal for a domain or application, then at most once a day for the same limit | none |
| Level-2 limit refused mail | at the first refusal of a sending domain's applications, then at most once a day for that domain (one subject per domain, as built: what the refused row says of its domain is the client's own word, so the registry is asked) | none |
| Queue older than three days | when such a message appears | one, when none is left |
Event state is kept in the database, so a restart announces nothing a second
@@ -115,5 +115,5 @@ to the owner before any code ([panel-redesign.md](panel-redesign.md)
- [x] Panel: Account, Settings, Health card, help
- [x] guide.md, architecture.md, security.md, schema-migrations.md
- [x] `go vet`, `go test`, e2e: a level-2 refusal reaches a mailbox as a signed notification (on the test server: vet ok, 23 packages ok, e2e ok; "once" and "clears" are held by the unit tests of `internal/notify`)
- [ ] Technical review
- [ ] Security review
- [x] Technical review — **Opus** (2026-10-12; no Critical. Fixed: refusals counted by the registered domain and only under a limit, with an index of their own; account e-mails may be at public mail hosts; "said" means sent; DNS checks must be 45 minutes apart; the notifier's own mail is not a queue subject and a cut or unreadable listing is no listing; the tables must name the selector the panel shows; an unanswered `a`/`mx` lookup is not an SPF mismatch; a pass stops between domains at shutdown; days are rounded as a person counts. Left as found: a recipient whose copy failed while another's went out is not retried; a certificate that lives under 14 days is reported at each renewal)
- [x] Security review — **Fable** (2026-10-12; no Critical. Fixed: the limit notification was built on the domain a client writes in `MAIL FROM` - a mail cannon and a way to write to another tenant's users; a ceiling of 60 messages an hour; [security.md](../security.md) corrected on who can submit locally and on what the account e-mail now is. Recorded as accepted, with a roadmap candidate: an application can have a message signed as the server by writing its name into `From:` - the header is not held against the login, for sending domains either)
+37 -1
View File
@@ -35,10 +35,11 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
| structured-logging | `log/slog` with levels and fields | candidate | — | — |
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — |
| panel-notifications | E-mail notifications about important events | in progress | 8/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
| panel-notifications | E-mail notifications about important events | in progress | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
| password-reset | Password reset by e-mail | candidate | — | — |
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | candidate | — | — |
| from-header-binding | Hold a message's `From:` header against the application that sends it | candidate | — | — |
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
@@ -428,6 +429,41 @@ has one coherent layout, checked on a phone in "desktop site" mode and in a
---
## from-header-binding
**Problem (security review of panel-notifications, 2026-10-12):** Postfix
holds the envelope sender against the SASL login
(`reject_sender_login_mismatch`), and OpenDKIM chooses the signing key by the
`From:` header. Nothing holds the header against the login. An application
of one sending domain can therefore 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: mail that passes DMARC as a domain the application has
no right to, or that looks like a notification of the panel.
**Goal:** a message whose `From:` names a domain this server signs for is
accepted only from an application of that domain.
**Boundary:** the journal milter reads `From:` (and `Sender:`) and refuses at
end-of-headers when the header's domain is a signing name other than the
login's own. Mail whose `From:` is a domain this server does not sign for is
not touched - it goes out unsigned today and would after. The journal milter
is fail-open, so with it down the hole is as it is now; whether that is
enough or the check belongs somewhere stricter is part of the item.
**Done when:** the e2e stand shows an application refused for another
tenant's `From:` and for the hostname's, and accepted for its own.
**Dependencies / risks:** it refuses mail - an application that today sends
with a `From:` of another of the operator's domains stops working until it
is given an application of that domain; parsing `From:` differently from
OpenDKIM would leave a gap, so the check has to be the cruder one (does the
header mention `@<signing name>`).
**Version:** `2.x` MINOR; `candidate`. Until then:
[security.md](security.md) § Accepted risks.
---
## panel-redesign
**Goal:** a panel an operator opens a few times a year and still finds their
+6 -5
View File
@@ -17,8 +17,8 @@ When you add or review a migration, update this file in the same change.
| Field | Value |
|---|---|
| Chain head | `user_version = 4` |
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql` |
| Chain head | `user_version = 5` |
| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql`, `0005_send_log_rejected_index.sql` |
| File mark | `PRAGMA application_id = 0x53503230` ("SP20") |
| Database file | `/data/selfpost.db` (bind mount) |
| Compatibility | 2.x MINOR releases must boot a `2.0.0` data directory. **Nothing is carried over from 1.x.** |
@@ -63,8 +63,8 @@ again and no file is deleted, renamed or reordered — every 2.x database has
already run it. `2.0.0` and `2.0.1` shipped with the baseline alone; the
features after them add files, the first being `0002_inbound_spam_log.sql` of
inbound-antispam-panel (`2.1.0`), then `0003_send_log_lines.sql` of
delivery-log-storage (`2.2.0`) and `0004_notifications.sql` of
panel-notifications (`2.3.0`).
delivery-log-storage (`2.2.0`), and `0004_notifications.sql` and
`0005_send_log_rejected_index.sql` of panel-notifications (`2.3.0`).
---
@@ -76,6 +76,7 @@ panel-notifications (`2.3.0`).
| 2 | `0002_inbound_spam_log.sql` | 2.1.0 | DDL | `inbound_spam_log`, `inbound_spam_list` and two indexes; nothing existing changes |
| 3 | `0003_send_log_lines.sql` | 2.2.0 | DDL | `send_log_lines` and one index; nothing existing changes |
| 4 | `0004_notifications.sql` | 2.3.0 | DDL | `users.notify` (default 1: every user starts with notifications on) and `notify_state` |
| 5 | `0005_send_log_rejected_index.sql` | 2.3.0 | DDL | `idx_send_log_rejected`, a partial index of the refused rows of `send_log` by time; a file of its own because the test installation had run 0004 before the review asked for it |
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
for operators upgrading from older 2.x images.
@@ -104,7 +105,7 @@ for operators upgrading from older 2.x images.
| `dmarc_report_records` | Per-source rows inside a report |
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only (v2) |
| `inbound_spam_list` | Allow / deny entries for the external filter (`address`, `domain`, `ip`), instance-wide; unique per list (v2) |
| `notify_state` | What the panel has already written to its users about, one row per subject (`dns:<domain>`, `cert`, `queue`, `limit:<domain>:<login>`), so that each thing is said once and a restart says nothing again (v4) |
| `notify_state` | What the panel has already written to its users about, one row per subject (`dns:<domain>`, `cert`, `queue`, `limit:<domain>`), so that each thing is said once and a restart says nothing again (v4) |
| `send_log_lines` | The `mail.log` lines Postfix wrote under a queue id, with the line's own time; what a message's page shows once the log has rotated. No reference to `send_log`: a line belongs to a row by queue id and time, and goes when no row carries its id (v3) |
`TestBaselineSchema` (`internal/store/schema_test.go`) pins every table, column
+41 -8
View File
@@ -421,12 +421,40 @@ deferred item from the roadmap.
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. The panel's
membership in `postdrop`, described in the image as read-side, is now what
lets it submit; any local user could already do that through the
world-executable `sendmail`. Notifications are signed with a key of the
server's own name and bypass the level-1 and level-2 limits: they are a few
messages a day at most, to addresses only a panel user can set.
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.
- **A notification can be forged by an application, signed as the server.**
OpenDKIM chooses the key by the `From:` header, and Postfix holds only the
envelope sender against the login. An authenticated application can
therefore send a message whose header says `From: postmaster@<hostname>` and
have it signed with the key of the server's own name - a message that
passes DMARC and looks like the panel's. The same has always let an
application of one sending domain write another registered domain into
`From:` and get that domain's signature; the server's name is one more name
it works for. Accepted for now: applications are the operator's own, and a
forged notification can ask for nothing the panel acts on - it carries no
link and the panel takes no mail. The fix is to hold the `From:` header
against the login in the journal milter, which refuses mail and is a
decision of its own ([roadmap](roadmap.md) § from-header-binding). Trigger
to revisit: applications handed to anyone the operator does not trust with
every domain of the instance.
- **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.
- **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.
@@ -454,8 +482,13 @@ deferred item from the roadmap.
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
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. 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