Commit 3d82ea0efe
Verified · cmc
Layout: unified · split
internal/mailin/dkim.go +66 −28
| @@ -3,9 +3,10 @@ package mailin | |||
| 3 | import ( | 3 | import ( |
| 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 |
| 109 | func (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. | ||
| 119 | func (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 | ||
| 157 | func 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") { | 188 | func 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. | ||
| 336 | func 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 | |||
| 349 | func replyAddr(t *testing.T, f *fixture) string { | ||
| 350 | return mailreply.Address(replyBase, f.token(t, f.bob)) | ||
| 351 | } | ||
| 352 | |||
| 353 | func 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. | ||
| 413 | func 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. | ||
| 432 | func 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. | ||
| 445 | func 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 | |||
| 471 | func 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. | ||
| 502 | func 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 @@ | |||
| 1 | package mailin | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 10 | type 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. | ||
| 18 | var 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 "". | ||
| 25 | func 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 "". | ||
| 94 | func 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 |
| 284 | func (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. | ||
| 305 | func (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. | 402 | var recipientFields = []string{"Delivered-To", "X-Original-To", "Envelope-To", "To", "Cc"} |
| 375 | func 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. | ||
| 406 | func 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. |