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:
+11
-13
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user