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