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
3import ( 3import (
4 "bytes" 4 "bytes"
5 "context" 5 "context"
6 "crypto/sha256"
7 "encoding/hex"
6 "errors" 8 "errors"
7 "net" 9 "net"
8 "net/mail"
9 "strings" 10 "strings"
10 "sync" 11 "sync"
11 "time" 12 "time"
@@ -20,8 +21,11 @@ const (
20 // dnsTimeout bounds one selector key lookup. 21 // dnsTimeout bounds one selector key lookup.
21 dnsTimeout = 5 * time.Second 22 dnsTimeout = 5 * time.Second
22 // futureSkew is how far ahead of this clock a signature's t= may be. 23 // futureSkew is how far ahead of this clock a signature's t= may be.
23 futureSkew = 15 * time.Minute 24 futureSkew = 15 * time.Minute
24 keyCacheTTL = time.Hour 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
25 keyCacheSize = 256 29 keyCacheSize = 256
26) 30)
27 31
@@ -101,32 +105,46 @@ func (p *Processor) lookupKey(name string) ([]string, error) {
101} 105}
102 106
103// dkimVerified checks the DKIM signatures on raw, the message as it 107// dkimVerified checks the DKIM signatures on raw, the message as it
104// was fetched. It returns "" when one of the first maxSignatures 108// was fetched. A signature passes when it is one of the first
105// verifies, covers From in h=, has a d= in relaxed alignment with the 109// maxSignatures, verifies, has a d= in relaxed alignment with the From
106// From domain, has not expired and is not dated in the future. retry is 110// domain, has not expired, is not dated in the future, and its h=
107// true when no signature passed and one could not be checked because 111// covers From, tokenField (the To or Cc the reply address was read
108// its key lookup failed for a reason that may pass. 112// from), Content-Type, and Message-ID when the message has one. An
109func (p *Processor) dkimVerified(raw []byte, h mail.Header, from string) (reason string, retry bool) { 113// unsigned Content-Transfer-Encoding is accepted only when it is an
110 _, fromDomain, ok := strings.Cut(strings.ToLower(from), "@") 114// identity encoding (7bit, 8bit, binary), which does not change what
111 if !ok || fromDomain == "" { 115// the body decodes to; mail clients commonly leave it out of h=. It returns an id for each passing
112 return "no From domain", false 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:])
113 } 123 }
114 // A second From field could be one the signature does not cover 124 if fromDomain == "" {
115 // while it is the one read as the sender. 125 return nil, "no From domain", false
116 if len(h["From"]) != 1 { 126 }
117 return "more than one From field", false 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
118 } 136 }
119 verifs, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{ 137 verifs, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{
120 LookupTXT: p.lookupKey, MaxVerifications: maxSignatures}) 138 LookupTXT: p.lookupKey, MaxVerifications: maxSignatures})
121 if err != nil && !errors.Is(err, dkim.ErrTooManySignatures) { 139 if err != nil && !errors.Is(err, dkim.ErrTooManySignatures) {
122 return "DKIM: unreadable message", false 140 return nil, "DKIM: unreadable message", false
123 } 141 }
124 if len(verifs) == 0 { 142 if len(verifs) == 0 {
125 return "no DKIM-Signature", false 143 return nil, "no DKIM-Signature", false
126 } 144 }
127 now := p.now() 145 now := p.now()
128 var fails []string 146 var fails []string
129 for _, v := range verifs { 147 for i, v := range verifs {
130 d := strings.ToLower(v.Domain) 148 d := strings.ToLower(v.Domain)
131 why := "" 149 why := ""
132 switch { 150 switch {
@@ -135,8 +153,6 @@ func (p *Processor) dkimVerified(raw []byte, h mail.Header, from string) (reason
135 why = "key lookup failed" 153 why = "key lookup failed"
136 case v.Err != nil: 154 case v.Err != nil:
137 why = strings.TrimPrefix(v.Err.Error(), "dkim: ") 155 why = strings.TrimPrefix(v.Err.Error(), "dkim: ")
138 case !signsFrom(v.HeaderKeys):
139 why = "From field not signed"
140 case !v.Expiration.IsZero() && now.After(v.Expiration): 156 case !v.Expiration.IsZero() && now.After(v.Expiration):
141 why = "signature has expired" 157 why = "signature has expired"
142 case !v.Time.IsZero() && v.Time.After(now.Add(futureSkew)): 158 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
144 case !aligned(d, fromDomain): 160 case !aligned(d, fromDomain):
145 why = "d= not aligned with the From domain" 161 why = "d= not aligned with the From domain"
146 default: 162 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 }
148 } 174 }
149 if len(d) > 100 { 175 if len(d) > 100 {
150 d = d[:100] 176 d = d[:100]
151 } 177 }
152 fails = append(fails, "d="+d+": "+why) 178 fails = append(fails, "d="+d+": "+why)
153 } 179 }
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
155} 184}
156 185
157func signsFrom(keys []string) bool { 186// unsigned returns the first of need that keys (a signature's h=) does
158 for _, k := range keys { 187// not list, or "".
159 if strings.EqualFold(k, "from") { 188func unsigned(keys, need []string) string {
160 return true 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
161 } 199 }
162 } 200 }
163 return false 201 return ""
164} 202}
internal/mailin/dkim_test.go +197
@@ -17,6 +17,8 @@ import (
17 "time" 17 "time"
18 18
19 "github.com/emersion/go-msgauth/dkim" 19 "github.com/emersion/go-msgauth/dkim"
20
21 "gitbay.org/gitbay/internal/mailreply"
20) 22)
21 23
22// Fixed test keys: RSA 2048 and Ed25519. 24// Fixed test keys: RSA 2048 and Ed25519.
@@ -325,3 +327,198 @@ func TestLookupKeyErrors(t *testing.T) {
325 } 327 }
326 } 328 }
327} 329}
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) {
21 f.Add([]byte("Subject: x\r\n\r\nOn Mon wrote:\r\n> a\r\n-- \r\nsig\r\n")) 21 f.Add([]byte("Subject: x\r\n\r\nOn Mon wrote:\r\n> a\r\n-- \r\nsig\r\n"))
22 key := [][]byte{[]byte("0123456789abcdef0123456789abcdef")} 22 key := [][]byte{[]byte("0123456789abcdef0123456789abcdef")}
23 f.Fuzz(func(t *testing.T, raw []byte) { 23 f.Fuzz(func(t *testing.T, raw []byte) {
24 parseRawHeader(raw)
24 msg, err := mail.ReadMessage(bytes.NewReader(raw)) 25 msg, err := mail.ReadMessage(bytes.NewReader(raw))
25 if err != nil { 26 if err != nil {
26 return 27 return
27 } 28 }
28 automatic(msg.Header) 29 automatic(msg.Header)
29 if tok := findToken(msg.Header, "reply@x.example"); tok != "" { 30 if tok, _ := findToken(msg.Header, "reply@x.example", recipientFields); tok != "" {
30 mailreply.Verify(key, tok, fuzzNow) 31 mailreply.Verify(key, tok, fuzzNow)
31 } 32 }
32 if s, err := textBody(textproto.MIMEHeader(msg.Header), msg.Body, 1<<16); err == nil { 33 if s, err := textBody(textproto.MIMEHeader(msg.Header), msg.Body, 1<<16); err == nil {
@@ -60,7 +61,11 @@ func FuzzDKIM(f *testing.F) {
60 if err != nil || len(from) != 1 { 61 if err != nil || len(from) != 1 {
61 return 62 return
62 } 63 }
64 rh, reason := parseRawHeader(raw)
65 if reason != "" {
66 return
67 }
63 p := &Processor{LookupTXT: (&fakeDNS{}).lookup} 68 p := &Processor{LookupTXT: (&fakeDNS{}).lookup}
64 p.dkimVerified(raw, msg.Header, from[0].Address) 69 p.dkimVerified(raw, rh, from[0].Address, "to")
65 }) 70 })
66} 71}
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 {
140 if len(bytes.TrimSpace(raw)) == 0 { 140 if len(bytes.TrimSpace(raw)) == 0 {
141 return p.refuse(0, "", "empty message") 141 return p.refuse(0, "", "empty message")
142 } 142 }
143 rh, reason := parseRawHeader(raw)
144 if reason != "" {
145 return p.refuse(0, "", reason)
146 }
143 msg, err := mail.ReadMessage(bytes.NewReader(raw)) 147 msg, err := mail.ReadMessage(bytes.NewReader(raw))
144 if err != nil { 148 if err != nil {
145 return p.refuse(0, "", "unreadable message") 149 return p.refuse(0, "", "unreadable message")
@@ -152,7 +156,7 @@ func (p *Processor) Handle(raw []byte) Result {
152 return p.refuse(0, msgID, "automatic reply") 156 return p.refuse(0, msgID, "automatic reply")
153 } 157 }
154 in := p.Cfg.Mail.Inbound 158 in := p.Cfg.Mail.Inbound
155 token := findToken(msg.Header, in.ReplyAddress) 159 token, _ := findToken(msg.Header, in.ReplyAddress, recipientFields)
156 if token == "" { 160 if token == "" {
157 return p.refuse(0, msgID, "not addressed to a reply address") 161 return p.refuse(0, msgID, "not addressed to a reply address")
158 } 162 }
@@ -201,7 +205,8 @@ func (p *Processor) Handle(raw []byte) Result {
201 if !ok { 205 if !ok {
202 return p.refuse(u.ID, msgID, "From is not a verified address of the account") 206 return p.refuse(u.ID, msgID, "From is not a verified address of the account")
203 } 207 }
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 {
205 if res.Retry { 210 if res.Retry {
206 return *res 211 return *res
207 } 212 }
@@ -248,13 +253,24 @@ func (p *Processor) Handle(raw []byte) Result {
248 sum := sha256.Sum256(raw) 253 sum := sha256.Sum256(raw)
249 id = "sha256:" + hex.EncodeToString(sum[:]) 254 id = "sha256:" + hex.EncodeToString(sum[:])
250 } 255 }
251 key := fmt.Sprintf("%d/%s/%d/%d/%s", u.ID, target.Kind, target.RepoID, target.Number, id) 256 // Each passing DKIM signature is claimed too, so a copy of a signed
252 claimed, err := p.St.ClaimMailReply(key) 257 // message is not posted again under another Message-ID or with
253 if err != nil { 258 // unsigned fields changed.
254 return Result{Retry: true, Reason: err.Error()} 259 var claims []string
255 } 260 for _, id := range append([]string{id}, sigIDs...) {
256 if !claimed { 261 claims = append(claims, fmt.Sprintf("%d/%s/%d/%d/%s", u.ID, target.Kind, target.RepoID, target.Number, id))
257 return p.refuse(u.ID, msgID, "already posted") 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 }
258 } 274 }
259 275
260 var stdout, stderr bytes.Buffer 276 var stdout, stderr bytes.Buffer
@@ -266,8 +282,10 @@ func (p *Processor) Handle(raw []byte) Result {
266 if code == protocol.ExitOK { 282 if code == protocol.ExitOK {
267 return Result{Posted: true} 283 return Result{Posted: true}
268 } 284 }
269 p.St.ReleaseMailReply(key) 285 for _, k := range claims {
270 reason := strings.TrimSpace(stderr.String()) 286 p.St.ReleaseMailReply(k)
287 }
288 reason = strings.TrimSpace(stderr.String())
271 if code == protocol.ExitFailure { 289 if code == protocol.ExitFailure {
272 return Result{Retry: true, Reason: reason} 290 return Result{Retry: true, Reason: reason}
273 } 291 }
@@ -279,32 +297,42 @@ func (p *Processor) Handle(raw []byte) Result {
279// authenticate checks that the mail host or the sender's domain vouches 297// authenticate checks that the mail host or the sender's domain vouches
280// for From: an Authentication-Results pass from trusted_authserv_id, or 298// for From: an Authentication-Results pass from trusted_authserv_id, or
281// a DKIM signature that verifies here with require_dkim. When both are 299// 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 300// set either is enough. A DKIM pass also needs the reply address in a
283// neither is set, and the unaudited refusal or retry otherwise. 301// signed To or Cc; an Authentication-Results pass takes it from any
284func (p *Processor) authenticate(raw []byte, h mail.Header, from string) *Result { 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) {
285 in := p.Cfg.Mail.Inbound 306 in := p.Cfg.Mail.Inbound
286 var reasons []string 307 var reasons []string
287 if id := in.TrustedAuthservID; id != "" { 308 if id := in.TrustedAuthservID; id != "" {
288 reason := authenticated(h, id, from) 309 reason := authenticated(h, id, from)
289 if reason == "" { 310 if reason == "" {
290 return nil 311 return nil, nil
291 } 312 }
292 reasons = append(reasons, reason) 313 reasons = append(reasons, reason)
293 } 314 }
294 if in.RequireDKIM { 315 if in.RequireDKIM {
295 reason, retry := p.dkimVerified(raw, h, from) 316 // The reply address must be in a field the signature covers,
296 if reason == "" { 317 // or a signed message could be redirected to any token.
297 return nil 318 tok, field := findToken(h, in.ReplyAddress, []string{"To", "Cc"})
298 } 319 if tok != token {
299 if retry { 320 reasons = append(reasons, "DKIM: reply address not in To or Cc")
300 return &Result{Retry: true, Reason: reason} 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)
301 } 330 }
302 reasons = append(reasons, reason)
303 } 331 }
304 if len(reasons) == 0 { 332 if len(reasons) == 0 {
305 return nil 333 return nil, nil
306 } 334 }
307 return &Result{Reason: strings.Join(reasons, "; ")} 335 return nil, &Result{Reason: strings.Join(reasons, "; ")}
308} 336}
309 337
310// createdAfter refuses when the row was created after the token was 338// createdAfter refuses when the row was created after the token was
@@ -370,10 +398,13 @@ func automatic(h mail.Header) bool {
370 return h.Get("X-Autoreply") != "" || h.Get("X-Autorespond") != "" 398 return h.Get("X-Autoreply") != "" || h.Get("X-Autorespond") != ""
371} 399}
372 400
373// findToken returns the reply token from the first recipient header 401// recipientFields are where a reply address is looked for, in order.
374// that carries one. 402var recipientFields = []string{"Delivered-To", "X-Original-To", "Envelope-To", "To", "Cc"}
375func findToken(h mail.Header, base string) string { 403
376 for _, name := range []string{"Delivered-To", "X-Original-To", "Envelope-To", "To", "Cc"} { 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 {
377 for _, v := range h[textproto.CanonicalMIMEHeaderKey(name)] { 408 for _, v := range h[textproto.CanonicalMIMEHeaderKey(name)] {
378 addrs, err := mail.ParseAddressList(v) 409 addrs, err := mail.ParseAddressList(v)
379 if err != nil { 410 if err != nil {
@@ -381,12 +412,12 @@ func findToken(h mail.Header, base string) string {
381 } 412 }
382 for _, a := range addrs { 413 for _, a := range addrs {
383 if tok, ok := mailreply.TokenFrom(base, a.Address); ok { 414 if tok, ok := mailreply.TokenFrom(base, a.Address); ok {
384 return tok 415 return tok, strings.ToLower(name)
385 } 416 }
386 } 417 }
387 } 418 }
388 } 419 }
389 return "" 420 return "", ""
390} 421}
391 422
392// Poller reads the configured mailbox every poll interval. 423// Poller reads the configured mailbox every poll interval.