test / test (push) Waiting to run
The first rollout of faa3c11 to the test server crash-looped: without
opendkim the image's package accounts got other numbers (postfix 101 ->
100, postdrop 103 -> 102), the queue under /data kept the old ones, and
postsuper, which "postfix check" runs as the mail owner
(src/postsuper/postsuper.c:1442), could not open it. Rolled back within
five minutes. The entrypoint now hands an existing queue to Postfix's users
by name before postfix-config.sh, and again after it (own_queue).
Technical review (F1-F10):
- F1 high: a backup of an upgraded installation carried /data/opendkim, a
restore left it root's, and the panel refused every start. The tree is
left out of archives, and the entrypoint makes it the panel's alone
(0700/0600; also the security review's F3 - nothing of 2.8 needs the
group any more; a 2.7.x start re-normalises it).
- F2: List-Unsubscribe-Post is signed (RFC 8058); Sender too (security F1).
- F3: SetServerName keeps the name in lower case - a key written under
"Mail.Example.com" was never found. Only the DKIM side: the SASL realm
is compared as written.
- F4: the signer rewrites the selector table at every start
(WriteSelectors, replacing EnsureServerSigned), and tries again every
10 s while a rewrite fails.
- F5: e2e signature_over_awkward_headers (folded, tab-led, no space, two
To:, 8-bit body, List-Unsubscribe-Post) checked by dkimpy;
TestStartsOnA27Data starts on a 2.7 /data (old key tree, a queue of
foreign numbers) and checks the copied key signs, the old tree's modes
and the queue's owners; unit tests for repeated headers and the header
limit.
- F6: the table's writers are serialised (tableMu).
- F7: the table is read for every message; keys the table no longer names
are dropped from memory.
- F8: the signature's value brings its own space (SMFIP_HDR_LEADSPC gets
none from Postfix, cleanup_milter.c:1085).
- F9: a body line over 998 bytes is logged with its domain: Postfix's SMTP
client breaks it after signing (smtp_proto.c:1266-1274).
- F10: the keys are copied while /data/dkim holds none (not only when it
is absent), after the old tree is made the panel's (an install from 2.6
had another uid); the plan's rollback note says what a rollback keeps.
Security review: F2 a header section over 1 MiB is refused (552 5.3.4)
and the kept headers freed once the block is built - fifty connections of
header-only messages could take the panel down; F5 the shown headers a
message has (Subject, Date, To, Cc, Reply-To, Sender) are over-signed,
only when present, since cleanup adds a missing Date: after the milters.
F1 (holding Sender: to the login like From:) changes what is refused and
waits for the owner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
91 lines
3.2 KiB
Go
91 lines
3.2 KiB
Go
package e2e
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testSignatureOverAwkwardHeaders: the panel's signature verifies, by dkimpy -
|
|
// an implementation that is not the signer's library - over the header shapes
|
|
// Postfix hands a milter in ways of its own: a folded value (lines joined by
|
|
// "\n"), a value led by a tab, one with no space after the colon, a header
|
|
// twice, and an 8-bit body (Postfix 3.10.13, src/milter/milter8.c:2329-2400,
|
|
// milter8_header(); build/postfix-config.sh, disable_mime_output_conversion).
|
|
// The unit tests verify with go-msgauth, the signer's own library, over a
|
|
// message they rebuild themselves; this is what Postfix delivers (review of
|
|
// panel-dkim-signing, F5).
|
|
func testSignatureOverAwkwardHeaders(t *testing.T, sc *scenario) {
|
|
token := uniqueToken("shapes")
|
|
raw := strings.Join([]string{
|
|
"From: =?utf-8?b?0J7RgtC00LXQuyDRgdCx0YvRgtCw?= <alerts@" + senderDomain + ">",
|
|
"To: first@sink.e2e.test",
|
|
"To: second@sink.e2e.test",
|
|
"Cc:third@sink.e2e.test",
|
|
"Subject: e2e " + token + " with a subject long enough",
|
|
"\tto be folded onto a second line",
|
|
"X-Tab:\tled by a tab",
|
|
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
|
|
"MIME-Version: 1.0",
|
|
"Content-Type: text/plain; charset=utf-8",
|
|
"Content-Transfer-Encoding: 8bit",
|
|
"List-Unsubscribe: <https://" + senderDomain + "/u/1>",
|
|
"List-Unsubscribe-Post: List-Unsubscribe=One-Click",
|
|
"",
|
|
"Привет - an 8-bit body, sent as it is.",
|
|
"A line with trailing white space ",
|
|
"",
|
|
"",
|
|
}, "\r\n")
|
|
res := attemptSend(sendAttempt{
|
|
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
|
from: "alerts@" + senderDomain, to: recipient, raw: raw,
|
|
})
|
|
if !res.ok() {
|
|
t.Fatalf("send: %v", res.firstErr())
|
|
}
|
|
file, delivered, err := findSinkFile(h.stageDir, token, 30*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("the message never reached the sink: %v", err)
|
|
}
|
|
if err := checkPanelSignature(h, file, delivered, senderDomain); err != nil {
|
|
t.Fatalf("%v\n%s", err, delivered)
|
|
}
|
|
for _, want := range []string{"Привет", "List-Unsubscribe-Post"} {
|
|
if !strings.Contains(string(delivered), want) {
|
|
t.Errorf("the delivered message lacks %q:\n%s", want, delivered)
|
|
}
|
|
}
|
|
// Both To: lines, the one Cc:, the Subject and List-Unsubscribe-Post are
|
|
// signed, the shown ones once more.
|
|
signedNames := strings.ToLower(dkimTagsOf(t, delivered)["h"])
|
|
for _, want := range []string{"to:to:", "cc:", "subject:", "list-unsubscribe-post:"} {
|
|
if !strings.Contains(signedNames, want) {
|
|
t.Errorf("h=%s lacks %q", signedNames, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// dkimTagsOf is the tags of the message's one DKIM-Signature.
|
|
func dkimTagsOf(t *testing.T, raw []byte) map[string]string {
|
|
t.Helper()
|
|
text := strings.ReplaceAll(string(raw), "\r\n", "\n")
|
|
head, _, _ := strings.Cut(text, "\n\n")
|
|
var value string
|
|
in := false
|
|
for _, line := range strings.Split(head, "\n") {
|
|
switch {
|
|
case strings.HasPrefix(strings.ToLower(line), "dkim-signature:"):
|
|
in, value = true, line[len("dkim-signature:"):]
|
|
case in && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")):
|
|
value += line
|
|
default:
|
|
in = false
|
|
}
|
|
}
|
|
if value == "" {
|
|
t.Fatalf("no DKIM-Signature in:\n%s", raw)
|
|
}
|
|
return dkimTags(value)
|
|
}
|