Files
selfpost/test/e2e/dkim_shapes_test.go
T
mixandClaude Opus 5.5 5c5f49bad1 signer: MIME headers and Message-ID signed; a long header line logged (audit M6, M8)
Security audit 2026-10-17, M6 and M8 (nits), stage 6.

M6: Content-Type, MIME-Version and Message-ID were not in h= (as in
OpenDKIM's default list): a relay could change a MIME boundary or the type
and the signature still verified. They are signed when present; Message-ID
is not over-signed, since cleanup adds one a message lacks after the
milters and an absent name in h= would refuse it.

M8: a header line over smtp_line_length_limit (998) is broken by Postfix's
smtp client after signing and the signature fails silently; only body
lines were logged. longHeaderLine names the header, once per message, in
the same words.

Tests: TestSignLinesCoverTheMIMEHeadersAndLongHeaderLinesAreNamed; the h=
expectations gain Message-ID; e2e dkim_shapes expects content-type: in h=
(and the receiver's verification over it). Docs: security.md,
architecture.md, guide; CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:37:14 +03:00

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:", "content-type:"} {
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)
}