from-header-binding: findings of the technical and security reviews
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
Both reviews were of c171fb5, read-only, given the code and questions.
Found by both, and the one that would have refused ordinary mail from the
first day: a folded From: line - what a mail library makes of a long display
name - did not parse and was refused with 550. The line is unfolded before it
is read. e2e sends one through Postfix now.
Technical (Opus)
- A display name in a charset Go does not convert (windows-1251, koi8-r) was
refused: such a name is passed through unread, only the address is judged.
- The statistics ran between binding the socket and serving it, with a
supervisorctl that has no timeout: they are kept beside the check now and
nothing of them is waited for. A supervisorctl that fails is logged.
- The count of deferred mail followed smtpd's reject lines and so missed a
check that stops answering in the middle of a message, where cleanup
writes them: it is taken at Postfix's warning, the line that names the
socket. The test line for that case had been invented with the wrong
process; it is marked as composed from Postfix's source.
- A card whose figures could not be read showed zeros and "ok": dashes and
"unknown".
- entrypoint removes the sockets a killed container left, which satisfied
the wrapper's wait before the check was listening.
- Outside quoted strings the line may hold one "@" only - a rule that needs
no knowledge of OpenDKIM's parser.
- Comments that still spoke of two sockets, or of a session replaced after a
refusal; the limits of the cards layout on a phone, said where it is defined.
Security (Fable; no Critical or High)
- Another sender's name in an encoded display name passed the second look:
the decoded name is looked at as well.
- A client could arm the deferred counter by sending the socket's name where
Postfix quotes it in a warning: the two warnings are matched from their
first word.
- A refused message wrote a row per recipient: one row, naming the first
recipient and how many more.
- Docs: the check's lookup is stricter than Postfix's for address extensions
and the case of logins, rather than "cannot disagree"; what a display name
looks like to a person is not promised; the timeouts and the 454 at AUTH
stated as they are; "messages" are connections turned away.
Left as found, in the plan: downtime that crosses midnight is booked on the
day of the restart; a message with the null envelope sender leaves a row
without a domain; a log read again after an unclean stop counts again.
Test server, this tree: go vet ok, go test 23 packages ok, make e2e ok (330 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+14
-4
@@ -233,8 +233,10 @@ server itself.
|
||||
bound to the login, or `@domain` is. So an application set to any address of
|
||||
its domain may write any address of that domain, and one with a list only
|
||||
the listed ones; everything else is refused - another registered domain,
|
||||
the server's name, a domain the server knows nothing of. One source for
|
||||
both senders: the two cannot disagree.
|
||||
the server's name, a domain the server knows nothing of. One table for
|
||||
both senders. Where the lookups differ the check is the stricter one:
|
||||
Postfix also tries an address without its `+extension` and compares logins
|
||||
without regard to case.
|
||||
- **How the header is read.** Exactly one `From:` field (none, or more than
|
||||
one, is refused), parsed as an address list that must hold exactly one
|
||||
address. Then a cruder second look at the raw value: OpenDKIM finds the
|
||||
@@ -242,7 +244,13 @@ server itself.
|
||||
differently - a display name, a comment, a quoted string - the other
|
||||
address would have to be spelled out in it. So no `@name` may appear there
|
||||
that is another name the server signs for; those names are read from
|
||||
OpenDKIM's own `SigningTable`.
|
||||
OpenDKIM's own `SigningTable`; the display name is looked at once more
|
||||
as decoded, since a mail program shows `=?utf-8?q?director=40example.org?=`
|
||||
as an address though no `@` is written in it. And, needing no knowledge of
|
||||
that parser:
|
||||
outside quoted strings the line has exactly one `@`. A folded line is
|
||||
unfolded first, and a display name in a charset Go does not convert is
|
||||
passed through unread - only the address is judged.
|
||||
- **The verdict** comes at the end of the headers: `550 5.7.1`, permanent,
|
||||
naming the line, as a reply code of the milter's own. What the client wrote
|
||||
is reduced to printable ASCII and cut before it is said back, logged or
|
||||
@@ -257,7 +265,9 @@ server itself.
|
||||
table from before would let a deleted application go on sending. The
|
||||
send-log row of a refusal (`status=rejected`, `reason=from`,
|
||||
`header_from`) is handed to another goroutine through a buffer and is not
|
||||
waited for.
|
||||
waited for. It is one row for the message, naming the first recipient and
|
||||
how many more there were: a row per recipient would let one refused message
|
||||
write a thousand.
|
||||
- **What it costs is counted.** The role marks itself alive every two
|
||||
seconds (the socket's path with `.alive`, under `/run`, so a new container
|
||||
starts without one). At start the time since the mark is the check's
|
||||
|
||||
+15
-6
@@ -392,7 +392,10 @@ readings, in the order of what stops mail first:
|
||||
panel did not answer while Postfix was running, and how many messages
|
||||
Postfix deferred because of it (see [Who an application may send
|
||||
as](#who-an-application-may-send-as)). A restart of the whole container is
|
||||
not counted: mail was stopped for another reason then. The card warns when
|
||||
not counted: mail was stopped for another reason then. What is counted as
|
||||
a message is a connection Postfix turned away - it refuses before a
|
||||
message is offered - so a monitor that only connects during an outage is
|
||||
counted too. Days are UTC days. The card warns when
|
||||
a message was deferred in the last 24 hours. If these numbers do not stay
|
||||
at nothing, the panel is in the way of your mail more than it should be -
|
||||
report it.
|
||||
@@ -1097,8 +1100,13 @@ listed for it. Since `2.4.0` that rule covers both senders a message has:
|
||||
A message whose `From:` is anything else is refused with `550 5.7.1` and a
|
||||
text naming the line: another of your domains, the server's own name, an
|
||||
address at a domain that is not on this server, a line with two addresses,
|
||||
or no `From:` at all. A name this server signs for may not appear anywhere
|
||||
in the line besides the address itself, a display name included. The refusal
|
||||
or no `From:` at all. The line is read strictly, because two programs read
|
||||
it - the check and the signer - and must not be able to read it differently:
|
||||
outside a quoted display name it may hold one `@`, the address's own, and no
|
||||
other name this server signs for may follow an `@` anywhere in it, a quoted
|
||||
name included. A display name with a comma or brackets has to be quoted
|
||||
(`"Shop, Inc." <orders@…>`), as the mail standard asks and mail libraries
|
||||
do. The refusal
|
||||
is permanent, so the application does not send the message again; it is a
|
||||
row of the [Outbound log](#outbound-log) tagged `wrong From`, and the row's
|
||||
page shows what the line said.
|
||||
@@ -1113,9 +1121,10 @@ What this stops working when you upgrade from an earlier version:
|
||||
`Reply-To:` and send from an address of your own, which is also what
|
||||
DMARC requires.
|
||||
|
||||
The check is strict: while the panel does not answer, Postfix replies `451`
|
||||
and applications send again later; nothing is accepted unchecked. **Server ›
|
||||
Health** counts how often that happens.
|
||||
The check is strict: while the panel does not answer, Postfix answers with a
|
||||
temporary error (`451`, and `454` to the login) and applications send again
|
||||
later; nothing is accepted unchecked. **Server › Health** counts how often
|
||||
that happens.
|
||||
|
||||
*Export domain* writes a single-domain file; *Import a domain* on *Server ›
|
||||
Backup* reads one back in (**global role only**) — see [Exporting and importing
|
||||
|
||||
@@ -146,5 +146,5 @@ the fourth was not.
|
||||
- [x] Panel: Health (card, sockets row, layout), Outbound log tags, the message's page, help
|
||||
- [x] guide.md, architecture.md, security.md (the accepted risk goes), schema-migrations.md
|
||||
- [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`)
|
||||
- [ ] Technical review — **Opus**
|
||||
- [ ] Security review — **Fable**
|
||||
- [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. Left as found: downtime that crosses midnight is booked on the day of the restart; a message with the null envelope sender leaves a row without a domain; a log read again after an unclean stop counts its deferrals again)
|
||||
- [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: the check's lookup is stricter than Postfix's in two places, address extensions and the case of logins; what a display name looks like to a person is not promised. Confirmed from the code: no path to the signer around the check, and every failure of the check defers)
|
||||
|
||||
+1
-1
@@ -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 | in progress, for `2.4.0` | 7/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 | built and reviewed, for `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) |
|
||||
|
||||
|
||||
+11
-4
@@ -202,8 +202,14 @@ operator can act on it.
|
||||
signer. It reads the header as one field holding one address and then
|
||||
looks at the raw value once more for any other name of OpenDKIM's
|
||||
`SigningTable`, so that a line its parser and OpenDKIM's could read
|
||||
differently is refused rather than argued about. What the client wrote
|
||||
reaches the reply, the log and the send log only as printable ASCII, cut.
|
||||
differently is refused rather than argued about; outside quoted strings
|
||||
the line may hold one `@`. What this does not promise is what a display
|
||||
name looks like to a person: a name of another sender written without an
|
||||
`@` the scan can find (a full-width dot, a space inside the name) 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. 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.
|
||||
Mail the panel submits itself does not pass the check: only root,
|
||||
`postfix` and the panel can submit locally. Architecture § Sender check.
|
||||
- **Inbound relay (optional, `INBOUND_RELAY_ENABLE`)** — port 25 is not an
|
||||
@@ -459,8 +465,9 @@ deferred item from the roadmap.
|
||||
the rules arrive at.
|
||||
- **The sender check stops mail while the panel does not answer.** It is a
|
||||
role of the panel process, attached with `default_action=tempfail`, so a
|
||||
panel that is down or slower than the milter timeouts (15 s a command)
|
||||
defers every submission: availability of sending now depends on the panel,
|
||||
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: availability of sending now depends on the panel,
|
||||
which it did not before `2.4.0`. 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 restarts the panel at
|
||||
|
||||
Reference in New Issue
Block a user