Commit 3d82ea0efe

3d82ea0efed35a4dc9f60ce398a6c63bf794bdfd

parent: a13f124bf6

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 15:30 UTC

mailin: strict header names; bind the reply address and dedupe to the DKIM signature

A message whose header has a field name outside RFC 5322 ftext
("From : x") is refused before any check, as is one without exactly one
From or with more than one To, Cc, Message-ID, Content-Type or
Content-Transfer-Encoding, counted on the raw header. Under
require_dkim the reply address must be in To or Cc, and that field,
Content-Type, and Message-ID when present must be in the passing
signature's h=; a Content-Transfer-Encoding outside h= must be 7bit,
8bit or binary. Each passing signature's b= is claimed with the
Message-ID, so a copy posts once. The From domain is split at the last
"@"; selector keys are cached for 15 minutes.

Ref #307

Layout: unified · split

internal/mailin/dkim.go +66 −28
@@ -3,9 +3,10 @@ package mailin
33import (
44 "bytes"
55 "context"
6 "crypto/sha256"
7 "encoding/hex"
68 "errors"
79 "net"
8 "net/mail"
910 "strings"
1011 "sync"
1112 "time"
@@ -20,8 +21,11 @@ const (
2021 // dnsTimeout bounds one selector key lookup.
2122 dnsTimeout = 5 * time.Second
2223 // futureSkew is how far ahead of this clock a signature's t= may be.
23 futureSkew = 15 * time.Minute
24 keyCacheTTL = time.Hour
24 futureSkew = 15 * time.Minute
25 // keyCacheTTL is how long a key record is reused. The resolver API
26 // does not report the record's TTL, so this is short: a revoked key
27 // is still honoured for up to this long.
28 keyCacheTTL = 15 * time.Minute
2529 keyCacheSize = 256
2630)
2731
@@ -101,32 +105,46 @@ func (p *Processor) lookupKey(name string) ([]string, error) {
101105}
102106
103107// dkimVerified checks the DKIM signatures on raw, the message as it
104// was fetched. It returns "" when one of the first maxSignatures
105// verifies, covers From in h=, has a d= in relaxed alignment with the
106// From domain, has not expired and is not dated in the future. retry is
107// true when no signature passed and one could not be checked because
108// its key lookup failed for a reason that may pass.
109func (p *Processor) dkimVerified(raw []byte, h mail.Header, from string) (reason string, retry bool) {
110 _, fromDomain, ok := strings.Cut(strings.ToLower(from), "@")
111 if !ok || fromDomain == "" {
112 return "no From domain", false
108// was fetched. A signature passes when it is one of the first
109// maxSignatures, verifies, has a d= in relaxed alignment with the From
110// domain, has not expired, is not dated in the future, and its h=
111// covers From, tokenField (the To or Cc the reply address was read
112// from), Content-Type, and Message-ID when the message has one. An
113// unsigned Content-Transfer-Encoding is accepted only when it is an
114// identity encoding (7bit, 8bit, binary), which does not change what
115// the body decodes to; mail clients commonly leave it out of h=. It returns an id for each passing
116// signature (a hash of its b=), or the refusal's reason. retry is true
117// when none passed and one could not be checked because its key lookup
118// failed for a reason that may pass.
119func (p *Processor) dkimVerified(raw []byte, rh rawHeader, from, tokenField string) (ids []string, reason string, retry bool) {
120 fromDomain := ""
121 if i := strings.LastIndex(from, "@"); i >= 0 {
122 fromDomain = strings.ToLower(from[i+1:])
113123 }
114 // A second From field could be one the signature does not cover
115 // while it is the one read as the sender.
116 if len(h["From"]) != 1 {
117 return "more than one From field", false
124 if fromDomain == "" {
125 return nil, "no From domain", false
126 }
127 need := []string{"from", tokenField, "content-type"}
128 if rh.count["message-id"] > 0 {
129 need = append(need, "message-id")
130 }
131 cteOK := true
132 switch rh.cte {
133 case "7bit", "8bit", "binary":
134 default:
135 cteOK = rh.count["content-transfer-encoding"] == 0
118136 }
119137 verifs, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{
120138 LookupTXT: p.lookupKey, MaxVerifications: maxSignatures})
121139 if err != nil && !errors.Is(err, dkim.ErrTooManySignatures) {
122 return "DKIM: unreadable message", false
140 return nil, "DKIM: unreadable message", false
123141 }
124142 if len(verifs) == 0 {
125 return "no DKIM-Signature", false
143 return nil, "no DKIM-Signature", false
126144 }
127145 now := p.now()
128146 var fails []string
129 for _, v := range verifs {
147 for i, v := range verifs {
130148 d := strings.ToLower(v.Domain)
131149 why := ""
132150 switch {
@@ -135,8 +153,6 @@ func (p *Processor) dkimVerified(raw []byte, h mail.Header, from string) (reason
135153 why = "key lookup failed"
136154 case v.Err != nil:
137155 why = strings.TrimPrefix(v.Err.Error(), "dkim: ")
138 case !signsFrom(v.HeaderKeys):
139 why = "From field not signed"
140156 case !v.Expiration.IsZero() && now.After(v.Expiration):
141157 why = "signature has expired"
142158 case !v.Time.IsZero() && v.Time.After(now.Add(futureSkew)):
@@ -144,21 +160,43 @@ func (p *Processor) dkimVerified(raw []byte, h mail.Header, from string) (reason
144160 case !aligned(d, fromDomain):
145161 why = "d= not aligned with the From domain"
146162 default:
147 return "", false
163 if n := unsigned(v.HeaderKeys, need); n != "" {
164 why = n + " not in h="
165 } else if !cteOK && unsigned(v.HeaderKeys, []string{"content-transfer-encoding"}) != "" {
166 why = "content-transfer-encoding not in h= and not 7bit, 8bit or binary"
167 } else if i < len(rh.dkimB) && rh.dkimB[i] != "" {
168 sum := sha256.Sum256([]byte(rh.dkimB[i]))
169 ids = append(ids, "dkim:"+hex.EncodeToString(sum[:]))
170 continue
171 } else {
172 why = "no b= tag"
173 }
148174 }
149175 if len(d) > 100 {
150176 d = d[:100]
151177 }
152178 fails = append(fails, "d="+d+": "+why)
153179 }
154 return "DKIM: no passing signature aligned with the From domain (" + strings.Join(fails, "; ") + ")", retry
180 if len(ids) > 0 {
181 return ids, "", false
182 }
183 return nil, "DKIM: no passing signature aligned with the From domain (" + strings.Join(fails, "; ") + ")", retry
155184}
156185
157func signsFrom(keys []string) bool {
158 for _, k := range keys {
159 if strings.EqualFold(k, "from") {
160 return true
186// unsigned returns the first of need that keys (a signature's h=) does
187// not list, or "".
188func unsigned(keys, need []string) string {
189 for _, n := range need {
190 found := false
191 for _, k := range keys {
192 if strings.EqualFold(k, n) {
193 found = true
194 break
195 }
196 }
197 if !found {
198 return n
161199 }
162200 }
163 return false
201 return ""
164202}
internal/mailin/dkim_test.go +197
@@ -17,6 +17,8 @@ import (
1717 "time"
1818
1919 "github.com/emersion/go-msgauth/dkim"
20
21 "gitbay.org/gitbay/internal/mailreply"
2022)
2123
2224// Fixed test keys: RSA 2048 and Ed25519.
@@ -325,3 +327,198 @@ func TestLookupKeyErrors(t *testing.T) {
325327 }
326328 }
327329}
330
331// "From : x" is a field net/mail files under "From " and the dkim
332// package under "From". mallory, at the same provider domain as bob,
333// signs her own From, rewrites it as "From : ..." (relaxed
334// canonicalization still verifies it) and adds "From: bob" above:
335// net/mail reads bob as the sender, the signature covers mallory.
336func TestDKIMFromWithSpaceBeforeColon(t *testing.T) {
337 f, _ := dkimSetup(t)
338 m := signMsg(t, f.messageAs(t, f.bob, "mallory@example.test", "<poc@x>", "", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
339 m = strings.Replace(m, "From: Someone <mallory@example.test>", "From: Someone <bob@example.test>\r\nFrom : Someone <mallory@example.test>", 1)
340 res := f.p.Handle([]byte(m))
341 if res.Posted || !strings.Contains(res.Reason, "malformed header field name") {
342 t.Fatalf("result %+v", res)
343 }
344 if !strings.Contains(f.refusalReasons(t), "malformed header field name") {
345 t.Fatal("refusal not audited")
346 }
347}
348
349func replyAddr(t *testing.T, f *fixture) string {
350 return mailreply.Address(replyBase, f.token(t, f.bob))
351}
352
353func TestDKIMTokenBinding(t *testing.T) {
354 var relaxed dkim.Canonicalization = dkim.CanonicalizationRelaxed
355 for _, c := range []struct {
356 name string
357 build func(t *testing.T, f *fixture) string
358 reason string
359 }{
360 {"reply address in Delivered-To only", func(t *testing.T, f *fixture) string {
361 m := f.messageAs(t, f.bob, "bob@example.test", "<b1@x>", "Delivered-To: "+replyAddr(t, f)+"\r\n", "hi")
362 m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
363 return signMsg(t, m, "example.test", "rsa", rsaKey, relaxed, nil)
364 }, "reply address not in To or Cc"},
365 {"To not in h=", func(t *testing.T, f *fixture) string {
366 return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b2@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed,
367 func(o *dkim.SignOptions) { o.HeaderKeys = []string{"from", "subject", "message-id", "content-type"} })
368 }, "to not in h="},
369 {"replayed with the reply address added in an unsigned Cc", func(t *testing.T, f *fixture) string {
370 m := f.messageAs(t, f.bob, "bob@example.test", "<b3@x>", "", "hi")
371 m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
372 m = signMsg(t, m, "example.test", "rsa", rsaKey, relaxed, nil)
373 return "Cc: " + replyAddr(t, f) + "\r\n" + m
374 }, "cc not in h="},
375 {"replayed with the To replaced", func(t *testing.T, f *fixture) string {
376 m := f.messageAs(t, f.bob, "bob@example.test", "<b4@x>", "", "hi")
377 m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
378 m = signMsg(t, m, "example.test", "rsa", rsaKey, relaxed, nil)
379 return strings.Replace(m, "To: friend@example.test", "To: "+replyAddr(t, f), 1)
380 }, "signature did not verify"},
381 {"second To field", func(t *testing.T, f *fixture) string {
382 m := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b5@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed, nil)
383 return "To: other@example.test\r\n" + m
384 }, "more than one to field"},
385 {"Message-ID not in h=", func(t *testing.T, f *fixture) string {
386 return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b6@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed,
387 func(o *dkim.SignOptions) { o.HeaderKeys = []string{"from", "to", "subject", "content-type"} })
388 }, "message-id not in h="},
389 {"Content-Type not in h=", func(t *testing.T, f *fixture) string {
390 return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b7@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed,
391 func(o *dkim.SignOptions) { o.HeaderKeys = []string{"from", "to", "subject", "message-id"} })
392 }, "content-type not in h="},
393 {"unsigned quoted-printable Content-Transfer-Encoding", func(t *testing.T, f *fixture) string {
394 return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b8@x>", "Content-Transfer-Encoding: quoted-printable\r\n", "hi"),
395 "example.test", "rsa", rsaKey, relaxed, nil)
396 }, "content-transfer-encoding not in h= and not 7bit"},
397 } {
398 t.Run(c.name, func(t *testing.T) {
399 f, _ := dkimSetup(t)
400 res := f.p.Handle([]byte(c.build(t, f)))
401 if res.Posted || res.Retry || !strings.Contains(res.Reason, c.reason) {
402 t.Fatalf("result %+v, want %q", res, c.reason)
403 }
404 if !strings.Contains(f.refusalReasons(t), c.reason) {
405 t.Fatal("refusal not audited")
406 }
407 })
408 }
409}
410
411// Content-Transfer-Encoding in h= passes when the message has one, and
412// a reply address in a signed Cc passes.
413func TestDKIMSignedCTEAndCc(t *testing.T) {
414 f, _ := dkimSetup(t)
415 keys := append([]string{"content-transfer-encoding"}, exampleKeys...)
416 m := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<p1@x>", "Content-Transfer-Encoding: 7bit\r\n", "hi"),
417 "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, func(o *dkim.SignOptions) { o.HeaderKeys = keys })
418 if res := f.p.Handle([]byte(m)); !res.Posted {
419 t.Fatalf("CTE signed: %+v", res)
420 }
421 m = f.messageAs(t, f.bob, "bob@example.test", "<p2@x>", "", "hi")
422 m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test\r\nCc: "+replyAddr(t, f), 1)
423 m = signMsg(t, m, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed,
424 func(o *dkim.SignOptions) { o.HeaderKeys = append([]string{"cc"}, exampleKeys...) })
425 if res := f.p.Handle([]byte(m)); !res.Posted {
426 t.Fatalf("signed Cc: %+v", res)
427 }
428}
429
430// With only trusted_authserv_id set, the reply address may still come
431// from Delivered-To.
432func TestAuthservTokenFromDeliveredTo(t *testing.T) {
433 f := setup(t)
434 f.p.Cfg.Mail.Inbound.TrustedAuthservID = "mx.example.net"
435 m := f.messageAs(t, f.bob, "bob@example.test", "<ar@x>",
436 "Authentication-Results: mx.example.net; dmarc=pass header.from=example.test\r\nDelivered-To: "+replyAddr(t, f)+"\r\n", "hi")
437 m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
438 if res := f.p.Handle([]byte(m)); !res.Posted {
439 t.Fatalf("result %+v", res)
440 }
441}
442
443// A copy of a signed message posts once, whatever unsigned fields or
444// signatures were changed on the way.
445func TestDKIMDedupeBySignature(t *testing.T) {
446 f, _ := dkimSetup(t)
447 m := f.messageAs(t, f.bob, "bob@example.test", "<gone@x>", "", "hi")
448 m = strings.Replace(m, "Message-ID: <gone@x>\r\n", "", 1)
449 m = signMsg(t, m, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
450 if res := f.p.Handle([]byte(m)); !res.Posted {
451 t.Fatalf("first: %+v", res)
452 }
453 if res := f.p.Handle([]byte("X-Resent: 1\r\n" + m)); res.Posted || !strings.Contains(res.Reason, "already posted") {
454 t.Fatalf("copy with an unsigned field added: %+v", res)
455 }
456
457 msg := f.messageAs(t, f.bob, "bob@example.test", "<dual@x>", "", "hi")
458 rsaSig := strings.TrimSuffix(signMsg(t, msg, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil), msg)
459 edSigned := signMsg(t, msg, "example.test", "ed", edKey, dkim.CanonicalizationRelaxed, nil)
460 if res := f.p.Handle([]byte(rsaSig + edSigned)); !res.Posted {
461 t.Fatalf("dual-signed: %+v", res)
462 }
463 if res := f.p.Handle([]byte(edSigned)); res.Posted || !strings.Contains(res.Reason, "already posted") {
464 t.Fatalf("copy with one signature stripped: %+v", res)
465 }
466 if n := len(f.comments(t)); n != 2 {
467 t.Fatalf("%d comments", n)
468 }
469}
470
471func TestParseRawHeader(t *testing.T) {
472 for _, c := range []struct{ raw, reason string }{
473 {"From: a@b\r\nTo: c@d\r\n\r\nbody", ""},
474 {"From: a@b\n Subject-ish continuation\nTo: c@d\n\nbody", ""},
475 {"From : a@b\r\n\r\n", "malformed header field name"},
476 {"From\t: a@b\r\n\r\n", "malformed header field name"},
477 {"Fr\xc3\xb6m: a@b\r\nFrom: a@b\r\n\r\n", "malformed header field name"},
478 {" From: a@b\r\n\r\n", "malformed header"},
479 {"From: a@b\r\nnot a field\r\n\r\n", "malformed header"},
480 {"From: a@b\r\n: x\r\n\r\n", "malformed header"},
481 {"To: c@d\r\n\r\n", "no From field"},
482 {"From: a@b\r\nfROM: c@d\r\n\r\n", "more than one From field"},
483 {"From: a@b\r\nMessage-Id: 1\r\nMESSAGE-ID: 2\r\n\r\n", "more than one message-id field"},
484 {"From: a@b\r\n\r\nFrom : in the body is fine\r\n", ""},
485 } {
486 if _, got := parseRawHeader([]byte(c.raw)); got != c.reason {
487 t.Errorf("%q: %q, want %q", c.raw, got, c.reason)
488 }
489 }
490 h, _ := parseRawHeader([]byte("DKIM-Signature: v=1; b=ab\r\n cd ;d=x\r\nFrom: a@b\r\ndkim-signature: b= ef\r\n\r\n"))
491 if len(h.dkimB) != 2 || h.dkimB[0] != "abcd" || h.dkimB[1] != "ef" {
492 t.Fatalf("dkimB = %q", h.dkimB)
493 }
494 if h, _ := parseRawHeader([]byte("From: a@b\r\nContent-Transfer-Encoding:\r\n Quoted-Printable \r\n\r\n")); h.cte != "quoted-printable" {
495 t.Fatalf("cte = %q", h.cte)
496 }
497}
498
499// The shape Thunderbird sends through Migadu: Content-Transfer-Encoding
500// outside h=. An identity encoding posts; one that changes the decoded
501// body, added unsigned, is refused.
502func TestDKIMUnsignedCTE(t *testing.T) {
503 for _, c := range []struct {
504 cte string
505 post bool
506 }{{"7bit", true}, {" 8BIT ", true}, {"binary", true}, {"quoted-printable", false}, {"base64", false}} {
507 f, _ := dkimSetup(t)
508 msg := "From: Bob <bob@example.test>\r\nTo: " + replyAddr(t, f) + "\r\nSubject: Re: [alice/app] #1: title\r\n" +
509 "Date: Tue, 29 Sep 2026 12:00:00 -0500\r\nMessage-ID: <tb-" + strings.TrimSpace(c.cte) + "@example.test>\r\nMIME-Version: 1.0\r\n" +
510 "Content-Type: text/plain; charset=UTF-8; format=flowed\r\nContent-Transfer-Encoding:" + c.cte + "\r\n\r\nThunderbird reply.\r\n"
511 m := signMsg(t, msg, "example.test", "rsa", rsaKey, dkim.CanonicalizationSimple, nil)
512 if !strings.Contains(m, "a=rsa-sha256;") || !strings.Contains(m, "c=simple/simple;") || !strings.Contains(m, "d=example.test;") ||
513 !strings.Contains(m, "h=from:to:subject:date:message-id:mime-version:content-type;") {
514 t.Fatalf("signature not in the expected shape:\n%s", m)
515 }
516 res := f.p.Handle([]byte(m))
517 if res.Posted != c.post {
518 t.Fatalf("CTE %q: posted %v, want %v (%+v)", c.cte, res.Posted, c.post, res)
519 }
520 if !c.post && !strings.Contains(res.Reason, "content-transfer-encoding not in h=") {
521 t.Fatalf("CTE %q: reason %q", c.cte, res.Reason)
522 }
523 }
524}
internal/mailin/fuzz_test.go +7 −2
@@ -21,12 +21,13 @@ func FuzzReply(f *testing.F) {
2121 f.Add([]byte("Subject: x\r\n\r\nOn Mon wrote:\r\n> a\r\n-- \r\nsig\r\n"))
2222 key := [][]byte{[]byte("0123456789abcdef0123456789abcdef")}
2323 f.Fuzz(func(t *testing.T, raw []byte) {
24 parseRawHeader(raw)
2425 msg, err := mail.ReadMessage(bytes.NewReader(raw))
2526 if err != nil {
2627 return
2728 }
2829 automatic(msg.Header)
29 if tok := findToken(msg.Header, "reply@x.example"); tok != "" {
30 if tok, _ := findToken(msg.Header, "reply@x.example", recipientFields); tok != "" {
3031 mailreply.Verify(key, tok, fuzzNow)
3132 }
3233 if s, err := textBody(textproto.MIMEHeader(msg.Header), msg.Body, 1<<16); err == nil {
@@ -60,7 +61,11 @@ func FuzzDKIM(f *testing.F) {
6061 if err != nil || len(from) != 1 {
6162 return
6263 }
64 rh, reason := parseRawHeader(raw)
65 if reason != "" {
66 return
67 }
6368 p := &Processor{LookupTXT: (&fakeDNS{}).lookup}
64 p.dkimVerified(raw, msg.Header, from[0].Address)
69 p.dkimVerified(raw, rh, from[0].Address, "to")
6570 })
6671}
internal/mailin/header.go added +102
@@ -0,0 +1,102 @@
1package mailin
2
3import (
4 "bytes"
5 "strings"
6)
7
8// rawHeader is what the checks read from the header section as
9// fetched, before net/mail or the dkim package interpret it.
10type rawHeader struct {
11 count map[string]int // field name, lower case, to occurrences
12 dkimB []string // b= of each DKIM-Signature, in order, whitespace removed
13 cte string // Content-Transfer-Encoding, unfolded, trimmed, lower case
14}
15
16// single names the fields a reply may carry at most once; From must
17// appear exactly once.
18var single = []string{"from", "to", "cc", "message-id", "content-type", "content-transfer-encoding"}
19
20// parseRawHeader reads the header section of raw. A field name must be
21// RFC 5322 ftext (printable US-ASCII other than ":"), so "From : x",
22// which net/mail files under another name than the dkim package does,
23// is refused rather than read two ways. It returns the refusal's
24// reason, or "".
25func parseRawHeader(raw []byte) (rawHeader, string) {
26 h := rawHeader{count: map[string]int{}}
27 var dkimVals []string
28 cur := -1 // index in dkimVals of the DKIM-Signature being continued
29 inCTE := false
30 for len(raw) > 0 {
31 line := raw
32 if i := bytes.IndexByte(raw, '\n'); i >= 0 {
33 line, raw = raw[:i], raw[i+1:]
34 } else {
35 raw = nil
36 }
37 line = bytes.TrimSuffix(line, []byte("\r"))
38 if len(line) == 0 {
39 break
40 }
41 if line[0] == ' ' || line[0] == '\t' {
42 if len(h.count) == 0 {
43 return h, "malformed header"
44 }
45 if cur >= 0 {
46 dkimVals[cur] += string(line)
47 }
48 if inCTE {
49 h.cte += string(line)
50 }
51 continue
52 }
53 name, value, ok := bytes.Cut(line, []byte(":"))
54 if !ok || len(name) == 0 {
55 return h, "malformed header"
56 }
57 for _, c := range name {
58 if c < 33 || c > 126 {
59 return h, "malformed header field name"
60 }
61 }
62 n := strings.ToLower(string(name))
63 h.count[n]++
64 cur = -1
65 inCTE = n == "content-transfer-encoding"
66 if inCTE {
67 h.cte = string(value)
68 }
69 if n == "dkim-signature" {
70 dkimVals = append(dkimVals, string(value))
71 cur = len(dkimVals) - 1
72 }
73 }
74 h.cte = strings.ToLower(strings.TrimSpace(h.cte))
75 for _, v := range dkimVals {
76 h.dkimB = append(h.dkimB, tagB(v))
77 }
78 if n := h.count["from"]; n != 1 {
79 if n == 0 {
80 return h, "no From field"
81 }
82 return h, "more than one From field"
83 }
84 for _, n := range single[1:] {
85 if h.count[n] > 1 {
86 return h, "more than one " + n + " field"
87 }
88 }
89 return h, ""
90}
91
92// tagB returns the b= tag of a DKIM-Signature value with whitespace
93// removed, or "".
94func tagB(v string) string {
95 for _, t := range strings.Split(v, ";") {
96 k, val, ok := strings.Cut(t, "=")
97 if ok && strings.TrimSpace(k) == "b" {
98 return strings.Join(strings.Fields(val), "")
99 }
100 }
101 return ""
102}
internal/mailin/mailin.go +61 −30
@@ -140,6 +140,10 @@ func (p *Processor) Handle(raw []byte) Result {
140140 if len(bytes.TrimSpace(raw)) == 0 {
141141 return p.refuse(0, "", "empty message")
142142 }
143 rh, reason := parseRawHeader(raw)
144 if reason != "" {
145 return p.refuse(0, "", reason)
146 }
143147 msg, err := mail.ReadMessage(bytes.NewReader(raw))
144148 if err != nil {
145149 return p.refuse(0, "", "unreadable message")
@@ -152,7 +156,7 @@ func (p *Processor) Handle(raw []byte) Result {
152156 return p.refuse(0, msgID, "automatic reply")
153157 }
154158 in := p.Cfg.Mail.Inbound
155 token := findToken(msg.Header, in.ReplyAddress)
159 token, _ := findToken(msg.Header, in.ReplyAddress, recipientFields)
156160 if token == "" {
157161 return p.refuse(0, msgID, "not addressed to a reply address")
158162 }
@@ -201,7 +205,8 @@ func (p *Processor) Handle(raw []byte) Result {
201205 if !ok {
202206 return p.refuse(u.ID, msgID, "From is not a verified address of the account")
203207 }
204 if res := p.authenticate(raw, msg.Header, from[0].Address); res != nil {
208 sigIDs, res := p.authenticate(raw, rh, msg.Header, from[0].Address, token)
209 if res != nil {
205210 if res.Retry {
206211 return *res
207212 }
@@ -248,13 +253,24 @@ func (p *Processor) Handle(raw []byte) Result {
248253 sum := sha256.Sum256(raw)
249254 id = "sha256:" + hex.EncodeToString(sum[:])
250255 }
251 key := fmt.Sprintf("%d/%s/%d/%d/%s", u.ID, target.Kind, target.RepoID, target.Number, id)
252 claimed, err := p.St.ClaimMailReply(key)
253 if err != nil {
254 return Result{Retry: true, Reason: err.Error()}
255 }
256 if !claimed {
257 return p.refuse(u.ID, msgID, "already posted")
256 // Each passing DKIM signature is claimed too, so a copy of a signed
257 // message is not posted again under another Message-ID or with
258 // unsigned fields changed.
259 var claims []string
260 for _, id := range append([]string{id}, sigIDs...) {
261 claims = append(claims, fmt.Sprintf("%d/%s/%d/%d/%s", u.ID, target.Kind, target.RepoID, target.Number, id))
262 }
263 for n, k := range claims {
264 claimed, err := p.St.ClaimMailReply(k)
265 if err != nil || !claimed {
266 for _, k := range claims[:n] {
267 p.St.ReleaseMailReply(k)
268 }
269 if err != nil {
270 return Result{Retry: true, Reason: err.Error()}
271 }
272 return p.refuse(u.ID, msgID, "already posted")
273 }
258274 }
259275
260276 var stdout, stderr bytes.Buffer
@@ -266,8 +282,10 @@ func (p *Processor) Handle(raw []byte) Result {
266282 if code == protocol.ExitOK {
267283 return Result{Posted: true}
268284 }
269 p.St.ReleaseMailReply(key)
270 reason := strings.TrimSpace(stderr.String())
285 for _, k := range claims {
286 p.St.ReleaseMailReply(k)
287 }
288 reason = strings.TrimSpace(stderr.String())
271289 if code == protocol.ExitFailure {
272290 return Result{Retry: true, Reason: reason}
273291 }
@@ -279,32 +297,42 @@ func (p *Processor) Handle(raw []byte) Result {
279297// authenticate checks that the mail host or the sender's domain vouches
280298// for From: an Authentication-Results pass from trusted_authserv_id, or
281299// a DKIM signature that verifies here with require_dkim. When both are
282// set either is enough. It returns nil when From is authenticated or
283// neither is set, and the unaudited refusal or retry otherwise.
284func (p *Processor) authenticate(raw []byte, h mail.Header, from string) *Result {
300// set either is enough. A DKIM pass also needs the reply address in a
301// signed To or Cc; an Authentication-Results pass takes it from any
302// recipient field. It returns nil when From is authenticated or neither
303// is set, and the unaudited refusal or retry otherwise; sigIDs are the
304// passing DKIM signatures when DKIM authenticated the reply.
305func (p *Processor) authenticate(raw []byte, rh rawHeader, h mail.Header, from, token string) (sigIDs []string, res *Result) {
285306 in := p.Cfg.Mail.Inbound
286307 var reasons []string
287308 if id := in.TrustedAuthservID; id != "" {
288309 reason := authenticated(h, id, from)
289310 if reason == "" {
290 return nil
311 return nil, nil
291312 }
292313 reasons = append(reasons, reason)
293314 }
294315 if in.RequireDKIM {
295 reason, retry := p.dkimVerified(raw, h, from)
296 if reason == "" {
297 return nil
298 }
299 if retry {
300 return &Result{Retry: true, Reason: reason}
316 // The reply address must be in a field the signature covers,
317 // or a signed message could be redirected to any token.
318 tok, field := findToken(h, in.ReplyAddress, []string{"To", "Cc"})
319 if tok != token {
320 reasons = append(reasons, "DKIM: reply address not in To or Cc")
321 } else {
322 ids, reason, retry := p.dkimVerified(raw, rh, from, field)
323 if reason == "" {
324 return ids, nil
325 }
326 if retry {
327 return nil, &Result{Retry: true, Reason: reason}
328 }
329 reasons = append(reasons, reason)
301330 }
302 reasons = append(reasons, reason)
303331 }
304332 if len(reasons) == 0 {
305 return nil
333 return nil, nil
306334 }
307 return &Result{Reason: strings.Join(reasons, "; ")}
335 return nil, &Result{Reason: strings.Join(reasons, "; ")}
308336}
309337
310338// createdAfter refuses when the row was created after the token was
@@ -370,10 +398,13 @@ func automatic(h mail.Header) bool {
370398 return h.Get("X-Autoreply") != "" || h.Get("X-Autorespond") != ""
371399}
372400
373// findToken returns the reply token from the first recipient header
374// that carries one.
375func findToken(h mail.Header, base string) string {
376 for _, name := range []string{"Delivered-To", "X-Original-To", "Envelope-To", "To", "Cc"} {
401// recipientFields are where a reply address is looked for, in order.
402var recipientFields = []string{"Delivered-To", "X-Original-To", "Envelope-To", "To", "Cc"}
403
404// findToken returns the reply token from the first of names that
405// carries one, and that field's name in lower case.
406func findToken(h mail.Header, base string, names []string) (token, field string) {
407 for _, name := range names {
377408 for _, v := range h[textproto.CanonicalMIMEHeaderKey(name)] {
378409 addrs, err := mail.ParseAddressList(v)
379410 if err != nil {
@@ -381,12 +412,12 @@ func findToken(h mail.Header, base string) string {
381412 }
382413 for _, a := range addrs {
383414 if tok, ok := mailreply.TokenFrom(base, a.Address); ok {
384 return tok
415 return tok, strings.ToLower(name)
385416 }
386417 }
387418 }
388419 }
389 return ""
420 return "", ""
390421}
391422
392423// Poller reads the configured mailbox every poll interval.