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>
This commit is contained in:
mix
2026-10-17 22:37:14 +03:00
co-authored by Claude Opus 5.5
parent 820b1bb968
commit 5c5f49bad1
7 changed files with 72 additions and 13 deletions
+3
View File
@@ -46,6 +46,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
on the image's binaries before a release is pushed; a weekly check says on the image's binaries before a release is pushed; a weekly check says
when a Debian package the mail path runs on has moved, so that a patch when a Debian package the mail path runs on has moved, so that a patch
release follows. release follows.
- **The signature covers `Content-Type`, `MIME-Version` and `Message-ID`**
when the message has them (audit M6), and a header line too long to be
delivered unbroken is named in the log as a body line is (M8).
- **The TLS private key no longer has to be readable by Postfix** (audit - **The TLS private key no longer has to be readable by Postfix** (audit
I1): the guide gave it to group 10998 for the panel's backup, and I1): the guide gave it to group 10998 for the panel's backup, and
`postfix` is in that group, so every Postfix daemon - an smtpd talking to `postfix` is in that group, so every Postfix daemon - an smtpd talking to
+4 -2
View File
@@ -294,8 +294,10 @@ then OpenDKIM did, in a program of its own
- **What is signed** — `relaxed/simple`, `rsa-sha256`, with - **What is signed** — `relaxed/simple`, `rsa-sha256`, with
`github.com/emersion/go-msgauth` v0.7.0 (`dkim`). `h=`: every header of `github.com/emersion/go-msgauth` v0.7.0 (`dkim`). `h=`: every header of
the message whose name is in RFC 6376 § 5.4.1's list as OpenDKIM's the message whose name is in RFC 6376 § 5.4.1's list as OpenDKIM's
`dkim_should_signhdrs[]` held it, and `Sender` and `List-Unsubscribe-Post` `dkim_should_signhdrs[]` held it, and `Sender`, `List-Unsubscribe-Post`,
besides (`signHeaders`), each occurrence, in message order; then, once `Content-Type`, `MIME-Version` and `Message-ID` besides (`signHeaders`;
`Message-ID` is not over-signed: cleanup adds one a message lacks after the
milters), each occurrence, in message order; then, once
more, each of `Subject`, `Date`, `To`, `Cc`, `Reply-To` and `Sender` the more, each of `Subject`, `Date`, `To`, `Cc`, `Reply-To` and `Sender` the
message has (`overSign` - only those present: Postfix's cleanup adds a message has (`overSign` - only those present: Postfix's cleanup adds a
missing `Date:` after the milters, out of their sight, and a name in `h=` missing `Date:` after the milters, out of their sight, and a name in `h=`
+4 -3
View File
@@ -1529,12 +1529,13 @@ DMARC counts that signature only when its domain aligns with the domain of
signature as another of its domains, or as the server itself. signature as another of its domains, or as the server itself.
**Lines of more than 998 bytes.** The signer signs the message as the **Lines of more than 998 bytes.** The signer signs the message as the
application handed it over. Postfix's SMTP client breaks a body line longer application handed it over. Postfix's SMTP client breaks a line longer
than 998 bytes when it delivers the message (`smtp_line_length_limit`), than 998 bytes, in the body or in a header, when it delivers the message (`smtp_line_length_limit`),
after the signature was made, and the receiver's check of the signature after the signature was made, and the receiver's check of the signature
then fails. Such a message is still sent; the container log names its then fails. Such a message is still sent; the container log names its
domain and login (`dkim-signer: example.com (login "shop"): a body line domain and login (`dkim-signer: example.com (login "shop"): a body line
longer than 998 bytes - ...`). Mail libraries keep lines short with longer than 998 bytes - ...`, or `a line of the Subject header is longer
than 998 bytes - ...`). Mail libraries keep lines short with
quoted-printable or base64; an application that writes its own body has to quoted-printable or base64; an application that writes its own body has to
as well. A message whose header section is over 1 MiB is refused, as well. A message whose header section is over 1 MiB is refused,
`552 5.3.4`. `552 5.3.4`.
+4 -2
View File
@@ -324,8 +324,10 @@ operator can act on it.
server's key may sign it. What cannot be signed (a login with no domain, a server's key may sign it. What cannot be signed (a login with no domain, a
name with no key, a read error) is deferred, never sent unsigned. Port 25 name with no key, a read error) is deferred, never sent unsigned. Port 25
has no signer. `relaxed/simple`, rsa-sha256; `h=` names every header of has no signer. `relaxed/simple`, rsa-sha256; `h=` names every header of
RFC 6376 § 5.4.1's list the message has, and `Sender` and RFC 6376 § 5.4.1's list the message has, and `Sender`,
`List-Unsubscribe-Post`, then once more each of the shown ones present `List-Unsubscribe-Post`, `Content-Type`, `MIME-Version` and `Message-ID`
(the last three since `2.14.4`, audit M6: unsigned, a relay could change a
MIME boundary and the signature still verified), then once more each of the shown ones present
(Subject, Date, To, Cc, Reply-To, Sender) and `From` last, so one of those (Subject, Date, To, Cc, Reply-To, Sender) and `From` last, so one of those
added after signing breaks the signature. Until `2.8.0` OpenDKIM signed - a added after signing breaks the signature. Until `2.8.0` OpenDKIM signed - a
2018 beta with three 2026 CVEs open and nobody upstream to fix them 2018 beta with three 2026 CVEs open and nobody upstream to fix them
+33 -3
View File
@@ -30,9 +30,14 @@ import (
// signHeaders is the set of header names signed when present: RFC 6376 // signHeaders is the set of header names signed when present: RFC 6376
// § 5.4.1's list, as OpenDKIM's dkim_should_signhdrs[] held it (OpenDKIM // § 5.4.1's list, as OpenDKIM's dkim_should_signhdrs[] held it (OpenDKIM
// 2.11.0~beta2, libopendkim/dkim.c:223-246), which SelfPost signed with until // 2.11.0~beta2, libopendkim/dkim.c:223-246), which SelfPost signed with until
// 2.8.0; and two that list leaves out: Sender, which a mail program shows // 2.8.0; and those that list leaves out: Sender, which a mail program shows
// beside From ("on behalf of"), and List-Unsubscribe-Post, which RFC 8058 // beside From ("on behalf of"), List-Unsubscribe-Post, which RFC 8058 § 4
// § 4 wants signed for a one-click unsubscribe to be honoured. Every // wants signed for a one-click unsubscribe to be honoured, and since 2.14.4
// Content-Type, MIME-Version and Message-ID (security audit 2026-10-17, M6):
// unsigned, a relay could change a MIME boundary or the type and the
// signature still verified. Message-ID is not over-signed: cleanup adds one a
// message lacks after the milters, and a name in h= that is absent would
// refuse it. Every
// occurrence is signed, in message order, and the shown ones once more // occurrence is signed, in message order, and the shown ones once more
// (signLines). // (signLines).
var signHeaders = map[string]bool{ var signHeaders = map[string]bool{
@@ -41,6 +46,7 @@ var signHeaders = map[string]bool{
"in-reply-to": true, "references": true, "in-reply-to": true, "references": true,
"list-id": true, "list-help": true, "list-unsubscribe": true, "list-unsubscribe-post": true, "list-id": true, "list-help": true, "list-unsubscribe": true, "list-unsubscribe-post": true,
"list-subscribe": true, "list-post": true, "list-owner": true, "list-archive": true, "list-subscribe": true, "list-post": true, "list-owner": true, "list-archive": true,
"content-type": true, "mime-version": true, "message-id": true,
} }
// overSign are the signed headers a mail program shows: each that the message // overSign are the signed headers a mail program shows: each that the message
@@ -292,6 +298,9 @@ func (s *signSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milte
log.Printf("dkim-signer: %v - deferring", err) log.Printf("dkim-signer: %v - deferring", err)
return milter.RespTempFail, nil return milter.RespTempFail, nil
} }
if name := longHeaderLine(s.headers); name != "" {
log.Printf("dkim-signer: %s (login %q): a line of the %s header is longer than %d bytes - Postfix breaks it on delivery and the signature will not verify", s.domain, s.login, name, smtpLineLimit)
}
keys, block := signLines(s.headers) keys, block := signLines(s.headers)
s.headers = nil // the block is what is kept now s.headers = nil // the block is what is kept now
signer, err := dkim.NewSigner(&dkim.SignOptions{ signer, err := dkim.NewSigner(&dkim.SignOptions{
@@ -362,6 +371,27 @@ func signLines(headers []field) ([]string, []byte) {
return append(keys, "From"), []byte(b.String()) return append(keys, "From"), []byte(b.String())
} }
// longHeaderLine names the first header with a line longer than
// smtpLineLimit, "" when none has: Postfix's SMTP client breaks such a line
// on the way out, after it was signed whole, as it breaks a body line
// (security audit 2026-10-17, M8: only body lines were logged). A header's
// first line is "Name:" and the value with its leading space; its folded
// lines come as "\n"-separated parts of the value (Header).
func longHeaderLine(headers []field) string {
for _, h := range headers {
lines := strings.Split(h.value, "\n")
if len(h.name)+1+len(lines[0]) > smtpLineLimit {
return h.name
}
for _, l := range lines[1:] {
if len(strings.TrimRight(l, "\r")) > smtpLineLimit {
return h.name
}
}
}
return ""
}
// BodyChunk hashes the body as it comes. Postfix 3.10.13, // BodyChunk hashes the body as it comes. Postfix 3.10.13,
// src/milter/milter8.c:2470-2500, milter8_body(): every line ends "\r\n". // src/milter/milter8.c:2470-2500, milter8_body(): every line ends "\r\n".
// A line longer than smtpLineLimit is logged, once a message: its signature // A line longer than smtpLineLimit is logged, once a message: its signature
+23 -2
View File
@@ -193,7 +193,7 @@ func TestSignerSignsWithTheLoginsDomain(t *testing.T) {
if !strings.HasPrefix(sig.HeaderValue, " ") { if !strings.HasPrefix(sig.HeaderValue, " ") {
t.Errorf("the signature's value does not bring its space: %q", sig.HeaderValue) t.Errorf("the signature's value does not bring its space: %q", sig.HeaderValue)
} }
for _, want := range []string{"c=relaxed/simple;", "s=sel;", "h=From:To:Subject:Date:To:Subject:Date:From;"} { for _, want := range []string{"c=relaxed/simple;", "s=sel;", "h=From:To:Subject:Date:Message-ID:To:Subject:Date:From;"} {
if !strings.Contains(strings.Join(strings.Fields(sig.HeaderValue), ""), want) { if !strings.Contains(strings.Join(strings.Fields(sig.HeaderValue), ""), want) {
t.Errorf("signature lacks %q: %s", want, sig.HeaderValue) t.Errorf("signature lacks %q: %s", want, sig.HeaderValue)
} }
@@ -354,7 +354,7 @@ func TestSignerRefusesAHugeHeaderSection(t *testing.T) {
// signLines writes the block Postfix will deliver and names what is signed. // signLines writes the block Postfix will deliver and names what is signed.
func TestSignLines(t *testing.T) { func TestSignLines(t *testing.T) {
keys, block := signLines(testHeaders) keys, block := signLines(testHeaders)
if got := strings.Join(keys, ":"); got != "From:To:Subject:Date:To:Subject:Date:From" { if got := strings.Join(keys, ":"); got != "From:To:Subject:Date:Message-ID:To:Subject:Date:From" {
t.Errorf("keys %s", got) t.Errorf("keys %s", got)
} }
// Every occurrence is signed; a shown one is named once more, once. // Every occurrence is signed; a shown one is named once more, once.
@@ -374,3 +374,24 @@ func TestSignLines(t *testing.T) {
t.Errorf("block\n%q\nwant\n%q", block, want) t.Errorf("block\n%q\nwant\n%q", block, want)
} }
} }
// Content-Type, MIME-Version and Message-ID are signed when present and not
// over-signed (security audit 2026-10-17, M6); a header line past the SMTP
// client's limit is named in the log (M8).
func TestSignLinesCoverTheMIMEHeadersAndLongHeaderLinesAreNamed(t *testing.T) {
keys, _ := signLines([]field{
{"From", " a@example.org"}, {"MIME-Version", " 1.0"}, {"Content-Type", " text/plain"}, {"Message-ID", " <x@example.org>"},
})
if got := strings.Join(keys, ":"); got != "From:MIME-Version:Content-Type:Message-ID:From" {
t.Errorf("h= %s", got)
}
if got := longHeaderLine([]field{{"Subject", " " + strings.Repeat("x", 998)}}); got != "Subject" {
t.Errorf("a 1007-byte Subject line: %q", got)
}
if got := longHeaderLine([]field{{"To", " a@example.org,\n " + strings.Repeat("b", 999)}}); got != "To" {
t.Errorf("a long folded line: %q", got)
}
if got := longHeaderLine([]field{{"Subject", " short"}, {"To", " a,\n b"}}); got != "" {
t.Errorf("short lines named %q", got)
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ func testSignatureOverAwkwardHeaders(t *testing.T, sc *scenario) {
// Both To: lines, the one Cc:, the Subject and List-Unsubscribe-Post are // Both To: lines, the one Cc:, the Subject and List-Unsubscribe-Post are
// signed, the shown ones once more. // signed, the shown ones once more.
signedNames := strings.ToLower(dkimTagsOf(t, delivered)["h"]) signedNames := strings.ToLower(dkimTagsOf(t, delivered)["h"])
for _, want := range []string{"to:to:", "cc:", "subject:", "list-unsubscribe-post:"} { for _, want := range []string{"to:to:", "cc:", "subject:", "list-unsubscribe-post:", "content-type:"} {
if !strings.Contains(signedNames, want) { if !strings.Contains(signedNames, want) {
t.Errorf("h=%s lacks %q", signedNames, want) t.Errorf("h=%s lacks %q", signedNames, want)
} }