logtail: Inbound log takes Postfix's own tail, not a recipient's (audit M2)
Security audit 2026-10-17, finding M2, stage 0 - proven on the live server: Postfix quotes a refused recipient in its reply in internal form (quotes removed, "; < > =" kept: extract_addr, tok822_internalize), so RCPT TO:<"; from=<ceo@…> to=<board@…> proto=ESMTP helo=<x>; "@domain> spelled a whole tail inside the reply, and inboundRejectRe, whose reply ended at the FIRST "; from=<", took the stranger's sender, recipient and inbound domain for the row. Now the reply is greedy and the tail anchored: "; from=<…> to=<…> proto=\S+", a HELO name holding none of Postfix's NEUTER_CHARACTERS (smtpd.c:1634, helo_cmd neuters ` <>()\";@`), and one-word SASL fields to the end of the line (a `.*` SASL tail would have let a quoted MAIL FROM spell the whole tail). Belt and braces: a reply that still holds "; from=<" - a line with two tails - makes no row, so neither a recipient nor an un-neutered HELO name can choose one. The quote/bracket/space filter on from/to stays. Tests: TestInboundRecipientCannotWriteTheRow (the live line's shape, and with a forged MAIL FROM too); TestInboundHeloCannotWriteTheRow now feeds HELO names as Postfix logs them (neutered) and expects no row for the un-neutered ones. go test ./internal/logtail on Windows (the three known Windows-only Follow failures aside). Docs: security.md § Mail path; CHANGELOG; plan line ticked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -697,10 +697,13 @@ the argument.
|
||||
in postfix-config.sh:538,545; an e2e case plants a link and expects the
|
||||
log line. — **Opus**, 0.5 h. *Done 2026-10-17:* TestStartsOnA28Data
|
||||
plants a dangling `dmarc_transport` with DMARC reports on.
|
||||
- [ ] **M2.** Greedy reply group anchored on Postfix's own tail; the live
|
||||
- [x] **M2.** Greedy reply group anchored on Postfix's own tail; the live
|
||||
line above as a test; security.md § Mail path reworded ("the last `;
|
||||
from=<`, which is Postfix's because a HELO name can hold none of
|
||||
`<>;\"`"). — **Opus**, 1 h.
|
||||
`<>;\"`"). — **Opus**, 1 h. *Done 2026-10-17:* the HELO name in the
|
||||
tail may hold none of the neutered characters and each SASL field is one
|
||||
word (a `.*` SASL tail would have let a quoted sender spell the whole
|
||||
tail); TestInboundRecipientCannotWriteTheRow.
|
||||
- [ ] **M5.** Keep at most two `From:`/`Sender:` values; security.md:810
|
||||
reworded. — **Opus**, 0.5 h.
|
||||
- [ ] **A4.** Duplicate-cookie line through `loggedPath`. — **Sonnet**, 0.25 h.
|
||||
|
||||
+11
-4
@@ -441,10 +441,17 @@ operator can act on it.
|
||||
The signature goes on to the upstream with the mail and is of no use there:
|
||||
it fits one queue id. 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,
|
||||
which Postfix logs with non-printable characters and ` <>()\";@` turned into
|
||||
`?` (Postfix 3.10.13, src/smtpd/smtpd.c `helo_cmd()`) - 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.
|
||||
the *last* `; from=<…> to=<…> proto=…` that runs to the end of the line.
|
||||
That tail is Postfix's own: the HELO name in it is logged with
|
||||
non-printable characters and ` <>()\";@` turned into `?` (Postfix 3.10.13,
|
||||
src/smtpd/smtpd.c `helo_cmd()`), and the pattern takes none of them there;
|
||||
a refused recipient, which the reply quotes with its quotes removed and
|
||||
`; < > =` kept, can spell such a tail only inside a quoted local part,
|
||||
whose quote then lands in what would be the sender or the recipient - and
|
||||
an address with a quote, a bracket or a space in it is not kept. A line
|
||||
whose reply still holds `; from=<` - two tails - makes no row. (Until
|
||||
`2.14.4` the reply ended at the *first* `; from=<`, which a stranger's
|
||||
recipient could supply - security audit 2026-10-17, M2.)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user