sender check: From: read with OpenDKIM's own parser; L8 closed
test / test (push) Canceled after 0s

Audit 2026-10-12, stage 2c, the owner's decision of 2026-10-14: the check
decides by the address OpenDKIM signs for, whatever a display name or a
comment holds.

internal/milter/mailparse.go ports libopendkim's dkim_mail_parse() with
dkim_mail_first_special(), dkim_mail_token() and
dkim_mail_matching_paren() from Debian's opendkim 2.11.0~beta2-8+deb12u1,
line for line: the '<' form, group and list punctuation, an '@' before the
first ':', ';' or ',' deciding where the address starts, source routes
dropped, quoted strings and domain literals kept with their delimiters,
comments skipped, whitespace between atoms one space, no encoded word
decoded; and the C's own quirks ("@d" gives d as the local part too).
testdata/mailparse_harness.c compiles the C from the image's source; the
test feeds it and the port 200 019 lines (the hand-written cases and
200 000 random ones over the specials) - 93 443 parse and agree byte for
byte, 106 576 fail and agree on the error (on an error the C leaves its
in-place result unterminated, and OpenDKIM uses neither part then).

judge(): one From: field and one address stay required (moreAfter: only
list or group punctuation may follow); what OpenDKIM cannot read is
refused (mlfi_eoh() would not sign it); the address is lower-cased in
ASCII only, as dkimf_lowercase() and Postfix's tables do. Found reading
dkimf_apply_signtable(): OpenDKIM matches its refile SigningTable against
"user@domain" printed into MAXADDRESS + 1 = 257 bytes, so a longer address
is matched cut short - a quoted local part padded to end there in
"@another.tenant" would be signed with that tenant's key while its real
domain is the application's. Such an address (over 256 bytes) is refused;
one that fits is matched whole. The display-name and comment scan, the
"one @ outside quotes" rule and the SigningTable reader (Signers) go; the
check asks for header values with their leading space as OpenDKIM does
(OptHeaderLeadingSpace; Postfix keeps a v2 milter's event mask, milter8.c
milter8_negotiate()).

L8 closed: refused or passed no longer depends on other domains of the
server; security.md's accepted risk is struck. The shared other-sender
lines are now held to "never signed for the other sender": unit test (a
passed line is read at the application's domain) and e2e (a passed line
arrives signed for the application's domain, none for the other).
Documents: architecture "How the header is read", security's sender
check paragraph, the guide; the help topic per 2cf118c.

Stand (2026-10-15, make e2e, exit 0): sender_check_holds_the_from_line -
another sender's address in the display name now passes; a From: whose
quoted local part is padded so that, cut at 256 bytes, it ends in
"@other.e2e.test" is refused 550 5.7.1 "the address is longer than
OpenDKIM reads whole"; lines_naming_another_sender_never_signed_for_it
and passed_from_lines_signed_for_their_domain_only pass. go vet and go
test ./... pass on the server (24 packages); the differential test ran
there against the compiled C.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 02:48:02 +03:00
co-authored by Claude Opus 5.5
parent 2cf118cafc
commit 3584adcbc2
16 changed files with 734 additions and 268 deletions
+11 -13
View File
@@ -286,19 +286,17 @@ server itself.
`src/global/mail_addr_find.c`, `mail_addr_find_opt()`). A key written
twice makes Postfix refuse the whole table, and the check defers then too.
- **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
address with a parser of its own, and wherever the two could read a line
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`; 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.
one, is refused), read with OpenDKIM's own parser: libopendkim's
`dkim_mail_parse()`, ported line for line from Debian's opendkim
2.11.0~beta2 (`internal/milter/mailparse.go`) and tested against the C on
two hundred thousand lines. The check asks Postfix for header values with
their leading space, as OpenDKIM does (`SMFIP_HDR_LEADSPC`), so both read the
same bytes. The address it returns is the one judged - the one OpenDKIM
signs for - whatever a display name, a comment or a quoted local part
holds; a parse failure, no address, or a second one after it is refused,
and so is an address over 256 bytes, which OpenDKIM would match its
`SigningTable` against cut short (`dkimf_apply_signtable()`,
`MAXADDRESS`). Encoded words are not decoded, by either.
- **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
+11 -1
View File
@@ -852,7 +852,7 @@ guard against two parsers parting ways - and it is what makes L8 half
closed. This stage replaces the guard with the parser itself. Stage 3 and
those after it move up one patch number each.
- [ ] **Port `dkim_mail_parse` to Go, from the version the image runs**
- [x] **Port `dkim_mail_parse` to Go, from the version the image runs**
(Debian bookworm's opendkim 2.11.0~beta2 source package, not upstream's
develop branch), line for line, with its tokenizer
(`dkim_mail_first_special`, `dkim_mail_token`). The check then decides by
@@ -865,6 +865,16 @@ those after it move up one patch number each.
here (L8 closed; security.md's accepted risk struck). Whether the "one @
outside quotes" rule is still needed is decided against the port, and
said. - **Opus**
*Done 2026-10-15:* `internal/milter/mailparse.go`; the C itself compiled
from the image's source agrees with the port on 200 019 lines
(`testdata/mailparse_harness.c`). The "one @ outside quotes" rule goes:
the owner's decision says the address decides "whatever the display name
or a comment holds", and with OpenDKIM's parser there is no second one to
part ways with. Found reading the source: OpenDKIM matches its
SigningTable against "user@domain" cut to 256 bytes (MAXADDRESS), so a
longer address is refused - cut there, a quoted local part could end in
another tenant's domain. The check asks for header values with their
leading space, as OpenDKIM does; the SigningTable reader goes.
- [ ] **The port checked against OpenDKIM itself.** On the stand, several
hundred generated `From:` lines - quotes, escapes, comments nested and
open, groups, brackets, encoded words, a second domain of the server in
+7 -7
View File
@@ -1204,13 +1204,13 @@ Postfix asks who owns the address before it asks who owns the domain.
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. 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
or no `From:` at all. The line is read the way the signer, OpenDKIM, reads
it - the panel runs OpenDKIM's own parser - so only its address counts: what a
display name or a comment around it says is not judged, and a line with more
than one address is refused. A display name with a comma or brackets has to
be quoted (`"Shop, Inc." <orders@…>`), as the mail standard asks and mail
libraries do; unquoted, OpenDKIM may take a word of the name for the
address. 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.
+1 -1
View File
@@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.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) |
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 46/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`; next stage 2c → `2.5.2`) | 47/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
+28 -33
View File
@@ -215,28 +215,27 @@ operator can act on it.
does not sign for included (the owner's rule: the header may say what the
envelope may, nothing more). It is the first milter of the chain and
strict: a message it could not judge is deferred, never passed on to the
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; outside quoted strings
the line may hold one `@`. That the two agree is tested, not only argued:
the e2e sends every `From:` line the check passes - a list it shares with
the check's unit test, `internal/milter/testdata/from-lines-passed.txt` -
and wants the message signed for the application's domain and no other;
and every line of a second list, lines that name another sender of the
server in a quoted string, a comment, a quoted pair, capitals or an encoded
word (`from-lines-other.txt`), must be refused and never arrive signed for
that sender. The reply to a refusal says that the line names another
sender of the server, not which. That a line naming a domain is refused
when the domain is a sender of this server, and passed when it is not,
still tells an application which domains are hosted here; the probe needs
an application of this server, and refusing every foreign address in a
display name would change the 2.4.0 rule - accepted by the owner
(accepted risks). 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
signer. Since `2.5.2` it reads the header with OpenDKIM's own parser,
`dkim_mail_parse()` ported line for line from the opendkim the image runs
(`internal/milter/mailparse.go`), and judges the address that parser
returns - the address OpenDKIM signs for - whatever a display name, a
comment or a quoted local part holds; one `From:` field and one address in
it stay required, and an address longer than the 256 bytes OpenDKIM
matches its `SigningTable` against is refused, since cut there it could end
in another domain's name (opendkim.c, `dkimf_apply_signtable()`,
`MAXADDRESS`). That the port is OpenDKIM's parser is tested against the C
itself, two hundred thousand lines and every result alike; that the signed
domain follows, by the e2e: it sends every `From:` line the check passes (a
list shared with the unit test,
`internal/milter/testdata/from-lines-passed.txt`) and wants it signed for
the application's domain and no other, and every
line of a second list naming another sender of the server in a quoted
string, a comment, a quoted pair, capitals or an encoded word
(`from-lines-other.txt`) must be refused or arrive signed for the
application's domain, never for that sender. What this does not promise is
what a display name looks like to a person: a name of another sender in
it 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,
@@ -459,17 +458,13 @@ deferred item from the roadmap.
through - but no message data. The inbound journal itself is in the
database, 0640. Trigger to revisit: a shared host, where the sidecar should
instead be given the files through a group it shares with the panel.
- **An application can learn which domains are senders of this server.**
A `From:` whose display name or comment names an address at a domain this
server signs for is refused; one naming any other domain passes. The reply
no longer names the domain (audit 2026-10-12, L8), but refused or passed
answers the question for whoever sends the line. Accepted by the owner,
2026-10-14: it needs an application of this server - a trusted tenant
guessing names one at a time, as it already had to before L8, when the
reply only said in words what the line had named - and closing it means
refusing every foreign address in a display name, which would change his
2.4.0 rule (a stranger's address in the name passes). Trigger to revisit:
tenants who are not trusted, or a display-name rule decided anew.
- ~~**An application can learn which domains are senders of this server.**~~
Struck in `2.5.2` (audit 2026-10-12, stage 2c; L8 closed): the sender
check reads a `From:` line with OpenDKIM's own parser, ported
(`internal/milter/mailparse.go`), and decides by the address OpenDKIM signs
for; what a display name, a comment or a quoted local part names no longer
matters, so refused or passed says nothing about other domains of the
server.
- ~~**A row of the inbound log belongs to a domain by name.**~~ Struck in
`2.5.0` (audit 2026-10-12, L12): both logs now hold a domain by id, see
§ Roles.