from-header-binding: findings of the technical and security reviews
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:
mix
2026-10-12 20:06:02 +03:00
co-authored by Claude Opus 5.5
parent c171fb5bc7
commit 89fd0a6116
17 changed files with 239 additions and 85 deletions
+14 -4
View File
@@ -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