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:
@@ -29,6 +29,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
case, owners may be separated by blanks or continue on the next line, and
|
case, owners may be separated by blanks or continue on the next line, and
|
||||||
a key written twice makes the check defer, as it makes Postfix refuse the
|
a key written twice makes the check defer, as it makes Postfix refuse the
|
||||||
whole table (assumptions B-PS1, B-PS4).
|
whole table (assumptions B-PS1, B-PS4).
|
||||||
|
- **The sender check reads `From:` with OpenDKIM's own parser.** It used its
|
||||||
|
own parser and, to cover where the two could disagree, refused any line
|
||||||
|
naming another domain of the server in a display name or a comment - which
|
||||||
|
told an application, by refused or passed, which domains are hosted here
|
||||||
|
(audit 2026-10-12, L8, an accepted risk until now). It now runs OpenDKIM's
|
||||||
|
`dkim_mail_parse()`, ported line for line and tested against the C on two
|
||||||
|
hundred thousand lines, and judges the address OpenDKIM signs for. A display
|
||||||
|
name or a comment no longer matters - a line with another sender's address
|
||||||
|
only in its display name now passes, signed with the application's own
|
||||||
|
key; a line OpenDKIM finds no address in, or two addresses in, is refused,
|
||||||
|
and so is an address over 256 bytes, which OpenDKIM would match against its
|
||||||
|
signing table cut short - cut there, a quoted local part could end in
|
||||||
|
another domain's name.
|
||||||
- **Over IPv6 every client looks the same to SelfPost** - documented, not
|
- **Over IPv6 every client looks the same to SelfPost** - documented, not
|
||||||
changed. The compose network has no IPv6, so Docker's proxy carries an
|
changed. The compose network has no IPv6, so Docker's proxy carries an
|
||||||
IPv6 connection and the container sees the network's gateway
|
IPv6 connection and the container sees the network's gateway
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/domain"
|
|
||||||
"github.com/mixeme/selfpost/internal/health"
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
"github.com/mixeme/selfpost/internal/milter"
|
"github.com/mixeme/selfpost/internal/milter"
|
||||||
"github.com/mixeme/selfpost/internal/postfix"
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
@@ -67,8 +66,7 @@ func serveSenderCheck(ctx context.Context, cfg config, st *store.Store) error {
|
|||||||
// The two tables the mail path itself goes by: whom Postfix lets send as
|
// The two tables the mail path itself goes by: whom Postfix lets send as
|
||||||
// what, and what OpenDKIM signs.
|
// what, and what OpenDKIM signs.
|
||||||
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
|
bind := milter.NewBindings(postfix.New(cfg.postfixDir).SenderLoginMapsPath())
|
||||||
signers := milter.NewSigners(domain.NewOpenDKIM(cfg.opendkimDir).SigningTablePath())
|
return milter.ServeSender(ctx, ln, bind, st)
|
||||||
return milter.ServeSender(ctx, ln, bind, signers, st)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// postfixState is what supervisord said about Postfix: since when it runs, if
|
// postfixState is what supervisord said about Postfix: since when it runs, if
|
||||||
|
|||||||
+11
-13
@@ -286,19 +286,17 @@ server itself.
|
|||||||
`src/global/mail_addr_find.c`, `mail_addr_find_opt()`). A key written
|
`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.
|
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
|
- **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
|
one, is refused), read with OpenDKIM's own parser: libopendkim's
|
||||||
address. Then a cruder second look at the raw value: OpenDKIM finds the
|
`dkim_mail_parse()`, ported line for line from Debian's opendkim
|
||||||
address with a parser of its own, and wherever the two could read a line
|
2.11.0~beta2 (`internal/milter/mailparse.go`) and tested against the C on
|
||||||
differently - a display name, a comment, a quoted string - the other
|
two hundred thousand lines. The check asks Postfix for header values with
|
||||||
address would have to be spelled out in it. So no `@name` may appear there
|
their leading space, as OpenDKIM does (`SMFIP_HDR_LEADSPC`), so both read the
|
||||||
that is another name the server signs for; those names are read from
|
same bytes. The address it returns is the one judged - the one OpenDKIM
|
||||||
OpenDKIM's own `SigningTable`; the display name is looked at once more
|
signs for - whatever a display name, a comment or a quoted local part
|
||||||
as decoded, since a mail program shows `=?utf-8?q?director=40example.org?=`
|
holds; a parse failure, no address, or a second one after it is refused,
|
||||||
as an address though no `@` is written in it. And, needing no knowledge of
|
and so is an address over 256 bytes, which OpenDKIM would match its
|
||||||
that parser:
|
`SigningTable` against cut short (`dkimf_apply_signtable()`,
|
||||||
outside quoted strings the line has exactly one `@`. A folded line is
|
`MAXADDRESS`). Encoded words are not decoded, by either.
|
||||||
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,
|
- **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
|
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
|
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
|
closed. This stage replaces the guard with the parser itself. Stage 3 and
|
||||||
those after it move up one patch number each.
|
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
|
(Debian bookworm's opendkim 2.11.0~beta2 source package, not upstream's
|
||||||
develop branch), line for line, with its tokenizer
|
develop branch), line for line, with its tokenizer
|
||||||
(`dkim_mail_first_special`, `dkim_mail_token`). The check then decides by
|
(`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 @
|
here (L8 closed; security.md's accepted risk struck). Whether the "one @
|
||||||
outside quotes" rule is still needed is decided against the port, and
|
outside quotes" rule is still needed is decided against the port, and
|
||||||
said. - **Opus**
|
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
|
- [ ] **The port checked against OpenDKIM itself.** On the stand, several
|
||||||
hundred generated `From:` lines - quotes, escapes, comments nested and
|
hundred generated `From:` lines - quotes, escapes, comments nested and
|
||||||
open, groups, brackets, encoded words, a second domain of the server in
|
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
|
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
|
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,
|
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
|
or no `From:` at all. The line is read the way the signer, OpenDKIM, reads
|
||||||
it - the check and the signer - and must not be able to read it differently:
|
it - the panel runs OpenDKIM's own parser - so only its address counts: what a
|
||||||
outside a quoted display name it may hold one `@`, the address's own, and no
|
display name or a comment around it says is not judged, and a line with more
|
||||||
other name this server signs for may follow an `@` anywhere in it, a quoted
|
than one address is refused. A display name with a comma or brackets has to
|
||||||
name included. A display name with a comma or brackets has to be quoted
|
be quoted (`"Shop, Inc." <orders@…>`), as the mail standard asks and mail
|
||||||
(`"Shop, Inc." <orders@…>`), as the mail standard asks and mail libraries
|
libraries do; unquoted, OpenDKIM may take a word of the name for the
|
||||||
do. The refusal
|
address. The refusal
|
||||||
is permanent, so the application does not send the message again; it is a
|
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
|
row of the [Outbound log](#outbound-log) tagged `wrong From`, and the row's
|
||||||
page shows what the line said.
|
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) |
|
| 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) |
|
| 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) |
|
| 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
|
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
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
|
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
|
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
|
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
|
signer. Since `2.5.2` it reads the header with OpenDKIM's own parser,
|
||||||
looks at the raw value once more for any other name of OpenDKIM's
|
`dkim_mail_parse()` ported line for line from the opendkim the image runs
|
||||||
`SigningTable`, so that a line its parser and OpenDKIM's could read
|
(`internal/milter/mailparse.go`), and judges the address that parser
|
||||||
differently is refused rather than argued about; outside quoted strings
|
returns - the address OpenDKIM signs for - whatever a display name, a
|
||||||
the line may hold one `@`. That the two agree is tested, not only argued:
|
comment or a quoted local part holds; one `From:` field and one address in
|
||||||
the e2e sends every `From:` line the check passes - a list it shares with
|
it stay required, and an address longer than the 256 bytes OpenDKIM
|
||||||
the check's unit test, `internal/milter/testdata/from-lines-passed.txt` -
|
matches its `SigningTable` against is refused, since cut there it could end
|
||||||
and wants the message signed for the application's domain and no other;
|
in another domain's name (opendkim.c, `dkimf_apply_signtable()`,
|
||||||
and every line of a second list, lines that name another sender of the
|
`MAXADDRESS`). That the port is OpenDKIM's parser is tested against the C
|
||||||
server in a quoted string, a comment, a quoted pair, capitals or an encoded
|
itself, two hundred thousand lines and every result alike; that the signed
|
||||||
word (`from-lines-other.txt`), must be refused and never arrive signed for
|
domain follows, by the e2e: it sends every `From:` line the check passes (a
|
||||||
that sender. The reply to a refusal says that the line names another
|
list shared with the unit test,
|
||||||
sender of the server, not which. That a line naming a domain is refused
|
`internal/milter/testdata/from-lines-passed.txt`) and wants it signed for
|
||||||
when the domain is a sender of this server, and passed when it is not,
|
the application's domain and no other, and every
|
||||||
still tells an application which domains are hosted here; the probe needs
|
line of a second list naming another sender of the server in a quoted
|
||||||
an application of this server, and refusing every foreign address in a
|
string, a comment, a quoted pair, capitals or an encoded word
|
||||||
display name would change the 2.4.0 rule - accepted by the owner
|
(`from-lines-other.txt`) must be refused or arrive signed for the
|
||||||
(accepted risks). What this does not promise is what a display
|
application's domain, never for that sender. What this does not promise is
|
||||||
name looks like to a person: a name of another sender written without an
|
what a display name looks like to a person: a name of another sender in
|
||||||
`@` the scan can find (a full-width dot, a space inside the name) passes,
|
it passes, signed with the application's own key - a forged display name is
|
||||||
signed with the application's own key - a forged display name is as old
|
as old as mail, and no key of another's is involved. What the client wrote
|
||||||
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,
|
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.
|
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,
|
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
|
through - but no message data. The inbound journal itself is in the
|
||||||
database, 0640. Trigger to revisit: a shared host, where the sidecar should
|
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.
|
instead be given the files through a group it shares with the panel.
|
||||||
- **An application can learn which domains are senders of this server.**
|
- ~~**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
|
Struck in `2.5.2` (audit 2026-10-12, stage 2c; L8 closed): the sender
|
||||||
server signs for is refused; one naming any other domain passes. The reply
|
check reads a `From:` line with OpenDKIM's own parser, ported
|
||||||
no longer names the domain (audit 2026-10-12, L8), but refused or passed
|
(`internal/milter/mailparse.go`), and decides by the address OpenDKIM signs
|
||||||
answers the question for whoever sends the line. Accepted by the owner,
|
for; what a display name, a comment or a quoted local part names no longer
|
||||||
2026-10-14: it needs an application of this server - a trusted tenant
|
matters, so refused or passed says nothing about other domains of the
|
||||||
guessing names one at a time, as it already had to before L8, when the
|
server.
|
||||||
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.
|
|
||||||
- ~~**A row of the inbound log belongs to a domain by name.**~~ Struck in
|
- ~~**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
|
`2.5.0` (audit 2026-10-12, L12): both logs now hold a domain by id, see
|
||||||
§ Roles.
|
§ Roles.
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package milter
|
||||||
|
|
||||||
|
// The address OpenDKIM signs for, found as OpenDKIM finds it: a port of
|
||||||
|
// libopendkim's dkim_mail_parse() and its tokenizer, from Debian's
|
||||||
|
// opendkim 2.11.0~beta2-8+deb12u1 (libopendkim/dkim-mailparse.c, identical
|
||||||
|
// there to upstream's develop branch), line for line. OpenDKIM calls it on the
|
||||||
|
// value of the message's first From: field (opendkim/opendkim.c, mlfi_eoh(),
|
||||||
|
// dkimf_findheader(dfc, "from", 0): build/opendkim.conf sets no SenderHeaders),
|
||||||
|
// lower-cases the domain it returns, and signs with the key whose SigningTable
|
||||||
|
// pattern matches "user@domain" (dkimf_apply_signtable()). Whatever the port
|
||||||
|
// returns, OpenDKIM does: quoted strings and domain literals are kept with
|
||||||
|
// their delimiters, comments are dropped, a source route resets the address,
|
||||||
|
// an '@' before the first ':', ';' or ',' decides where the address starts,
|
||||||
|
// and encoded words are not decoded.
|
||||||
|
//
|
||||||
|
// The C works in place on a NUL-terminated buffer, writing the result over
|
||||||
|
// the input; here the input is read through at(), which gives 0 at its end as
|
||||||
|
// the C finds the terminator, and the result goes to a buffer of its own, in
|
||||||
|
// which 0 ends a string as '\0' does there.
|
||||||
|
|
||||||
|
// mailparse errors, DKIM_MAILPARSE_ERR_* (dkim-mailparse.c).
|
||||||
|
const (
|
||||||
|
mailparseOK = 0
|
||||||
|
mailparsePUnbalanced = 1 // unbalanced parentheses
|
||||||
|
mailparseQUnbalanced = 2 // unbalanced quotes
|
||||||
|
mailparseSUnbalanced = 3 // unbalanced square brackets
|
||||||
|
)
|
||||||
|
|
||||||
|
// mailparseSpecials is SPECIALS: "<>@,;:\\\"/[]?=".
|
||||||
|
var mailparseSpecials = func() (t [256]bool) {
|
||||||
|
for _, c := range []byte("<>@,;:\\\"/[]?=") {
|
||||||
|
t[c] = true
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}()
|
||||||
|
|
||||||
|
// mailparseSpace is isascii(c) && isspace(c) in the C locale.
|
||||||
|
func mailparseSpace(c byte) bool {
|
||||||
|
return c == ' ' || (c >= '\t' && c <= '\r')
|
||||||
|
}
|
||||||
|
|
||||||
|
type mailparser struct {
|
||||||
|
b []byte // the input up to its first NUL, as strlen() sees it
|
||||||
|
}
|
||||||
|
|
||||||
|
// at is *p: the byte, or the terminator at and beyond the end.
|
||||||
|
func (m *mailparser) at(i int) byte {
|
||||||
|
if i >= len(m.b) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return m.b[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchingParen is dkim_mail_matching_paren(): the position of the close
|
||||||
|
// character that balances the open one before s, or e. An open character of
|
||||||
|
// 0 is never met inside the string, so for quotes and brackets it only skips
|
||||||
|
// to the close, past backslash escapes.
|
||||||
|
func (m *mailparser) matchingParen(s, e int, open, close byte) int {
|
||||||
|
paren := 1
|
||||||
|
for ; s < e; s++ {
|
||||||
|
c := m.b[s]
|
||||||
|
if c == close {
|
||||||
|
paren--
|
||||||
|
if paren == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else if c == open {
|
||||||
|
paren++
|
||||||
|
} else if c == '\\' {
|
||||||
|
if m.at(s+1) != 0 {
|
||||||
|
s++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstSpecial is dkim_mail_first_special(): the '<' that opens an address,
|
||||||
|
// or the ':', ';' or ',' that ends a part - or the '@' seen before it - or
|
||||||
|
// the end.
|
||||||
|
func (m *mailparser) firstSpecial(p, e int) (int, int) {
|
||||||
|
atPtr := -1
|
||||||
|
for ; p < e && m.at(p) != 0; p++ {
|
||||||
|
// skip white space between tokens
|
||||||
|
for p < e && (m.at(p) == '(' || mailparseSpace(m.at(p))) {
|
||||||
|
if m.at(p) != '(' {
|
||||||
|
p++
|
||||||
|
} else {
|
||||||
|
p = m.matchingParen(p+1, e, '(', ')')
|
||||||
|
if m.at(p) == 0 {
|
||||||
|
return p, mailparsePUnbalanced
|
||||||
|
}
|
||||||
|
p++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.at(p) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c := m.at(p)
|
||||||
|
switch {
|
||||||
|
case c == '"':
|
||||||
|
p = m.matchingParen(p+1, e, 0, '"')
|
||||||
|
if m.at(p) == 0 {
|
||||||
|
return p, mailparseQUnbalanced
|
||||||
|
}
|
||||||
|
case c == '[':
|
||||||
|
p = m.matchingParen(p+1, e, 0, ']')
|
||||||
|
if m.at(p) == 0 {
|
||||||
|
return p, mailparseSUnbalanced
|
||||||
|
}
|
||||||
|
case mailparseSpecials[c]:
|
||||||
|
if c == '<' {
|
||||||
|
return p, mailparseOK
|
||||||
|
} else if c == ':' || c == ';' || c == ',' {
|
||||||
|
if atPtr >= 0 {
|
||||||
|
return atPtr, mailparseOK
|
||||||
|
}
|
||||||
|
return p, mailparseOK
|
||||||
|
} else if c == '@' {
|
||||||
|
atPtr = p
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
for m.at(p) != 0 && !mailparseSpecials[m.at(p)] && !mailparseSpace(m.at(p)) && m.at(p) != '(' {
|
||||||
|
p++
|
||||||
|
}
|
||||||
|
p--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, mailparseOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// token is dkim_mail_token(): the next token from s - a quoted string or a
|
||||||
|
// domain literal with its delimiters, a special character alone, or an atom
|
||||||
|
// ('x') - its type (0 at the end), and whether whitespace outside a comment
|
||||||
|
// came before it (ws is set, never cleared, as in the C).
|
||||||
|
func (m *mailparser) token(s, e int, ws *bool) (typ int, start, end int, err int) {
|
||||||
|
p := s
|
||||||
|
// skip white space between tokens
|
||||||
|
for p < e && (m.at(p) == '(' || mailparseSpace(m.at(p))) {
|
||||||
|
if m.at(p) != '(' {
|
||||||
|
*ws = true
|
||||||
|
p++
|
||||||
|
} else {
|
||||||
|
p = m.matchingParen(p+1, e, '(', ')')
|
||||||
|
if m.at(p) == 0 {
|
||||||
|
return 0, 0, 0, mailparsePUnbalanced
|
||||||
|
}
|
||||||
|
p++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p >= e || m.at(p) == 0 {
|
||||||
|
return 0, 0, 0, mailparseOK
|
||||||
|
}
|
||||||
|
start = p
|
||||||
|
c := m.at(p)
|
||||||
|
switch {
|
||||||
|
case c == '"':
|
||||||
|
end = m.matchingParen(p+1, e, 0, '"')
|
||||||
|
typ = '"'
|
||||||
|
if m.at(end) != 0 {
|
||||||
|
end++
|
||||||
|
} else {
|
||||||
|
err = mailparseQUnbalanced
|
||||||
|
}
|
||||||
|
case c == '[':
|
||||||
|
end = m.matchingParen(p+1, e, 0, ']')
|
||||||
|
typ = '['
|
||||||
|
if m.at(end) != 0 {
|
||||||
|
end++
|
||||||
|
} else {
|
||||||
|
err = mailparseSUnbalanced
|
||||||
|
}
|
||||||
|
case mailparseSpecials[c]:
|
||||||
|
end = p + 1
|
||||||
|
typ = int(c)
|
||||||
|
default:
|
||||||
|
for p < e && m.at(p) != 0 && !mailparseSpecials[m.at(p)] && !mailparseSpace(m.at(p)) && m.at(p) != '(' {
|
||||||
|
p++
|
||||||
|
}
|
||||||
|
end = p
|
||||||
|
typ = 'x'
|
||||||
|
}
|
||||||
|
return typ, start, end, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailParsed is what dkim_mail_parse() returns: the local part and the
|
||||||
|
// domain, each absent (NULL) or a string, and the error. rest is not the
|
||||||
|
// C's: where in the line it stopped reading, for the caller's question
|
||||||
|
// whether anything follows the address.
|
||||||
|
type mailParsed struct {
|
||||||
|
user, domain string
|
||||||
|
hasUser, hasDomain bool
|
||||||
|
err int
|
||||||
|
rest int
|
||||||
|
}
|
||||||
|
|
||||||
|
// dkimMailParse is dkim_mail_parse().
|
||||||
|
func dkimMailParse(line string) mailParsed {
|
||||||
|
if i := indexByte(line, 0); i >= 0 {
|
||||||
|
line = line[:i] // strlen()
|
||||||
|
}
|
||||||
|
m := &mailparser{b: []byte(line)}
|
||||||
|
e := len(m.b)
|
||||||
|
var out []byte
|
||||||
|
user, domain := -1, -1
|
||||||
|
result := func(err, rest int) mailParsed {
|
||||||
|
r := mailParsed{err: err, rest: rest}
|
||||||
|
if user >= 0 {
|
||||||
|
r.user, r.hasUser = cstring(out[user:]), true
|
||||||
|
}
|
||||||
|
if domain >= 0 {
|
||||||
|
r.domain, r.hasDomain = cstring(out[domain:]), true
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
ws := false
|
||||||
|
pos := 0
|
||||||
|
for {
|
||||||
|
special, err := m.firstSpecial(pos, e)
|
||||||
|
if err != 0 {
|
||||||
|
return result(err, special)
|
||||||
|
}
|
||||||
|
switch m.at(special) {
|
||||||
|
case '<':
|
||||||
|
// display name <address>
|
||||||
|
pos = special + 1
|
||||||
|
for {
|
||||||
|
typ, ts, te, err := m.token(pos, e, &ws)
|
||||||
|
if err != 0 {
|
||||||
|
return result(err, pos)
|
||||||
|
}
|
||||||
|
if typ == '>' || typ == 0 {
|
||||||
|
out = append(out, 0)
|
||||||
|
if typ == 0 {
|
||||||
|
te = e
|
||||||
|
}
|
||||||
|
return result(mailparseOK, te)
|
||||||
|
} else if typ == '@' {
|
||||||
|
out = append(out, 0)
|
||||||
|
domain = len(out)
|
||||||
|
} else if typ == ',' || typ == ':' {
|
||||||
|
// source route punctuation
|
||||||
|
user, domain = -1, -1
|
||||||
|
} else {
|
||||||
|
if user < 0 {
|
||||||
|
user = len(out)
|
||||||
|
}
|
||||||
|
out = append(out, m.b[ts:te]...)
|
||||||
|
}
|
||||||
|
pos = te
|
||||||
|
}
|
||||||
|
case ';', ':', ',':
|
||||||
|
// skip a group name or result
|
||||||
|
pos = special + 1
|
||||||
|
default:
|
||||||
|
// (display name) addr(display name)ess
|
||||||
|
ws = false
|
||||||
|
for {
|
||||||
|
typ, ts, te, err := m.token(pos, e, &ws)
|
||||||
|
if err != 0 {
|
||||||
|
return result(err, pos)
|
||||||
|
}
|
||||||
|
if typ == 0 || typ == ',' || typ == ';' {
|
||||||
|
out = append(out, 0)
|
||||||
|
if typ == 0 {
|
||||||
|
te = e
|
||||||
|
}
|
||||||
|
return result(mailparseOK, te)
|
||||||
|
} else if typ == '@' {
|
||||||
|
out = append(out, 0)
|
||||||
|
domain = len(out)
|
||||||
|
ws = false
|
||||||
|
} else {
|
||||||
|
if user < 0 {
|
||||||
|
user = len(out)
|
||||||
|
} else if typ == 'x' && ws {
|
||||||
|
out = append(out, ' ')
|
||||||
|
}
|
||||||
|
out = append(out, m.b[ts:te]...)
|
||||||
|
ws = false
|
||||||
|
}
|
||||||
|
pos = te
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// moreAfter says whether anything but list and group punctuation follows
|
||||||
|
// position rest of line: another address, or text after the one found.
|
||||||
|
func moreAfter(line string, rest int) bool {
|
||||||
|
if i := indexByte(line, 0); i >= 0 {
|
||||||
|
line = line[:i]
|
||||||
|
}
|
||||||
|
m := &mailparser{b: []byte(line)}
|
||||||
|
ws := false
|
||||||
|
for pos := rest; ; {
|
||||||
|
typ, _, te, err := m.token(pos, len(m.b), &ws)
|
||||||
|
if err != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch typ {
|
||||||
|
case 0:
|
||||||
|
return false
|
||||||
|
case ',', ';':
|
||||||
|
pos = te
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexByte(s string, c byte) int {
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == c {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// cstring reads a NUL-terminated string from b.
|
||||||
|
func cstring(b []byte) string {
|
||||||
|
if i := indexByte(string(b), 0); i >= 0 {
|
||||||
|
return string(b[:i])
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package milter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mailparseCases are lines whose result is written down here, read off
|
||||||
|
// dkim-mailparse.c by hand and confirmed against it by
|
||||||
|
// TestMailParseMatchesOpenDKIM.
|
||||||
|
var mailparseCases = []struct {
|
||||||
|
line string
|
||||||
|
user, domain string // "-" for absent
|
||||||
|
err int
|
||||||
|
}{
|
||||||
|
{"user@example.com", "user", "example.com", 0},
|
||||||
|
{" Name <user@example.com>", "user", "example.com", 0},
|
||||||
|
{`"Some One" <user@example.com>`, "user", "example.com", 0},
|
||||||
|
{"user@example.com (Some One)", "user", "example.com", 0},
|
||||||
|
{"Some One <user@ex(comment)ample.com>", "user", "example.com", 0},
|
||||||
|
// An '@' before the first ',' decides: the address is read from the start.
|
||||||
|
{"ceo@other.com <me@mine.com>", "me", "mine.com", 0},
|
||||||
|
{"me@mine.com, you@yours.com", "me", "mine.com", 0},
|
||||||
|
// Quoted local parts keep their quotes; an '@' inside them is no '@'.
|
||||||
|
{`"x@other.com"@mine.com`, `"x@other.com"`, "mine.com", 0},
|
||||||
|
{`<"a b"@mine.com>`, `"a b"`, "mine.com", 0},
|
||||||
|
// A source route is dropped.
|
||||||
|
{"<@relay.example,@r2.example:u@mine.com>", "u", "mine.com", 0},
|
||||||
|
// Group syntax: the name is skipped.
|
||||||
|
{"group: u@mine.com;", "u", "mine.com", 0},
|
||||||
|
// No local part: the domain is taken for it, so OpenDKIM has both.
|
||||||
|
{"@mine.com", "mine.com", "mine.com", 0},
|
||||||
|
// Whitespace between atoms of the default form becomes one space.
|
||||||
|
{"a b@mine.com", "a b", "mine.com", 0},
|
||||||
|
// Unbalanced.
|
||||||
|
{"Name (comment <u@mine.com>", "-", "-", mailparsePUnbalanced},
|
||||||
|
{`"open <u@mine.com>`, "-", "-", mailparseQUnbalanced},
|
||||||
|
{"u@[1.2.3.4", "-", "-", mailparseSUnbalanced},
|
||||||
|
// Encoded words are not decoded.
|
||||||
|
{"=?utf-8?q?boss=40other.com?= <u@mine.com>", "u", "mine.com", 0},
|
||||||
|
// No address at all.
|
||||||
|
{"Undisclosed recipients:;", "-", "-", 0},
|
||||||
|
{"", "-", "-", 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsedParts(r mailParsed) (string, string) {
|
||||||
|
u, d := "-", "-"
|
||||||
|
if r.hasUser {
|
||||||
|
u = r.user
|
||||||
|
}
|
||||||
|
if r.hasDomain {
|
||||||
|
d = r.domain
|
||||||
|
}
|
||||||
|
return u, d
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailParseCases(t *testing.T) {
|
||||||
|
for _, c := range mailparseCases {
|
||||||
|
r := dkimMailParse(c.line)
|
||||||
|
u, d := parsedParts(r)
|
||||||
|
if r.err != c.err || (c.err == 0 && (u != c.user || d != c.domain)) {
|
||||||
|
t.Errorf("%q: user %q domain %q err %d; want %q %q %d", c.line, u, d, r.err, c.user, c.domain, c.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoreAfter(t *testing.T) {
|
||||||
|
for line, want := range map[string]bool{
|
||||||
|
"user@example.com": false,
|
||||||
|
"Name <user@example.com>": false,
|
||||||
|
"Name <user@example.com> (note)": false,
|
||||||
|
"group: u@mine.com;": false,
|
||||||
|
"me@mine.com, you@yours.com": true,
|
||||||
|
"Name <a@x>, b@y": true,
|
||||||
|
"Name <a@x> trailing": true,
|
||||||
|
} {
|
||||||
|
r := dkimMailParse(line)
|
||||||
|
if got := moreAfter(line, r.rest); got != want {
|
||||||
|
t.Errorf("%q: moreAfter %v, want %v", line, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailparseCorpus is the cases above and n random lines over an alphabet
|
||||||
|
// weighted toward what the parser treats specially.
|
||||||
|
func mailparseCorpus(n int) []string {
|
||||||
|
lines := []string{}
|
||||||
|
for _, c := range mailparseCases {
|
||||||
|
lines = append(lines, c.line)
|
||||||
|
}
|
||||||
|
alphabet := []string{"<", ">", "@", ",", ";", ":", "\\", `"`, "/", "[", "]", "?", "=", "(", ")",
|
||||||
|
" ", "\t", "\n", "\r", ".", "a", "b", "x", "mine.com", "other.com", "\xc3\xa9", "\xff", "=?utf-8?q?", "?="}
|
||||||
|
rng := rand.New(rand.NewSource(20261015))
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
var b strings.Builder
|
||||||
|
for j := rng.Intn(24); j >= 0; j-- {
|
||||||
|
b.WriteString(alphabet[rng.Intn(len(alphabet))])
|
||||||
|
}
|
||||||
|
lines = append(lines, b.String())
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMailParseMatchesOpenDKIM feeds the corpus to OpenDKIM's own C
|
||||||
|
// dkim_mail_parse() through testdata/mailparse_harness.c, built against the
|
||||||
|
// opendkim source the image runs, and wants the port's answer for every line.
|
||||||
|
// It runs where MAILPARSE_C names the built harness.
|
||||||
|
func TestMailParseMatchesOpenDKIM(t *testing.T) {
|
||||||
|
bin := os.Getenv("MAILPARSE_C")
|
||||||
|
if bin == "" {
|
||||||
|
t.Skip("MAILPARSE_C not set (see testdata/mailparse_harness.c)")
|
||||||
|
}
|
||||||
|
lines := mailparseCorpus(200000)
|
||||||
|
var in strings.Builder
|
||||||
|
for _, l := range lines {
|
||||||
|
in.WriteString(hex.EncodeToString([]byte(l)))
|
||||||
|
in.WriteByte('\n')
|
||||||
|
}
|
||||||
|
cmd := exec.Command(bin)
|
||||||
|
cmd.Stdin = strings.NewReader(in.String())
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("harness: %v", err)
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(strings.NewReader(string(out)))
|
||||||
|
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
||||||
|
enc := func(present bool, s string) string {
|
||||||
|
if !present {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return "=" + hex.EncodeToString([]byte(s))
|
||||||
|
}
|
||||||
|
diffs, failed := 0, 0
|
||||||
|
for i := 0; sc.Scan(); i++ {
|
||||||
|
r := dkimMailParse(lines[i])
|
||||||
|
got := fmt.Sprintf("%d %s %s", r.err, enc(r.hasUser, r.user), enc(r.hasDomain, r.domain))
|
||||||
|
want := sc.Text()
|
||||||
|
if r.err != 0 {
|
||||||
|
// On an error the C returns before it writes the result's end, so
|
||||||
|
// what it prints of user and domain runs on into the unread
|
||||||
|
// input; OpenDKIM uses neither then (mlfi_eoh(): status != 0 is
|
||||||
|
// BADFORMAT, no signature). The error itself must agree.
|
||||||
|
failed++
|
||||||
|
got, want = fmt.Sprint(r.err), strings.Fields(want)[0]
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
diffs++
|
||||||
|
if diffs <= 20 {
|
||||||
|
t.Errorf("line %q: port %s, OpenDKIM %s", lines[i], got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if diffs > 0 {
|
||||||
|
t.Fatalf("%d of %d lines differ", diffs, len(lines))
|
||||||
|
}
|
||||||
|
t.Logf("%d lines, the port and OpenDKIM agree on every one (%d of them errors, compared by code)", len(lines), failed)
|
||||||
|
}
|
||||||
+55
-147
@@ -3,11 +3,8 @@ package milter
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
|
||||||
"net"
|
"net"
|
||||||
"net/mail"
|
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,9 +23,13 @@ import (
|
|||||||
// that line, so without the check an application could have mail signed as
|
// that line, so without the check an application could have mail signed as
|
||||||
// another domain of the server, or as the server itself.
|
// another domain of the server, or as the server itself.
|
||||||
//
|
//
|
||||||
|
// The line is read with OpenDKIM's own parser, ported (mailparse.go), so the
|
||||||
|
// address judged is the one OpenDKIM signs for.
|
||||||
|
//
|
||||||
// Unlike the journal milter this one is attached strictly
|
// Unlike the journal milter this one is attached strictly
|
||||||
// (default_action=tempfail in build/postfix-config.sh): while it does not
|
// (default_action=tempfail in build/postfix-config.sh): while it does not
|
||||||
// answer, Postfix defers mail. It therefore depends on as little as it can.
|
// answer, Postfix answers the client with a temporary error. It therefore
|
||||||
|
// depends on as little as it can.
|
||||||
// It reads no database: what an application may send as comes from the file
|
// It reads no database: what an application may send as comes from the file
|
||||||
// of sender bindings Postfix itself reads for the envelope, so the two go by
|
// of sender bindings Postfix itself reads for the envelope, so the two go by
|
||||||
// one table, read the way Postfix reads it and looked up the way Postfix looks
|
// one table, read the way Postfix reads it and looked up the way Postfix looks
|
||||||
@@ -258,51 +259,6 @@ func (b *Bindings) DomainOf(login string) string {
|
|||||||
return b.domainOf[strings.ToLower(login)]
|
return b.domainOf[strings.ToLower(login)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signers is OpenDKIM's SigningTable as the check reads it: the names this
|
|
||||||
// server signs for - every sending domain, with applications or without, and
|
|
||||||
// the server's own name.
|
|
||||||
type Signers struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
file tableFile
|
|
||||||
names map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSigners reads the file at path on first use.
|
|
||||||
func NewSigners(path string) *Signers {
|
|
||||||
return &Signers{file: tableFile{path: path}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signs reports whether mail whose From: is at domain gets a signature.
|
|
||||||
//
|
|
||||||
// OpenDKIM 2.11.0~beta2, opendkim/opendkim-db.c, dkimf_db_open(), case
|
|
||||||
// DKIMF_DB_TYPE_REFILE: the pattern is a line up to its first blank, "#"
|
|
||||||
// starts a comment; opendkim/util.c, dkimf_mkregexp(): "^" + the pattern
|
|
||||||
// ("*" as ".*", "." escaped) + "$", compiled REG_ICASE (DKIMF_DB_FLAG_ICASE,
|
|
||||||
// opendkim.c). So "*@d" signs user@d in any case, and no subdomain of d.
|
|
||||||
func (s *Signers) Signs(domain string) (bool, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
lines, again, err := s.file.changed()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if again {
|
|
||||||
names := make(map[string]bool)
|
|
||||||
for _, line := range lines {
|
|
||||||
// "*@<name> <key name>" (internal/domain renderTables).
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if d := domainOf(fields[0]); d != "" {
|
|
||||||
names[d] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.names = names
|
|
||||||
}
|
|
||||||
return s.names[strings.ToLower(domain)], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refusals is where the check leaves the send-log rows of what it refused.
|
// Refusals is where the check leaves the send-log rows of what it refused.
|
||||||
// *store.Store satisfies it.
|
// *store.Store satisfies it.
|
||||||
type Refusals interface {
|
type Refusals interface {
|
||||||
@@ -313,7 +269,6 @@ type Refusals interface {
|
|||||||
type senderSession struct {
|
type senderSession struct {
|
||||||
milter.NoOpMilter
|
milter.NoOpMilter
|
||||||
bind *Bindings
|
bind *Bindings
|
||||||
signers *Signers
|
|
||||||
refused chan<- store.SendLogEntry
|
refused chan<- store.SendLogEntry
|
||||||
|
|
||||||
login string
|
login string
|
||||||
@@ -411,58 +366,58 @@ func (s *senderSession) judge() (shown, why string, err error) {
|
|||||||
default:
|
default:
|
||||||
return printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil
|
return printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil
|
||||||
}
|
}
|
||||||
// Postfix hands a milter a header as it came, folded lines and all; the
|
// The value is the one OpenDKIM gets - Postfix hands both milters the
|
||||||
// address parser reads one line.
|
// field as it came, folded lines and all, and with the leading space each
|
||||||
raw := unfold(s.froms[0])
|
// asked for (OptHeaderLeadingSpace, ServeSender) - and it is read with
|
||||||
|
// OpenDKIM's own parser (mailparse.go). So the address judged here is the
|
||||||
|
// address OpenDKIM signs for, whatever the display name, a comment or a
|
||||||
|
// quoted string around it holds; the domain of the key it signs with is
|
||||||
|
// that address's (assumptions B-PM12; audit 2026-10-12, stage 2c).
|
||||||
|
raw := s.froms[0]
|
||||||
shown = printable(raw)
|
shown = printable(raw)
|
||||||
list, perr := fromParser.ParseList(raw)
|
r := dkimMailParse(raw)
|
||||||
if perr != nil || len(list) != 1 || list[0].Address == "" {
|
if r.err != mailparseOK || !r.hasUser || !r.hasDomain || r.user == "" || r.domain == "" {
|
||||||
|
// OpenDKIM does not sign then either (mlfi_eoh(): BADFORMAT).
|
||||||
|
return shown, "it holds no address OpenDKIM can read", nil
|
||||||
|
}
|
||||||
|
if moreAfter(raw, r.rest) {
|
||||||
|
// OpenDKIM takes the first; a reader may show the others.
|
||||||
return shown, "it must be one address", nil
|
return shown, "it must be one address", nil
|
||||||
}
|
}
|
||||||
addr := strings.ToLower(list[0].Address)
|
addr := r.user + "@" + asciiLower(r.domain)
|
||||||
|
// OpenDKIM matches its SigningTable against "user@domain" as
|
||||||
|
// snprintf() leaves it in a buffer of MAXADDRESS + 1 = 257 bytes
|
||||||
|
// (opendkim.c, dkimf_apply_signtable(); libopendkim/dkim-internal.h).
|
||||||
|
// Cut there, a quoted local part could end in another tenant's
|
||||||
|
// "@domain" and pick that tenant's key; an address that fits is read
|
||||||
|
// whole, and its domain (DKIM_MAXHOSTNAMELEN, 256) with it.
|
||||||
|
if len(addr) > 256 {
|
||||||
|
return printable(addr), "the address is longer than OpenDKIM reads whole", nil
|
||||||
|
}
|
||||||
shown = printable(addr)
|
shown = printable(addr)
|
||||||
ok, err := s.bind.Allowed(s.login, addr)
|
ok, err := s.bind.Allowed(s.login, asciiLower(addr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return shown, "", fmt.Errorf("sender bindings: %w", err)
|
return shown, "", fmt.Errorf("sender bindings: %w", err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return shown, "not an address this application sends as", nil
|
return shown, "not an address this application sends as", nil
|
||||||
}
|
}
|
||||||
// A cruder second look. OpenDKIM finds the address in this line with a
|
|
||||||
// parser of its own, and the key it signs with follows what that parser
|
|
||||||
// finds. Wherever the two could read the line differently - a display
|
|
||||||
// name, a comment, a quoted string - the other address would have to be
|
|
||||||
// spelled out in it, so no other name this server signs for may appear.
|
|
||||||
//
|
|
||||||
// The display name is looked at once more as a reader will see it: a mail
|
|
||||||
// program decodes an encoded word, and "=?utf-8?q?director=40example.org?="
|
|
||||||
// shows as an address of another sender though no "@" is written in it.
|
|
||||||
own := domainOf(addr)
|
|
||||||
for _, d := range append(mentionedDomains(raw), mentionedDomains(list[0].Name)...) {
|
|
||||||
if d == own {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
signs, err := s.signers.Signs(d)
|
|
||||||
if err != nil {
|
|
||||||
return shown, "", fmt.Errorf("signing table: %w", err)
|
|
||||||
}
|
|
||||||
if signs {
|
|
||||||
// Which one is not said: the reply would confirm to whoever
|
|
||||||
// tries names that another tenant sends from this server (audit
|
|
||||||
// 2026-10-12, L8). The line as written is in the reply and on
|
|
||||||
// the refused message's page.
|
|
||||||
return printable(raw), "it names another sender of this server", nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// And one that needs to know nothing of OpenDKIM's parser: outside quoted
|
|
||||||
// strings the line has the one "@" of its address. A second one - in a
|
|
||||||
// comment, in an unquoted name - is where two parsers could part ways.
|
|
||||||
if strings.Count(unquoted(raw), "@") != 1 {
|
|
||||||
return printable(raw), "outside quotes it must have one @, the address's own", nil
|
|
||||||
}
|
|
||||||
return shown, "", nil
|
return shown, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// asciiLower lower-cases A-Z only, as OpenDKIM's dkimf_lowercase() and
|
||||||
|
// Postfix's table lookups do; strings.ToLower would also fold characters
|
||||||
|
// outside ASCII - the Kelvin sign to "k" - into names they are not.
|
||||||
|
func asciiLower(v string) string {
|
||||||
|
b := []byte(v)
|
||||||
|
for i, c := range b {
|
||||||
|
if c >= 'A' && c <= 'Z' {
|
||||||
|
b[i] = c + 'a' - 'A'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
// record leaves a row for the Outbound log. It never waits: a database that
|
// record leaves a row for the Outbound log. It never waits: a database that
|
||||||
// is slow must not make the check slow. One row for the message, not one for
|
// is slow must not make the check slow. One row for the message, not one for
|
||||||
// each recipient as an accepted message has: nothing was sent to anybody, and
|
// each recipient as an accepted message has: nothing was sent to anybody, and
|
||||||
@@ -496,37 +451,6 @@ func (s *senderSession) record(shown, queueID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fromParser reads the From: line. A display name encoded in a charset Go
|
|
||||||
// does not convert (windows-1251, koi8-r, ...) is still a display name: its
|
|
||||||
// bytes are passed through unread, since only the address is judged.
|
|
||||||
var fromParser = mail.AddressParser{WordDecoder: &mime.WordDecoder{
|
|
||||||
CharsetReader: func(charset string, input io.Reader) (io.Reader, error) { return input, nil },
|
|
||||||
}}
|
|
||||||
|
|
||||||
// unfold joins the lines of a folded header value (RFC 5322 2.2.3): a line
|
|
||||||
// break is dropped, the white space that follows it stays.
|
|
||||||
func unfold(v string) string {
|
|
||||||
return strings.NewReplacer("\r\n", "", "\n", "", "\r", "").Replace(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// unquoted returns v without its quoted strings, a backslash inside one
|
|
||||||
// escaping the next character.
|
|
||||||
func unquoted(v string) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
in := false
|
|
||||||
for i := 0; i < len(v); i++ {
|
|
||||||
switch c := v[i]; {
|
|
||||||
case in && c == '\\':
|
|
||||||
i++
|
|
||||||
case c == '"':
|
|
||||||
in = !in
|
|
||||||
case !in:
|
|
||||||
sb.WriteByte(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// domain is the sending domain of the refused message for its row in the
|
// domain is the sending domain of the refused message for its row in the
|
||||||
// log: the envelope sender's, which Postfix has held against the login - or,
|
// log: the envelope sender's, which Postfix has held against the login - or,
|
||||||
// for the null sender of a bounce an application writes itself, which Postfix
|
// for the null sender of a bounce an application writes itself, which Postfix
|
||||||
@@ -543,29 +467,6 @@ func (s *senderSession) domain() string {
|
|||||||
return s.bind.DomainOf(s.login)
|
return s.bind.DomainOf(s.login)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mentionedDomains returns, lower-cased, what follows every "@" of v as far
|
|
||||||
// as it reads as a host name.
|
|
||||||
func mentionedDomains(v string) []string {
|
|
||||||
var out []string
|
|
||||||
for i := 0; i < len(v); i++ {
|
|
||||||
if v[i] != '@' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
j := i + 1
|
|
||||||
for j < len(v) && isHostByte(v[j]) {
|
|
||||||
j++
|
|
||||||
}
|
|
||||||
if d := strings.Trim(strings.ToLower(v[i+1:j]), ".-"); d != "" {
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHostByte(c byte) bool {
|
|
||||||
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
// printable reduces what a client wrote to printable ASCII without the
|
// printable reduces what a client wrote to printable ASCII without the
|
||||||
// characters that mean something in an SMTP reply or a log line, and cuts it:
|
// characters that mean something in an SMTP reply or a log line, and cuts it:
|
||||||
// it is said back to the client, logged and stored.
|
// it is said back to the client, logged and stored.
|
||||||
@@ -600,7 +501,7 @@ func printable(v string) string {
|
|||||||
// ServeSender runs the sender check on ln until ctx is cancelled. rec may be
|
// ServeSender runs the sender check on ln until ctx is cancelled. rec may be
|
||||||
// nil: refusals are then not logged. What it relies on of go-milter and
|
// nil: refusals are then not logged. What it relies on of go-milter and
|
||||||
// Postfix is cited at Serve in milter.go.
|
// Postfix is cited at Serve in milter.go.
|
||||||
func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, signers *Signers, rec Refusals) error {
|
func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, rec Refusals) error {
|
||||||
var refused chan store.SendLogEntry
|
var refused chan store.SendLogEntry
|
||||||
if rec != nil {
|
if rec != nil {
|
||||||
refused = make(chan store.SendLogEntry, 256)
|
refused = make(chan store.SendLogEntry, 256)
|
||||||
@@ -619,10 +520,17 @@ func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, signers *
|
|||||||
}
|
}
|
||||||
srv := &milter.Server{
|
srv := &milter.Server{
|
||||||
NewMilter: func() milter.Milter {
|
NewMilter: func() milter.Milter {
|
||||||
return &senderSession{bind: bind, signers: signers, refused: refused}
|
return &senderSession{bind: bind, refused: refused}
|
||||||
},
|
},
|
||||||
Actions: 0, // read-only
|
Actions: 0, // read-only
|
||||||
Protocol: milter.OptNoBody, // the headers decide
|
// The headers decide; and each header value with its leading space,
|
||||||
|
// as OpenDKIM asks for it (opendkim.c, mlfi_negotiate(),
|
||||||
|
// SMFIP_HDR_LEADSPC), so that both read the same bytes. Postfix
|
||||||
|
// 3.7.11, src/milter/milter8.c: milter8_negotiate() keeps the event
|
||||||
|
// mask a milter of any version asks for (go-milter v0.4.1 says
|
||||||
|
// version 2), and milter8_header() drops the space only for a milter
|
||||||
|
// without SMFIP_HDR_LEADSPC.
|
||||||
|
Protocol: milter.OptNoBody | milter.OptHeaderLeadingSpace,
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -24,22 +24,6 @@ invoices@example.org billing
|
|||||||
billing@example.org billing
|
billing@example.org billing
|
||||||
`
|
`
|
||||||
|
|
||||||
// signingTable is a SigningTable for the same server, composed by hand: the
|
|
||||||
// three sending domains, one more that has no application yet, and the
|
|
||||||
// server's own name. Its key-name column is not what internal/domain
|
|
||||||
// renderTables writes (the domain name); the check reads only the pattern.
|
|
||||||
const signingTable = `*@news.example.net selfpost._domainkey.news.example.net
|
|
||||||
*@shop.example.org selfpost._domainkey.shop.example.org
|
|
||||||
*@example.org selfpost._domainkey.example.org
|
|
||||||
*@empty.example.com selfpost._domainkey.empty.example.com
|
|
||||||
*@mail.example.org selfpost._domainkey.mail.example.org
|
|
||||||
`
|
|
||||||
|
|
||||||
func signers(t *testing.T) *Signers {
|
|
||||||
t.Helper()
|
|
||||||
return NewSigners(writeMaps(t, signingTable))
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeMaps(t *testing.T, content string) string {
|
func writeMaps(t *testing.T, content string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
p := filepath.Join(t.TempDir(), "table")
|
p := filepath.Join(t.TempDir(), "table")
|
||||||
@@ -54,7 +38,7 @@ func writeMaps(t *testing.T, content string) string {
|
|||||||
func offer(t *testing.T, bind *Bindings, login, envelope string, headers ...[2]string) (milter.Response, []store.SendLogEntry) {
|
func offer(t *testing.T, bind *Bindings, login, envelope string, headers ...[2]string) (milter.Response, []store.SendLogEntry) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
refused := make(chan store.SendLogEntry, 8)
|
refused := make(chan store.SendLogEntry, 8)
|
||||||
s := &senderSession{bind: bind, signers: signers(t), refused: refused}
|
s := &senderSession{bind: bind, refused: refused}
|
||||||
if _, err := s.MailFrom(envelope, mods(map[string]string{"{auth_authen}": login})); err != nil {
|
if _, err := s.MailFrom(envelope, mods(map[string]string{"{auth_authen}": login})); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -109,6 +93,18 @@ func TestSenderCheck(t *testing.T) {
|
|||||||
{"a name in windows-1251", "shop", [][2]string{from("=?windows-1251?B?zODj4Ofo7Q==?= <a@shop.example.org>")}, true, ""},
|
{"a name in windows-1251", "shop", [][2]string{from("=?windows-1251?B?zODj4Ofo7Q==?= <a@shop.example.org>")}, true, ""},
|
||||||
{"a name in koi8-r, quoted-printable", "shop", [][2]string{from("=?koi8-r?Q?=ED=C1=C7=C1=DA=C9=CE?= <a@shop.example.org>")}, true, ""},
|
{"a name in koi8-r, quoted-printable", "shop", [][2]string{from("=?koi8-r?Q?=ED=C1=C7=C1=DA=C9=CE?= <a@shop.example.org>")}, true, ""},
|
||||||
{"a comment after the address", "shop", [][2]string{from("a@shop.example.org (the shop)")}, true, ""},
|
{"a comment after the address", "shop", [][2]string{from("a@shop.example.org (the shop)")}, true, ""},
|
||||||
|
{"the leading space Postfix keeps for OpenDKIM", "shop", [][2]string{from(" a@shop.example.org")}, true, ""},
|
||||||
|
// Whatever the name, a comment or a quoted local part holds: OpenDKIM
|
||||||
|
// signs for the address it reads, and that is the one judged.
|
||||||
|
{"another sender in the display name", "shop", [][2]string{from(`"director@example.org" <a@shop.example.org>`)}, true, ""},
|
||||||
|
{"the server's name in a comment", "shop", [][2]string{from("a@shop.example.org (postmaster@mail.example.org)")}, true, ""},
|
||||||
|
{"a domain with a key and no application, in the display name", "shop", [][2]string{from(`"x@empty.example.com" <a@shop.example.org>`)}, true, ""},
|
||||||
|
{"a stranger's address in the display name", "shop", [][2]string{from(`"someone@gmail.example" <a@shop.example.org>`)}, true, ""},
|
||||||
|
{"another sender in an encoded display name", "shop", [][2]string{from("=?utf-8?q?director=40example=2Eorg?= <a@shop.example.org>")}, true, ""},
|
||||||
|
{"a stranger's address in a comment", "shop", [][2]string{from("a@shop.example.org (someone@gmail.example)")}, true, ""},
|
||||||
|
{"an unquoted address as the display name", "shop", [][2]string{from("director@example.org <a@shop.example.org>")}, true, ""},
|
||||||
|
{"another domain inside a quoted local part", "shop", [][2]string{from(`"director@example.org"@shop.example.org`)}, true, ""},
|
||||||
|
{"no local part: OpenDKIM takes the domain for it", "shop", [][2]string{from("@shop.example.org")}, true, ""},
|
||||||
|
|
||||||
{"another registered domain", "shop", [][2]string{from("director@example.org")}, false, "not an address this application sends as"},
|
{"another registered domain", "shop", [][2]string{from("director@example.org")}, false, "not an address this application sends as"},
|
||||||
{"the server's own name", "shop", [][2]string{from("postmaster@mail.example.org")}, false, "not an address this application sends as"},
|
{"the server's own name", "shop", [][2]string{from("postmaster@mail.example.org")}, false, "not an address this application sends as"},
|
||||||
@@ -120,16 +116,14 @@ func TestSenderCheck(t *testing.T) {
|
|||||||
{"no From: line", "shop", [][2]string{{"Subject", "hello"}}, false, "no From: line"},
|
{"no From: line", "shop", [][2]string{{"Subject", "hello"}}, false, "no From: line"},
|
||||||
{"two From: lines", "shop", [][2]string{from("a@shop.example.org"), from("director@example.org")}, false, "more than one From: line"},
|
{"two From: lines", "shop", [][2]string{from("a@shop.example.org"), from("director@example.org")}, false, "more than one From: line"},
|
||||||
{"two addresses in one line", "shop", [][2]string{from("a@shop.example.org, director@example.org")}, false, "one address"},
|
{"two addresses in one line", "shop", [][2]string{from("a@shop.example.org, director@example.org")}, false, "one address"},
|
||||||
{"not an address", "shop", [][2]string{from("the shop")}, false, "one address"},
|
{"not an address", "shop", [][2]string{from("the shop")}, false, "no address OpenDKIM can read"},
|
||||||
|
{"an address before a list comma, then another", "shop", [][2]string{from("director@example.org, <a@shop.example.org>")}, false, "one address"},
|
||||||
|
// Past 256 bytes OpenDKIM matches its SigningTable against a cut
|
||||||
|
// "user@domain" - which could end in another tenant's domain.
|
||||||
|
{"an address longer than OpenDKIM reads", "shop", [][2]string{from(`"` + strings.Repeat("a", 240) + `@example.org"@shop.example.org`)}, false, "longer than OpenDKIM reads whole"},
|
||||||
{"a group", "shop", [][2]string{from("friends: a@shop.example.org, b@shop.example.org;")}, false, "one address"},
|
{"a group", "shop", [][2]string{from("friends: a@shop.example.org, b@shop.example.org;")}, false, "one address"},
|
||||||
{"another sender in the display name", "shop", [][2]string{from(`"director@example.org" <a@shop.example.org>`)}, false, "names another sender of this server"},
|
|
||||||
{"the server's name in a comment", "shop", [][2]string{from("a@shop.example.org (postmaster@mail.example.org)")}, false, "names another sender of this server"},
|
|
||||||
{"a domain with a key and no application, in the display name", "shop", [][2]string{from(`"x@empty.example.com" <a@shop.example.org>`)}, false, "names another sender of this server"},
|
|
||||||
{"a stranger's address in the display name", "shop", [][2]string{from(`"someone@gmail.example" <a@shop.example.org>`)}, true, ""},
|
|
||||||
{"another sender in an encoded display name", "shop", [][2]string{from("=?utf-8?q?director=40example=2Eorg?= <a@shop.example.org>")}, false, "names another sender of this server"},
|
|
||||||
{"a stranger's address in a comment", "shop", [][2]string{from("a@shop.example.org (someone@gmail.example)")}, false, "one @"},
|
|
||||||
{"another sender folded onto the next line", "shop", [][2]string{from("\"x\"\r\n <director@example.org>")}, false, "not an address this application sends as"},
|
{"another sender folded onto the next line", "shop", [][2]string{from("\"x\"\r\n <director@example.org>")}, false, "not an address this application sends as"},
|
||||||
{"a quote left open before another address", "shop", [][2]string{from(`"a@shop.example.org <director@example.org>`)}, false, "one address"},
|
{"a quote left open before another address", "shop", [][2]string{from(`"a@shop.example.org <director@example.org>`)}, false, "no address OpenDKIM can read"},
|
||||||
{"no login", "", [][2]string{from("a@shop.example.org")}, false, "no authenticated application"},
|
{"no login", "", [][2]string{from("a@shop.example.org")}, false, "no authenticated application"},
|
||||||
{"an unknown login", "ghost", [][2]string{from("a@shop.example.org")}, false, "not an address this application sends as"},
|
{"an unknown login", "ghost", [][2]string{from("a@shop.example.org")}, false, "not an address this application sends as"},
|
||||||
}
|
}
|
||||||
@@ -290,30 +284,10 @@ func TestBindingsDuplicateKeyBreaksTheTable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without the signing table the second look cannot be taken, and a line it
|
|
||||||
// would have to judge is deferred; a line that names nobody else is not held
|
|
||||||
// up by it.
|
|
||||||
func TestSenderCheckDefersWithoutSigningTable(t *testing.T) {
|
|
||||||
bind := NewBindings(writeMaps(t, senderMaps))
|
|
||||||
play := func(v string) milter.Response {
|
|
||||||
s := &senderSession{bind: bind, signers: NewSigners(filepath.Join(t.TempDir(), "absent"))}
|
|
||||||
_, _ = s.MailFrom("shop@shop.example.org", mods(map[string]string{"{auth_authen}": "shop"}))
|
|
||||||
_, _ = s.Header("From", v, mods(nil))
|
|
||||||
resp, _ := s.Headers(nil, mods(nil))
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
if resp := play(`"x@example.org" <a@shop.example.org>`); resp != milter.RespTempFail {
|
|
||||||
t.Fatalf("reply %s, want a temporary failure", replyText(resp))
|
|
||||||
}
|
|
||||||
if resp := play(`a@shop.example.org`); resp != milter.RespContinue {
|
|
||||||
t.Fatalf("a plain own address: %s", replyText(resp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A second message on the connection is judged by its own headers.
|
// A second message on the connection is judged by its own headers.
|
||||||
func TestSenderCheckResetsBetweenMessages(t *testing.T) {
|
func TestSenderCheckResetsBetweenMessages(t *testing.T) {
|
||||||
bind := NewBindings(writeMaps(t, senderMaps))
|
bind := NewBindings(writeMaps(t, senderMaps))
|
||||||
s := &senderSession{bind: bind, signers: signers(t)}
|
s := &senderSession{bind: bind}
|
||||||
auth := mods(map[string]string{"{auth_authen}": "shop"})
|
auth := mods(map[string]string{"{auth_authen}": "shop"})
|
||||||
for i, v := range []string{"director@example.org", "a@shop.example.org", "director@example.org"} {
|
for i, v := range []string{"director@example.org", "a@shop.example.org", "director@example.org"} {
|
||||||
_, _ = s.MailFrom("shop@shop.example.org", auth)
|
_, _ = s.MailFrom("shop@shop.example.org", auth)
|
||||||
@@ -329,7 +303,7 @@ func TestSenderCheckResetsBetweenMessages(t *testing.T) {
|
|||||||
func TestSenderCheckRefusalIsOneRow(t *testing.T) {
|
func TestSenderCheckRefusalIsOneRow(t *testing.T) {
|
||||||
bind := NewBindings(writeMaps(t, senderMaps))
|
bind := NewBindings(writeMaps(t, senderMaps))
|
||||||
refused := make(chan store.SendLogEntry, 8)
|
refused := make(chan store.SendLogEntry, 8)
|
||||||
s := &senderSession{bind: bind, signers: signers(t), refused: refused}
|
s := &senderSession{bind: bind, refused: refused}
|
||||||
_, _ = s.MailFrom("shop@shop.example.org", mods(map[string]string{"{auth_authen}": "shop"}))
|
_, _ = s.MailFrom("shop@shop.example.org", mods(map[string]string{"{auth_authen}": "shop"}))
|
||||||
for _, r := range []string{"a@x.example", "b@x.example", "c@x.example"} {
|
for _, r := range []string{"a@x.example", "b@x.example", "c@x.example"} {
|
||||||
_, _ = s.RcptTo(r, mods(nil))
|
_, _ = s.RcptTo(r, mods(nil))
|
||||||
@@ -392,14 +366,18 @@ func fromLines(t *testing.T, file, domain, other string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every line of the shared file of lines naming another sender is refused:
|
// No line of the shared file of lines naming another sender is passed to be
|
||||||
// none may be passed to be signed with that sender's key.
|
// signed with that sender's key: a line the check passes is one OpenDKIM
|
||||||
func TestSenderCheckRefusesTheSharedLinesNamingAnother(t *testing.T) {
|
// reads the application's own address in (the e2e looks at the signature).
|
||||||
|
func TestSenderCheckNeverPassesTheSharedLinesForAnother(t *testing.T) {
|
||||||
bind := NewBindings(writeMaps(t, senderMaps))
|
bind := NewBindings(writeMaps(t, senderMaps))
|
||||||
for _, line := range fromLines(t, "from-lines-other.txt", "shop.example.org", "news.example.net") {
|
for _, line := range fromLines(t, "from-lines-other.txt", "shop.example.org", "news.example.net") {
|
||||||
resp, _ := offer(t, bind, "shop", "shop@shop.example.org", [2]string{"From", line})
|
resp, _ := offer(t, bind, "shop", "shop@shop.example.org", [2]string{"From", line})
|
||||||
if resp == milter.RespContinue {
|
if resp != milter.RespContinue {
|
||||||
t.Errorf("From: %q passed", line)
|
continue
|
||||||
|
}
|
||||||
|
if r := dkimMailParse(line); asciiLower(r.domain) != "shop.example.org" {
|
||||||
|
t.Errorf("From: %q passed, and OpenDKIM reads the domain %q in it", line, r.domain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -1,10 +1,12 @@
|
|||||||
# From: lines that name another sender of this server, {other}, beside or in
|
# From: lines that name another sender of this server, {other}, beside or in
|
||||||
# place of the application's own address at {domain} (review of audit stage
|
# place of the application's own address at {domain} (review of audit stage
|
||||||
# 2). The check must refuse each: a line it read as the application's own and
|
# 2). None may be signed with {other}'s key. Since stage 2c the check reads a
|
||||||
# OpenDKIM as {other}'s would be signed with {other}'s key. The unit test
|
# line with OpenDKIM's own parser, so it passes those whose address OpenDKIM
|
||||||
# (internal/milter/sender_test.go) wants each refused; the e2e
|
# reads at {domain} - {other} in a display name, a comment or a quoted local
|
||||||
# (test/e2e/dkim_from_test.go) sends each and wants nothing signed for
|
# part - and refuses the rest. The unit test
|
||||||
# {other}. {OTHER} is {other} in capitals; {other_qp} is {other} with each
|
# (internal/milter/sender_test.go) wants every line it passes read at
|
||||||
|
# {domain}; the e2e (test/e2e/dkim_from_test.go) sends each and wants
|
||||||
|
# nothing signed for {other}. {OTHER} is {other} in capitals; {other_qp} is {other} with each
|
||||||
# dot escaped as a quoted pair. One Go-quoted string per line. The lines are
|
# dot escaped as a quoted pair. One Go-quoted string per line. The lines are
|
||||||
# composed by hand; that OpenDKIM signs none for {other} is measured, by the
|
# composed by hand; that OpenDKIM signs none for {other} is measured, by the
|
||||||
# e2e on every run.
|
# e2e on every run.
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Differential harness for internal/milter/mailparse.go: OpenDKIM's own
|
||||||
|
* dkim_mail_parse() (libopendkim/dkim-mailparse.c of the opendkim source the
|
||||||
|
* image runs, compiled in by #include), fed one hex-encoded line per input
|
||||||
|
* line. Output per line: the error, then the local part and the domain, each
|
||||||
|
* "-" when NULL and "=" followed by its bytes in hex otherwise.
|
||||||
|
*
|
||||||
|
* cc -o mailparse_harness -I <opendkim>/libopendkim mailparse_harness.c
|
||||||
|
* MAILPARSE_C=./mailparse_harness go test -run TestMailParseMatchesOpenDKIM ./internal/milter
|
||||||
|
*/
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "dkim-mailparse.c"
|
||||||
|
|
||||||
|
static int hexval(int c)
|
||||||
|
{
|
||||||
|
if (c >= '0' && c <= '9') return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void put(const unsigned char *s)
|
||||||
|
{
|
||||||
|
if (s == NULL) {
|
||||||
|
fputs(" -", stdout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fputs(" =", stdout);
|
||||||
|
for (; *s != '\0'; s++)
|
||||||
|
printf("%02x", *s);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
static char hex[1 << 20];
|
||||||
|
static unsigned char buf[1 << 19];
|
||||||
|
|
||||||
|
while (fgets(hex, sizeof hex, stdin) != NULL) {
|
||||||
|
size_t n = strcspn(hex, "\r\n"), i;
|
||||||
|
unsigned char *user, *domain;
|
||||||
|
int err;
|
||||||
|
|
||||||
|
for (i = 0; i + 1 < n; i += 2)
|
||||||
|
buf[i / 2] = (unsigned char) (hexval(hex[i]) << 4 | hexval(hex[i + 1]));
|
||||||
|
buf[n / 2] = '\0';
|
||||||
|
err = dkim_mail_parse(buf, &user, &domain);
|
||||||
|
printf("%d", err);
|
||||||
|
put(user);
|
||||||
|
put(domain);
|
||||||
|
putchar('\n');
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -169,7 +169,7 @@ var outboundTopics = []HelpTopic{
|
|||||||
Rich("An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The ",
|
Rich("An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The ",
|
||||||
Code("From:"), " line, the one a mail program shows and the one the DKIM signature is chosen by, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other ",
|
Code("From:"), " line, the one a mail program shows and the one the DKIM signature is chosen by, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other ",
|
||||||
Code("From:"), " - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with ",
|
Code("From:"), " - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with ",
|
||||||
Code("550 5.7.1"), ". It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from getting mail signed with another domain's key.",
|
Code("550 5.7.1"), ". The line is read the way the signer, OpenDKIM, reads it, so only its address counts: what a display name or a comment around it says is not judged, and a line holding more than one address is refused. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from getting mail signed with another domain's key.",
|
||||||
Br(), Br(),
|
Br(), Br(),
|
||||||
"The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own."),
|
"The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own."),
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -89,9 +89,11 @@ func testSignedForItsDomainOnly(t *testing.T, sc *scenario) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testNeverSignedForAnother: the lines that name another sender of the server
|
// testNeverSignedForAnother: no line that names another sender of the server
|
||||||
// are refused by the check; whatever happens to one, nothing arrives signed
|
// arrives signed with that sender's key (review of audit stage 2). Since stage
|
||||||
// with that sender's key (review of audit stage 2).
|
// 2c the check reads a line as OpenDKIM does: one whose address is the
|
||||||
|
// application's passes, wherever the other sender's name is written, and must
|
||||||
|
// arrive signed for the application's domain; the rest are refused.
|
||||||
func testNeverSignedForAnother(t *testing.T, sc *scenario) {
|
func testNeverSignedForAnother(t *testing.T, sc *scenario) {
|
||||||
lines, err := fromLines(h.repoRoot, "from-lines-other.txt", senderDomain, otherDomain)
|
lines, err := fromLines(h.repoRoot, "from-lines-other.txt", senderDomain, otherDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,16 +109,25 @@ func testNeverSignedForAnother(t *testing.T, sc *scenario) {
|
|||||||
if err := res.refusedAt("DATA", 550, "5.7.1 From:"); err == nil {
|
if err := res.refusedAt("DATA", 550, "5.7.1 From:"); err == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
t.Errorf("From: %q was not refused by the check: %v", line, res.firstErr())
|
if !res.ok() {
|
||||||
|
t.Errorf("From: %q: neither passed nor refused by the check: %v", line, res.firstErr())
|
||||||
|
continue
|
||||||
|
}
|
||||||
raw, err := findSinkMessage(h.stageDir, token, 15*time.Second)
|
raw, err := findSinkMessage(h.stageDir, token, 15*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
t.Errorf("From: %q passed and never arrived: %v", line, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
verifications, _ := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{LookupTXT: lookupTXTFunc()})
|
verifications, _ := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{LookupTXT: lookupTXTFunc()})
|
||||||
|
own := false
|
||||||
for _, v := range verifications {
|
for _, v := range verifications {
|
||||||
if v.Domain == otherDomain {
|
if v.Domain == otherDomain {
|
||||||
t.Errorf("From: %q arrived signed for %s", line, otherDomain)
|
t.Errorf("From: %q arrived signed for %s", line, otherDomain)
|
||||||
}
|
}
|
||||||
|
own = own || (v.Domain == senderDomain && v.Err == nil)
|
||||||
|
}
|
||||||
|
if !own {
|
||||||
|
t.Errorf("From: %q passed and did not arrive signed for %s: %+v", line, senderDomain, verifications)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,15 @@ func testSenderCheck(t *testing.T, sc *scenario) {
|
|||||||
// app-list, so the wildcard application may not have it, in the
|
// app-list, so the wildcard application may not have it, in the
|
||||||
// header as in the envelope (assumptions B-PS1; the envelope below).
|
// header as in the envelope (assumptions B-PS1; the envelope below).
|
||||||
{"the address listed for another application", wildcard, "billing@" + senderDomain, false},
|
{"the address listed for another application", wildcard, "billing@" + senderDomain, false},
|
||||||
{"another sender in the display name", wildcard, `"director@` + otherDomain + `" <alerts@` + senderDomain + `>`, false},
|
// Since 2.5.2 the address OpenDKIM reads decides (OpenDKIM's parser,
|
||||||
|
// internal/milter/mailparse.go): another sender's name in the display
|
||||||
|
// name passes, and is signed for the application's own domain
|
||||||
|
// (passed_from_lines_signed_for_their_domain_only looks at the key).
|
||||||
|
{"another sender in the display name", wildcard, `"director@` + otherDomain + `" <alerts@` + senderDomain + `>`, true},
|
||||||
|
// Past 256 bytes OpenDKIM matches its SigningTable against the
|
||||||
|
// address cut short, which here would end in the other domain's
|
||||||
|
// name (opendkim.c, dkimf_apply_signtable(), MAXADDRESS).
|
||||||
|
{"an address longer than OpenDKIM reads", wildcard, `"` + strings.Repeat("a", 256-len("@"+otherDomain)-1) + `@` + otherDomain + `x"@` + senderDomain, false},
|
||||||
} {
|
} {
|
||||||
token := uniqueToken("from")
|
token := uniqueToken("from")
|
||||||
res := attemptSend(sendAttempt{
|
res := attemptSend(sendAttempt{
|
||||||
|
|||||||
Reference in New Issue
Block a user