diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fe9e1..14fd3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,65 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +## [2.4.0] - 2026-10-12 + +### Security + +- **An application signs only as itself.** OpenDKIM chooses the key by the + `From:` header, and Postfix held only the envelope sender against the + login: an application of one sending domain could write another registered + domain, or the server's own name, into `From:` and have the message signed + with that name's key (security review of `2.3.0`, where it was recorded as + an accepted risk). The panel now holds the header to what the envelope is + held to - any address of the application's domain, or the addresses of its + list, with or without a `+extension` - and refuses anything else with + `550 5.7.1`, a domain this server does not sign for included + ([plans/from-header-binding.md](docs/plans/from-header-binding.md)). +- **The check is strict.** It is the first milter of the chain, on a socket + of its own (`SENDER_CHECK_SOCKET`), attached with + `default_action=tempfail`: while the panel does not answer, Postfix defers + mail and accepts nothing unchecked. The journal milter keeps its socket and + stays fail-open. + +### Added + +- **Server › Health counts what the strict check costs** in a card of its + own, *Sender check*: for how long the panel did not answer while Postfix + was running, and how many submissions Postfix turned away because of it - + today, in the last 30 days and in the last 12 months (UTC days, kept a + year). The card warns when mail was deferred in the last 24 hours. The + numbers are there to decide whether the check needs a process of its own. +- **A refused row of the Outbound log says why**, in its tag: `wrong From`, + `wrong client` (the application's client IP list) or `rate-limited`; a row + refused before this version says `rejected`. A message refused for its + `From:` line shows on its page what the line said and what the application + may send as. Help has a *Sender check* topic. +- Migration `0006_sender_check.sql` (DDL only): `send_log.reason`, + `send_log.header_from`, `sender_check_days`. + +### Changed + +- **Mail with a `From:` the application is not bound to is refused.** What + stops working on upgrade: an application sending with the `From:` of + another of your domains (give that domain an application); an application + with a list whose envelope and `From:` addresses differ (add the second to + the list); a form that puts its visitor's address into `From:` (use + `Reply-To:`). The line is read strictly: one `From:` field, one address, + one `@` outside a quoted display name; a name with a comma or brackets has + to be quoted. Guide § Who an application may send as. +- **With the panel down, mail is deferred, not accepted.** Until now a + stopped panel cost the send log and the level-2 limits and nothing else. +- **Server › Health is laid out as two stacks of boxes numbered by + importance**, read left to right and top to bottom, each stack closed up: + Processes, Milter sockets, Sender check, TLS certificate, Hostname and + reverse DNS, Notification mail, Machine, Configuration. On a phone they + come in that order. +- A refused message is one row of the log, naming its first recipient and + how many more there were. +- `recipient_delimiter` is set to `+` by the image instead of being left to + the distribution's default; the start removes milter sockets a killed + container left behind. + ## [2.3.0] - 2026-10-12 ### Added diff --git a/README.md b/README.md index 9da6600..b2e7931 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ docker run --rm -d --name selfpost-try \ -e SELFPOST_HOSTNAME=mail.local.test \ -e PANEL_COOKIE_SECURE=false \ -v selfpost-try-data:/data \ - ghcr.io/mixeme/selfpost:2.3.0 + ghcr.io/mixeme/selfpost:2.4.0 ``` **Get the setup URL** (pick one): diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 9d64230..cfc1a1f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -21,7 +21,7 @@ services: selfpost: - image: ghcr.io/mixeme/selfpost:2.3.0 + image: ghcr.io/mixeme/selfpost:2.4.0 restart: unless-stopped environment: SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" diff --git a/docs/development.md b/docs/development.md index 7e34230..a095203 100644 --- a/docs/development.md +++ b/docs/development.md @@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com 2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see [§ Release image](#release-image)). -**Planned version cuts** (from pin `2.3.0`; adjust if semver changes mid-track): +**Planned version cuts** (from pin `2.4.0`; adjust if semver changes mid-track): | Stage | ID | Cut | |---|---|---| @@ -128,6 +128,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com | 15 | inbound-antispam-panel | `2.1.0` (was `1.10.0`, reordered 2026-10-09) | | 16 | delivery-log-storage | `2.2.0` | | 17 | panel-notifications | `2.3.0` | +| 18 | from-header-binding | `2.4.0` | Docs-only prep (checklists in plans, no product code) uses the same per-step commit rule but **no** version cut until the next product stage ships. diff --git a/docs/guide.md b/docs/guide.md index 06a8a51..cb54a24 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -184,7 +184,7 @@ cat ./data/setup-token #### Fixed image tag `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), -deliberately never `:latest`. The current pin is `2.3.0`. Intermediate +deliberately never `:latest`. The current pin is `2.4.0`. Intermediate CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that image was published. Pinning matters because of the backup version check (see [Full backup and restore](#full-backup-and-restore)): the panel binary's diff --git a/docs/plans/from-header-binding.md b/docs/plans/from-header-binding.md index 864a661..354347a 100644 --- a/docs/plans/from-header-binding.md +++ b/docs/plans/from-header-binding.md @@ -1,6 +1,6 @@ # Plan: from-header-binding -**Status:** agreed 2026-10-12 - the design questions were closed with the owner one at a time and he took it into work +**Status:** shipped in `2.4.0` (2026-10-12); agreed the same day - the design questions were closed with the owner one at a time and he took it into work ("Да") **Date:** 2026-10-12 **Version:** `2.4.0` MINOR (one migration; it refuses mail that was accepted before). @@ -148,3 +148,87 @@ the fourth was not. - [x] `go vet`, `go test`, e2e: refused for another domain's `From:`, for the hostname's and for an address off the list; accepted for its own (on the test server: vet ok, 23 packages ok, e2e ok - `sender_check_holds_the_from_line`, `panel_down_defers_mail`) - [x] Technical review — **Opus** (2026-10-12; one Critical, fixed: a folded `From:` line - what a mail library makes of a long display name - was refused for good. Also fixed: a display name in a charset Go does not convert was refused; the statistics ran before the check served, and a hung `supervisorctl` would have kept it from serving; the count of deferred mail missed a check that stops answering in the middle of a message, and is now taken at Postfix's warning; a card whose figures could not be read said ok; a killed container's stale socket satisfied the wrapper's wait; outside quoted strings the line may now hold one `@` only. Three were first left as found and fixed the same evening at the owner's question: downtime is cut at the UTC midnights it crossed; the row of a message with the null envelope sender takes the domain the login is bound to; a log read again after an unclean stop does not count again what it counted before) - [x] Security review — **Fable** (2026-10-12; no Critical or High. The same folded line, as its one Medium. Fixed: another sender's name in an encoded display name passed the second look; a client could arm the deferred counter by writing the socket's name where Postfix quotes it in a warning; a refused message wrote a row per recipient, a thousand at most - now one. Recorded: what a display name looks like to a person is not promised. The review also found the check stricter than Postfix for an address with an extension (`invoices+x@`, allowed in the envelope and refused in the header); the owner asked for it to work, and the check now makes Postfix's second try, with `recipient_delimiter` set explicitly. Confirmed from the code: no path to the signer around the check, and every failure of the check defers) + +## Not done + +Written at the owner's word when he had the version cut ("Запиши все +недоделки в план", 2026-10-12). Nothing here was promised and dropped in +silence; each line says what is missing and what it would take. + +### Decided to wait + +- **A process of its own for the check.** The owner: measure first. Server › + Health now has the numbers; the decision is his when they have had time to + say something. Moving the role out is a change of where it is started - + a subcommand of the panel binary and a `[program:]` in supervisord - and + the downtime figure then needs a new meaning. + +### Shown to the owner one way and built another + +- **The Delivery log box is on a refused message's page.** The picture he + approved had left it out; the built page keeps it, with one sentence. And + the sentence is less than the truth for a `From:` refusal: cleanup did + write a line about it under a queue id (`milter-reject: END-OF-MESSAGE … + 5.7.1 From: …`), but the check answers before the queue id reaches it, so + the row keeps none and the page cannot show that line. To do: either drop + the box for a refused row, or have the tailer attach the line to the row. +- **The Outbound log has no status filter.** The mockup draws one; the built + log filters by domain and application. With three kinds of refusal now + told apart by tag, a filter by status and reason would be worth having. +- **"Messages deferred" counts connections Postfix turned away.** Postfix + refuses from the connection on, before a message is offered, so a monitor + that only connects during an outage is counted. The guide says so; the + card's column head and the help topic still say "messages". + +### Known and small + +- **"Today" on the card is the UTC day**, and the card does not say so. +- **A deferral in the very second of the last one counted before an unclean + stop is passed over** at the restart: `mail.log` stamps whole seconds. + Chosen over counting a stretch of the log twice. +- **Deferrals are lost when `mail.log` is rotated while the panel is down**: + the tailer starts on the new file. +- **A start at which `supervisorctl` fails counts no downtime**; it is + logged. +- **Logins are compared as written**, Postfix compares them without regard + to case. The panel writes logins one way, so nothing differs today. +- **The phone order of the `cards` layout is written for four boxes to a + stack** and takes every child of a stack for a box (said at the partial). + The rule the technical review suggested instead - the order from the + box's own number - changes the component and would be drawn first. +- **Health's socket check looks at the file, not at who listens**: a socket + nobody answers on would read "ok". The start now removes stale sockets, + which was the one way to get such a file. +- **The kit page's table of status tags does not list the new ones** + (`wrong From`, `wrong client`, `rate-limited`), and `rate-limited` is grey + beside two red tags - as the mockup has it, not as a decision. +- **Refusal rows still in the buffer when the panel stops are not written**; + the refusals themselves were answered. + +### Accepted, in security.md + +- **The panel's downtime is mail's.** The price of the strict check. +- **What a display name looks like to a person is not promised.** A name of + another sender written without an `@` the scan can find passes, signed + with the application's own key. +- **A malformed packet on the check's socket ends the panel** (the milter + library panics on an empty one). The socket is reachable only inside the + container, by the group Postfix is in; the journal milter always had this, + and what is new is that the price is deferred mail. + +### Not verified + +- **OpenDKIM's reading of a `From:` line** was reasoned about, not run: its + parser is not in this repository. The two rules that do not depend on it - + no other signing name after any `@`, one `@` outside quotes - are what the + claim rests on. A test that feeds the stand's OpenDKIM the lines the check + passes and reads which key it signs with would settle it. +- **The pages were not looked at in a browser on the deployed panel**: I + cannot sign in there. They were rendered from the fixtures and + screenshotted, the polling and the phone order read from the code and + exercised by e2e only as far as the Health numbers. +- **No refusal has been seen on the test server itself**, only on the e2e + stand: there is no application there I can send as. +- **A check that stops answering in the middle of a message** is counted + from a warning composed from Postfix's source; the stand only produced the + case of nobody listening. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2b818b5..2a7ee60 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.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 | built and reviewed, for `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) | +| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) | | 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) | @@ -47,7 +47,8 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and **inbound-antispam-panel** as `2.1.0` (2026-10-12), built in the new component kit — styling its two screens once instead of twice. **delivery-log-storage** -shipped as `2.2.0` the same day, and **panel-notifications** as `2.3.0`. +shipped as `2.2.0` the same day, **panel-notifications** as `2.3.0` and +**from-header-binding** as `2.4.0`. Nothing is agreed after it: every remaining item is a candidate. panel-docs shipped in [CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]` @@ -440,8 +441,9 @@ 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. -**Agreed with the owner on 2026-10-12** and taken into work -([plans/from-header-binding.md](plans/from-header-binding.md)). The rule he +**Agreed with the owner on 2026-10-12**, built and reviewed the same day +([plans/from-header-binding.md](plans/from-header-binding.md); what is not +done is listed there under *Not done*). The rule he chose is wider than the one first written here: `From:` is held to exactly what the envelope is held to - any address of the application's domain, or the addresses of its list - and anything else is refused, a stranger's domain @@ -454,8 +456,9 @@ needs a process of its own. tenant's `From:`, for the hostname's and for an address off its list, and accepted for its own. -**Version:** `2.4.0` MINOR; in progress. Until it ships: -[security.md](security.md) § Accepted risks. +**Version:** shipped `2.4.0` (2026-10-12), MINOR. What it costs - the +panel's downtime is now mail's - is in [security.md](security.md) § Accepted +risks. --- diff --git a/docs/security.md b/docs/security.md index 4becc4a..aca88c9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -476,6 +476,14 @@ deferred item from the roadmap. 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. - **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@` by right. The