Commit 3d82ea0efe
Verified · cmc
Layout: unified · split
internal/mailin/dkim.go +66 −28
| @@ -3,9 +3,10 @@ package mailin | ||
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | 5 | "context" |
| 6 | "crypto/sha256" | |
| 7 | "encoding/hex" | |
| 6 | 8 | "errors" |
| 7 | 9 | "net" |
| 8 | "net/mail" | |
| 9 | 10 | "strings" |
| 10 | 11 | "sync" |
| 11 | 12 | "time" |
| @@ -20,8 +21,11 @@ const ( | ||
| 20 | 21 | // dnsTimeout bounds one selector key lookup. |
| 21 | 22 | dnsTimeout = 5 * time.Second |
| 22 | 23 | // 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 | |
| 25 | 29 | keyCacheSize = 256 |
| 26 | 30 | ) |
| 27 | 31 | |
| @@ -101,32 +105,46 @@ func (p *Processor) lookupKey(name string) ([]string, error) { | ||
| 101 | 105 | } |
| 102 | 106 | |
| 103 | 107 | // 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. | |
| 109 | func (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. | |
| 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 | |
| 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 | |
| 118 | 136 | } |
| 119 | 137 | verifs, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{ |
| 120 | 138 | LookupTXT: p.lookupKey, MaxVerifications: maxSignatures}) |
| 121 | 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 | 142 | if len(verifs) == 0 { |
| 125 | return "no DKIM-Signature", false | |
| 143 | return nil, "no DKIM-Signature", false | |
| 126 | 144 | } |
| 127 | 145 | now := p.now() |
| 128 | 146 | var fails []string |
| 129 | for _, v := range verifs { | |
| 147 | for i, v := range verifs { | |
| 130 | 148 | d := strings.ToLower(v.Domain) |
| 131 | 149 | why := "" |
| 132 | 150 | switch { |
| @@ -135,8 +153,6 @@ func (p *Processor) dkimVerified(raw []byte, h mail.Header, from string) (reason | ||
| 135 | 153 | why = "key lookup failed" |
| 136 | 154 | case v.Err != nil: |
| 137 | 155 | why = strings.TrimPrefix(v.Err.Error(), "dkim: ") |
| 138 | case !signsFrom(v.HeaderKeys): | |
| 139 | why = "From field not signed" | |
| 140 | 156 | case !v.Expiration.IsZero() && now.After(v.Expiration): |
| 141 | 157 | why = "signature has expired" |
| 142 | 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 | 160 | case !aligned(d, fromDomain): |
| 145 | 161 | why = "d= not aligned with the From domain" |
| 146 | 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 | 175 | if len(d) > 100 { |
| 150 | 176 | d = d[:100] |
| 151 | 177 | } |
| 152 | 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 { | |
| 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 "". | |
| 188 | func 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 | |
| 161 | 199 | } |
| 162 | 200 | } |
| 163 | return false | |
| 201 | return "" | |
| 164 | 202 | } |
internal/mailin/dkim_test.go +197
| @@ -17,6 +17,8 @@ import ( | ||
| 17 | 17 | "time" |
| 18 | 18 | |
| 19 | 19 | "github.com/emersion/go-msgauth/dkim" |
| 20 | ||
| 21 | "gitbay.org/gitbay/internal/mailreply" | |
| 20 | 22 | ) |
| 21 | 23 | |
| 22 | 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 | 21 | f.Add([]byte("Subject: x\r\n\r\nOn Mon wrote:\r\n> a\r\n-- \r\nsig\r\n")) |
| 22 | 22 | key := [][]byte{[]byte("0123456789abcdef0123456789abcdef")} |
| 23 | 23 | f.Fuzz(func(t *testing.T, raw []byte) { |
| 24 | parseRawHeader(raw) | |
| 24 | 25 | msg, err := mail.ReadMessage(bytes.NewReader(raw)) |
| 25 | 26 | if err != nil { |
| 26 | 27 | return |
| 27 | 28 | } |
| 28 | 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 | 31 | mailreply.Verify(key, tok, fuzzNow) |
| 31 | 32 | } |
| 32 | 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 | 61 | if err != nil || len(from) != 1 { |
| 61 | 62 | return |
| 62 | 63 | } |
| 64 | rh, reason := parseRawHeader(raw) | |
| 65 | if reason != "" { | |
| 66 | return | |
| 67 | } | |
| 63 | 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 | 140 | if len(bytes.TrimSpace(raw)) == 0 { |
| 141 | 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 | 147 | msg, err := mail.ReadMessage(bytes.NewReader(raw)) |
| 144 | 148 | if err != nil { |
| 145 | 149 | return p.refuse(0, "", "unreadable message") |
| @@ -152,7 +156,7 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 152 | 156 | return p.refuse(0, msgID, "automatic reply") |
| 153 | 157 | } |
| 154 | 158 | in := p.Cfg.Mail.Inbound |
| 155 | token := findToken(msg.Header, in.ReplyAddress) | |
| 159 | token, _ := findToken(msg.Header, in.ReplyAddress, recipientFields) | |
| 156 | 160 | if token == "" { |
| 157 | 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 | 205 | if !ok { |
| 202 | 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 | 210 | if res.Retry { |
| 206 | 211 | return *res |
| 207 | 212 | } |
| @@ -248,13 +253,24 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 248 | 253 | sum := sha256.Sum256(raw) |
| 249 | 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) | |
| 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 | } | |
| 258 | 274 | } |
| 259 | 275 | |
| 260 | 276 | var stdout, stderr bytes.Buffer |
| @@ -266,8 +282,10 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 266 | 282 | if code == protocol.ExitOK { |
| 267 | 283 | return Result{Posted: true} |
| 268 | 284 | } |
| 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()) | |
| 271 | 289 | if code == protocol.ExitFailure { |
| 272 | 290 | return Result{Retry: true, Reason: reason} |
| 273 | 291 | } |
| @@ -279,32 +297,42 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 279 | 297 | // authenticate checks that the mail host or the sender's domain vouches |
| 280 | 298 | // for From: an Authentication-Results pass from trusted_authserv_id, or |
| 281 | 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 | |
| 283 | // neither is set, and the unaudited refusal or retry otherwise. | |
| 284 | func (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. | |
| 305 | func (p *Processor) authenticate(raw []byte, rh rawHeader, h mail.Header, from, token string) (sigIDs []string, res *Result) { | |
| 285 | 306 | in := p.Cfg.Mail.Inbound |
| 286 | 307 | var reasons []string |
| 287 | 308 | if id := in.TrustedAuthservID; id != "" { |
| 288 | 309 | reason := authenticated(h, id, from) |
| 289 | 310 | if reason == "" { |
| 290 | return nil | |
| 311 | return nil, nil | |
| 291 | 312 | } |
| 292 | 313 | reasons = append(reasons, reason) |
| 293 | 314 | } |
| 294 | 315 | 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) | |
| 301 | 330 | } |
| 302 | reasons = append(reasons, reason) | |
| 303 | 331 | } |
| 304 | 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 | 338 | // createdAfter refuses when the row was created after the token was |
| @@ -370,10 +398,13 @@ func automatic(h mail.Header) bool { | ||
| 370 | 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 | |
| 374 | // that carries one. | |
| 375 | func 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. | |
| 402 | var 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. | |
| 406 | func findToken(h mail.Header, base string, names []string) (token, field string) { | |
| 407 | for _, name := range names { | |
| 377 | 408 | for _, v := range h[textproto.CanonicalMIMEHeaderKey(name)] { |
| 378 | 409 | addrs, err := mail.ParseAddressList(v) |
| 379 | 410 | if err != nil { |
| @@ -381,12 +412,12 @@ func findToken(h mail.Header, base string) string { | ||
| 381 | 412 | } |
| 382 | 413 | for _, a := range addrs { |
| 383 | 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 | 423 | // Poller reads the configured mailbox every poll interval. |