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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user