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:
mix
2026-10-17 21:33:12 +03:00
co-authored by Claude Opus 5.5
parent f2ef705470
commit 683fdafc6d
5 changed files with 90 additions and 24 deletions
+5
View File
@@ -13,6 +13,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
check. Adding a user, setting a password, changing an e-mail or a role on check. Adding a user, setting a password, changing an e-mail or a role on
the user form, and deleting a user now ask for your own account password, the user form, and deleting a user now ask for your own account password,
as a backup does, under the same limit on wrong guesses. as a backup does, under the same limit on wrong guesses.
- **A stranger's recipient no longer writes an Inbound › Log row** (audit
M2): Postfix quotes a refused recipient in its reply with the quotes
removed, so `RCPT TO:<"; from=<x> to=<y> …"@domain>` spelled a second
sender and recipient there, and the parser took the first. It now takes
Postfix's own tail at the end of the line.
- **The entrypoint follows no link at the DMARC maps** (audit I2): the - **The entrypoint follows no link at the DMARC maps** (audit I2): the
three `dmarc_*` maps root creates at the start were missing from the list three `dmarc_*` maps root creates at the start were missing from the list
of paths where a symbolic link is removed first. of paths where a symbolic link is removed first.
+5 -2
View File
@@ -697,10 +697,13 @@ the argument.
in postfix-config.sh:538,545; an e2e case plants a link and expects the 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 log line. — **Opus**, 0.5 h. *Done 2026-10-17:* TestStartsOnA28Data
plants a dangling `dmarc_transport` with DMARC reports on. 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 `; 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 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 - [ ] **M5.** Keep at most two `From:`/`Sender:` values; security.md:810
reworded. — **Opus**, 0.5 h. reworded. — **Opus**, 0.5 h.
- [ ] **A4.** Duplicate-cookie line through `loggedPath`. — **Sonnet**, 0.25 h. - [ ] **A4.** Duplicate-cookie line through `loggedPath`. — **Sonnet**, 0.25 h.
+11 -4
View File
@@ -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: The signature goes on to the upstream with the mail and is of no use there:
it fits one queue id. Refusals are it fits one queue id. Refusals are
parsed from `mail.log` from the head of the line, with the reply ending at 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, the *last* `; from=<…> to=<…> proto=…` that runs to the end of the line.
which Postfix logs with non-printable characters and ` <>()\";@` turned into That tail is Postfix's own: the HELO name in it is logged with
`?` (Postfix 3.10.13, src/smtpd/smtpd.c `helo_cmd()`) - cannot stand in for non-printable characters and ` <>()\";@` turned into `?` (Postfix 3.10.13,
the sender, the recipient or the reply; an address with a quote, a bracket or a space in it is not kept. 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 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. 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 - **Filter lists** reach another container as files: values are whitelisted on
+24 -8
View File
@@ -101,15 +101,24 @@ type Inbound struct {
// addresses), and a pattern that could start anywhere would let him write // addresses), and a pattern that could start anywhere would let him write
// journal rows. // journal rows.
// //
// For the same reason the reply is the shortest text before "; from=<", and // The reply is the client's text too: a refusal quotes the refused
// the HELO name - the last thing on the line but the SASL fields above, which // recipient, and Postfix quotes it in internal form - quotes removed, "; < >
// Postfix logs as the client wrote it, spaces and brackets included - is // =" kept (extract_addr(), tok822_internalize(); smtpd_check_reject() applies
// whatever is left. Taking the longest reply instead would let a HELO name // only printable()) - so a stranger's RCPT TO:<"; from=<x> to=<y> ..."@d>
// that itself spells // spells a whole "; from=<…> to=<…> proto=…" inside the reply. The shortest
// "; from=<…> to=<…> proto=…" supply the sender, the recipient and the reply // reply ended there, and the row's sender, recipient and domain were the
// of the row (security review, 2026-10-12). // stranger's (security audit 2026-10-17, M2, proven on the test server).
// What the client cannot spell is the end of the line: the HELO name is
// logged with a space and ` <>()\";@` turned into "?" (src/smtpd/smtpd.c,
// helo_cmd(), NEUTER_CHARACTERS), and an address that holds "; from=<" or a
// ">" needs a quoted local part, whose quote then stands inside what the
// pattern would take for the sender or the recipient - which the filter
// below refuses. So the reply is the longest text before a "; from=<…>
// to=<…> proto=…" that runs to the end of the line, the HELO name holds none
// of the neutered characters, and each SASL field (never written on port 25)
// is one word. A line it does not fit leaves no row.
var ( var (
inboundRejectRe = regexp.MustCompile(`^\S+ postfix/(inbound/smtpd|cleanup)\[\d+\]: ([0-9A-Za-z]+): (reject|milter-reject|milter-discard): \S+ from [^\[\s]*\[([0-9A-Fa-f:.]+)\]: (.*?); from=<([^>]*)> to=<([^>]*)> proto=\S+(?: helo=<.*>)?(?: sasl_(?:method|username|sender)=.*)?$`) inboundRejectRe = regexp.MustCompile(`^\S+ postfix/(inbound/smtpd|cleanup)\[\d+\]: ([0-9A-Za-z]+): (reject|milter-reject|milter-discard): \S+ from [^\[\s]*\[([0-9A-Fa-f:.]+)\]: (.*); from=<([^>]*)> to=<([^>]*)> proto=\S+(?: helo=<[^ <>()\\";@]*>)?(?: sasl_method=\S*)?(?: sasl_username=\S*)?(?: sasl_sender=\S*)?$`)
inboundClientRe = regexp.MustCompile(`^\S+ postfix/inbound/smtpd\[\d+\]: ([0-9A-Za-z]+): client=`) inboundClientRe = regexp.MustCompile(`^\S+ postfix/inbound/smtpd\[\d+\]: ([0-9A-Za-z]+): client=`)
// A queue id stops being port 25's when the message is queued (qmgr takes // A queue id stops being port 25's when the message is queued (qmgr takes
// it: the filter let it through), when cleanup removes the queue file of // it: the filter let it through), when cleanup removes the queue file of
@@ -211,6 +220,13 @@ func (j *inboundJournal) handle(line string, now time.Time, catchingUp bool) {
if strings.ContainsAny(from, "\"<> ") || strings.ContainsAny(to, "\"<> ") { if strings.ContainsAny(from, "\"<> ") || strings.ContainsAny(to, "\"<> ") {
return return
} }
// A reply that still holds "; from=<" is a line with two tails, one of
// them written by the client - a refused recipient quoted in the reply
// (audit 2026-10-17, M2) or a HELO name Postfix failed to neuter. Which
// is Postfix's is then a guess the journal does not make.
if strings.Contains(reason, "; from=<") {
return
}
if program == "cleanup" { if program == "cleanup" {
if _, ok := j.port25[queueID]; !ok { if _, ok := j.port25[queueID]; !ok {
return // a submission-port message, or one opened before this run return // a submission-port message, or one opened before this run
+44 -9
View File
@@ -256,12 +256,14 @@ func TestInboundHoldIsTheMiltersRow(t *testing.T) {
} }
} }
// The HELO name is the last thing on the line and is logged as the client // The HELO name is the last thing on the line. Postfix logs it with a space
// wrote it. It cannot supply the row: the reply ends at the first "; from=<", // and ` <>()\";@` turned into "?" (src/smtpd/smtpd.c, helo_cmd()), so one
// which is Postfix's own. // that tried to spell another refusal arrives neutered and the row is
// Postfix's own; a HELO name with those characters left in is no line
// Postfix writes, and makes no row.
func TestInboundHeloCannotWriteTheRow(t *testing.T) { func TestInboundHeloCannotWriteTheRow(t *testing.T) {
j, st := inboundFixture() j, st := inboundFixture()
j.handle(head+"postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<real@example.net> to=<nobody@in.example.com> proto=ESMTP helo=<x 5.7.1 Refused by this server's deny list; from=<victim@bank.example> to=<ceo@in.example.com> proto=ESMTP helo=<y>", tailNow, false) j.handle(head+"postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<real@example.net> to=<nobody@in.example.com> proto=ESMTP helo=<x?5.7.1?Refused?by?this?server's?deny?list??from=?victim?bank.example??to=?ceo?in.example.com??proto=ESMTP?helo=?y?>", tailNow, false)
if len(st.rows) != 1 { if len(st.rows) != 1 {
t.Fatalf("rows = %+v", st.rows) t.Fatalf("rows = %+v", st.rows)
} }
@@ -271,23 +273,56 @@ func TestInboundHeloCannotWriteTheRow(t *testing.T) {
t.Errorf("row = %+v", got) t.Errorf("row = %+v", got)
} }
// A HELO name with a closing bracket in it does not lose the line either. // Un-neutered, which Postfix never writes: a line with two tails makes
// no row, and above all not the one the HELO name spells.
for _, line := range []string{
head + "postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<real@example.net> to=<nobody@in.example.com> proto=ESMTP helo=<x 5.7.1 Refused by this server's deny list; from=<victim@bank.example> to=<ceo@in.example.com> proto=ESMTP helo=<y>",
head + "postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: no; from=<real@example.net> to=<nobody@in.example.com> proto=ESMTP helo=<a>b>",
} {
j, st = inboundFixture() j, st = inboundFixture()
j.handle(head+"postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: no; from=<real@example.net> to=<nobody@in.example.com> proto=ESMTP helo=<a>b>", tailNow, false) j.handle(line, tailNow, false)
if len(st.rows) != 1 || st.rows[0].From != "real@example.net" { if len(st.rows) != 0 {
t.Errorf("rows = %+v", st.rows) t.Errorf("a line with an un-neutered HELO name wrote %+v", st.rows)
}
} }
// The same on a verdict line: the filter's reply is not extended by the // The same on a verdict line: the filter's reply is not extended by the
// HELO name, so a stranger cannot make a refusal look like the deny list's. // HELO name, so a stranger cannot make a refusal look like the deny list's.
j, st = inboundFixture() j, st = inboundFixture()
j.handle(head+"postfix/inbound/smtpd[41]: Q1: client=x[203.0.113.9]", tailNow, false) j.handle(head+"postfix/inbound/smtpd[41]: Q1: client=x[203.0.113.9]", tailNow, false)
j.handle(head+"postfix/cleanup[52]: Q1: milter-reject: END-OF-MESSAGE from x[203.0.113.9]: 5.7.1 Spam message rejected; from=<real@example.net> to=<b@in.example.com> proto=ESMTP helo=<x this server's deny list; from=<spoofed@example.org> to=<b@in.example.com> proto=ESMTP helo=<y>", tailNow, false) j.handle(head+"postfix/cleanup[52]: Q1: milter-reject: END-OF-MESSAGE from x[203.0.113.9]: 5.7.1 Spam message rejected; from=<real@example.net> to=<b@in.example.com> proto=ESMTP helo=<x?this?server's?deny?list??from=?spoofed?example.org??to=?b?in.example.com??proto=ESMTP?helo=?y?>", tailNow, false)
if len(st.rows) != 1 || st.rows[0].Detail != "5.7.1 Spam message rejected" || st.rows[0].From != "real@example.net" { if len(st.rows) != 1 || st.rows[0].Detail != "5.7.1 Spam message rejected" || st.rows[0].From != "real@example.net" {
t.Errorf("rows = %+v", st.rows) t.Errorf("rows = %+v", st.rows)
} }
} }
// The refused recipient is quoted in the reply in internal form - quotes
// removed, "; < > =" kept - so it can spell a whole "; from=<…> to=<…>
// proto=…" there. The line below is the shape mail.log on the test server
// held on 2026-10-17 for such a RCPT TO sent from outside (the audit's probe
// used a domain that is not inbound; here it is one). It writes no row in
// the stranger's words (security audit 2026-10-17, M2).
func TestInboundRecipientCannotWriteTheRow(t *testing.T) {
forged := `; from=<ceo@example.net> to=<board@in.example.com> proto=ESMTP helo=<x>; `
for name, line := range map[string]string{
"recipient unknown": head + `postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <` + forged + `@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<probe@example.net> to=<"` + forged + `"@in.example.com> proto=ESMTP helo=<audit.example>`,
"forged sender also in MAIL FROM": head + `postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <` + forged + `@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<"x; from=<ceo@example.net"@a.example> to=<"` + forged + `"@in.example.com> proto=ESMTP helo=<audit.example>`,
} {
j, st := inboundFixture()
j.handle(line, tailNow, false)
if len(st.rows) != 0 {
t.Errorf("%s: a line with two tails wrote %+v", name, st.rows)
}
}
// A plain refusal is still read whole, its reply up to Postfix's tail.
j, st := inboundFixture()
j.handle(unknownRcp, tailNow, false)
if len(st.rows) != 1 || st.rows[0].From != "a@example.net" || st.rows[0].To != "nobody@in.example.com" {
t.Errorf("rows = %+v", st.rows)
}
}
// A queue id is port 25's only until the message is queued, cleanup removes // A queue id is port 25's only until the message is queued, cleanup removes
// it unqueued, or the id is opened again by another listener - Postfix reuses // it unqueued, or the id is opened again by another listener - Postfix reuses
// ids. // ids.