mail: verify DKIM on reply mail !538
16 files changed, +1102 −44
Layout: unified · split
.gitbay/wiki/Admin.org +61 −15
| @@ -163,6 +163,7 @@ password_file = "/etc/gitbay/imap.pass" # one line, mode 0600, owned by gitbayd | ||
| 163 | 163 | mailbox = "INBOX" # default |
| 164 | 164 | poll_interval = "1m" # default; at least 10s |
| 165 | 165 | reply_address = "reply@gitbay.example" |
| 166 | require_dkim = true # recommended; see below | |
| 166 | 167 | trusted_authserv_id = "mx.example.org" # the mail host's Authentication-Results id |
| 167 | 168 | #+end_src |
| 168 | 169 | |
| @@ -192,9 +193,48 @@ trusted_authserv_id = "mx.example.org" # the mail host's Authentication-Result | ||
| 192 | 193 | server that sends more than about 11 MiB, or more than a thousand |
| 193 | 194 | untagged responses, for one command has its connection closed; one |
| 194 | 195 | poll handles at most ten thousand messages. |
| 195 | - =trusted_authserv_id= is required on any instance reachable from the | |
| 196 | internet. It names the authserv-id the mail host writes at the start | |
| 197 | of its =Authentication-Results= header (Gmail's is =mx.google.com=). | |
| 196 | - Whatever the settings, a message is refused when a header field name | |
| 197 | is not RFC 5322 =ftext= (=From : x=, a space or a non-ASCII byte in a | |
| 198 | name), when it does not have exactly one =From=, or when it has more | |
| 199 | than one =To=, =Cc=, =Message-ID=, =Content-Type= or | |
| 200 | =Content-Transfer-Encoding=. | |
| 201 | - =require_dkim= (default false) should be on for any instance | |
| 202 | reachable from the internet. With it, a reply is posted only when | |
| 203 | gitbayd itself verifies one of its DKIM signatures (RFC 6376) with a | |
| 204 | =d== in relaxed alignment with the From domain (the same | |
| 205 | organizational domain by the public suffix list; =d=github.io= aligns | |
| 206 | with nothing) and an =h== that covers =From=, the =To= or =Cc= holding | |
| 207 | the reply address, =Content-Type=, and =Message-ID= when the message | |
| 208 | has one. A =Content-Transfer-Encoding= outside =h== is accepted only | |
| 209 | when it is =7bit=, =8bit= or =binary=, which leave the decoded body as | |
| 210 | it is; Thunderbird, for one, does not sign it. An unsigned | |
| 211 | =quoted-printable= or =base64= is refused. The reply | |
| 212 | address is read only from =To= or =Cc=: a reply that reached the | |
| 213 | mailbox by Bcc, with the address only in =Delivered-To=, is refused | |
| 214 | ("reply address not in To or Cc"). It needs nothing from the mail | |
| 215 | host, so it works where the host adds no =Authentication-Results=. | |
| 216 | rsa-sha256 (keys of 1024 bits or more) and ed25519-sha256 are | |
| 217 | accepted, with simple or relaxed canonicalization; rsa-sha1, a body | |
| 218 | length tag (=l==), an expired =x== and a =t== more than fifteen | |
| 219 | minutes ahead are refused. Only the first five signatures are | |
| 220 | checked. The key is looked up at =<s>._domainkey.<d>= with a | |
| 221 | five-second timeout and cached for fifteen minutes (the resolver does | |
| 222 | not report the record's TTL); a lookup that fails for a reason that | |
| 223 | may pass (a timeout, SERVFAIL) leaves the message for the next poll, | |
| 224 | up to the five tries above, while a missing key refuses it. | |
| 225 | Signatures are checked on the message as fetched. Each passing | |
| 226 | signature is recorded with the =Message-ID=, so a copy of the same | |
| 227 | signed message posts once even with unsigned fields changed. | |
| 228 | - Either =require_dkim= or =trusted_authserv_id= passing is enough to | |
| 229 | authenticate =From=; when both are set, a reply needs only one of | |
| 230 | them, and a refusal names both reasons. With only | |
| 231 | =trusted_authserv_id=, the reply address may come from any recipient | |
| 232 | field (=Delivered-To=, =X-Original-To=, =Envelope-To=, =To=, =Cc=), | |
| 233 | since the mail host vouches for the sender and not for the fields. | |
| 234 | Set both when the mail host adds =Authentication-Results= for most | |
| 235 | senders but not all. | |
| 236 | - =trusted_authserv_id= names the authserv-id the mail host writes at | |
| 237 | the start of its =Authentication-Results= header (Gmail's is =mx.google.com=). | |
| 198 | 238 | With it set, a reply is posted only when the topmost header with that |
| 199 | 239 | id shows =dmarc=pass= with =header.from= equal to the From domain, or |
| 200 | 240 | =dkim=pass= with a =header.d= in relaxed alignment with it (the same |
| @@ -205,19 +245,24 @@ trusted_authserv_id = "mx.example.org" # the mail host's Authentication-Result | ||
| 205 | 245 | claiming the same id are the sender's and are not read. This is only safe when the mail host |
| 206 | 246 | removes incoming =Authentication-Results= headers that claim its id, |
| 207 | 247 | as RFC 8601 asks; Gmail, Fastmail and Migadu do. Check yours before |
| 208 | relying on it. Unset, the daemon logs a warning at start and =admin | |
| 209 | mail inbound check= repeats it: without it, =From= is whatever the | |
| 210 | sender wrote. Mail between two addresses at the same host may carry | |
| 211 | no =Authentication-Results= at all: at Migadu, mail from another | |
| 248 | relying on it. With neither this nor =require_dkim= set, the daemon | |
| 249 | logs a warning at start and =admin mail inbound check= repeats it: | |
| 250 | =From= is then whatever the sender wrote. Mail between two addresses | |
| 251 | at the same host may carry no =Authentication-Results= at all: at | |
| 252 | Migadu, mail from another | |
| 212 | 253 | Migadu-hosted domain is delivered through its outbound path and gets |
| 213 | none, so setting the id refuses every reply from such users. | |
| 214 | gitbay.org runs without it for that reason until gitbayd verifies | |
| 215 | DKIM itself (#307). | |
| 254 | none, so setting the id alone refuses every reply from such users. | |
| 255 | That mail does carry a DKIM signature aligned with =From= (for | |
| 256 | example =d=cleberg.net; s=key1; a=rsa-sha256; c=simple/simple; | |
| 257 | h=from:to:subject:date:message-id:mime-version:content-type=), which | |
| 258 | is why gitbay.org sets =require_dkim= instead. | |
| 216 | 259 | - =gitbay admin mail inbound check= logs in, opens the mailbox |
| 217 | read-only (EXAMINE) and reports the message and unseen counts, so a | |
| 218 | check never marks a reply seen before the poller reads it. With | |
| 219 | inbound off it says so and exits 0. Poll failures are logged as | |
| 220 | =mail reply: poll failed= with the server and the IMAP error. | |
| 260 | read-only (EXAMINE) and reports the message and unseen counts and how | |
| 261 | =From= is authenticated (=require_dkim=, =trusted_authserv_id=), so a | |
| 262 | check never marks a reply seen before the poller reads it. It warns | |
| 263 | when neither is set. With inbound off it says so and exits 0. Poll | |
| 264 | failures are logged as =mail reply: poll failed= with the server and | |
| 265 | the IMAP error. | |
| 221 | 266 | |
| 222 | 267 | A reply is posted when all of these hold, checked when it is read: |
| 223 | 268 | |
| @@ -230,7 +275,8 @@ A reply is posted when all of these hold, checked when it is read: | ||
| 230 | 275 | by a delete and reused is not the account the token named. The same |
| 231 | 276 | holds for the repository. |
| 232 | 277 | 4. =From= is one of that account's verified addresses, and, with |
| 233 | =trusted_authserv_id= set, the mail host authenticated it. | |
| 278 | =require_dkim= or =trusted_authserv_id= set, a DKIM signature | |
| 279 | gitbayd verified or the mail host's result authenticated it. | |
| 234 | 280 | 5. The message has a =text/plain= part (HTML-only mail is refused, not |
| 235 | 281 | converted), and what is left after quoted text and the signature |
| 236 | 282 | are removed is not empty and fits a comment (64 KiB). |
.gitbay/wiki/Architecture/03-Deployment.org +1
| @@ -62,6 +62,7 @@ a database check. | ||
| 62 | 62 | | SMTP relay | queued mail | STARTTLS required for a non-local relay, or implicit TLS | =mail.require_tls=; Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) | |
| 63 | 63 | | APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key | |
| 64 | 64 | | IMAP server | =mail.inbound.poll_interval= | implicit TLS or STARTTLS, certificate verified; no plaintext setting | operator-configured host only (=internal/imapc=) | |
| 65 | | DNS resolver | =mail.inbound.require_dkim=, a reply that passed the token and address checks | no; the system resolver, no DNSSEC validation in gitbayd | name is =<s>._domainkey.<d>= from the signature; five-second timeout, fifteen-minute cache of at most 256 keys, first five signatures only (=internal/mailin/dkim.go=) | | |
| 65 | 66 | | Webhook URLs | recorded events | yes when https; certificate verified | private, shared, loopback, link-local and multicast targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go=) | |
| 66 | 67 | | Mirror URLs | mirror schedule | per URL | address check at save and before each sync; git pinned to the checked addresses, no redirects (=internal/mirror/mirror.go=) | |
| 67 | 68 | | Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) | |
.gitbay/wiki/Threat-Model.org +35 −3
| @@ -124,9 +124,41 @@ things are required together, because neither is enough alone: | ||
| 124 | 124 | mail host echoes into its header (a quoted MAIL FROM local part, a |
| 125 | 125 | reason) cannot read as a result; =FuzzAuthResults= checks that. That rests on |
| 126 | 126 | the mail host removing incoming headers that claim its id (RFC 8601 |
| 127 | §5); Gmail, Fastmail and Migadu do. Unset, the daemon warns at start, | |
| 128 | and a leaked token plus a forged From posts. The Admin page calls it | |
| 129 | required for any exposed instance. | |
| 127 | §5); Gmail, Fastmail and Migadu do. With =require_dkim= set, gitbayd | |
| 128 | verifies the message's DKIM signatures itself, on the bytes as | |
| 129 | fetched, and requires one whose =d= has the same organizational | |
| 130 | domain as From; this depends on the sender's domain signing, not on | |
| 131 | the mail host, and is what an instance whose host adds no | |
| 132 | =Authentication-Results= for some senders (gitbay.org, at Migadu) | |
| 133 | relies on. The signature's =h= must cover From, the To or Cc the | |
| 134 | reply address is read from (a signed message cannot be redirected to | |
| 135 | another token by adding an unsigned Cc or by Bcc), Content-Type, and | |
| 136 | Message-ID when present. An unsigned Content-Transfer-Encoding is | |
| 137 | accepted only as 7bit, 8bit or binary, identity encodings, so adding | |
| 138 | one cannot change what the signed body decodes to; the encodings in | |
| 139 | MIME parts are inside the body and covered by =bh=. Header field | |
| 140 | names are checked on the raw header before anything reads it: a name | |
| 141 | outside RFC 5322 =ftext= such as =From : x= is one net/mail and the | |
| 142 | DKIM verifier would file under different names, so a forged From | |
| 143 | could be read while the signature covers another; such a message is | |
| 144 | refused, as is one without exactly one From or with a repeated To, | |
| 145 | Cc, Message-ID, Content-Type or Content-Transfer-Encoding. Signatures | |
| 146 | with a body length tag are refused, since content appended after the | |
| 147 | signed length would verify, as are rsa-sha1, keys under 1024 bits and | |
| 148 | expired signatures. Each passing signature's =b= is recorded with the | |
| 149 | Message-ID, so a replayed copy does not post twice. Keys are cached | |
| 150 | for fifteen minutes, so a revoked key is still honoured for up to | |
| 151 | that long. A DNS failure that may pass | |
| 152 | delays the reply rather than refusing it; the key lookup is bounded by | |
| 153 | a five-second timeout and only the first five signatures are checked, | |
| 154 | so a message cannot make gitbayd wait on many lookups. The key comes | |
| 155 | from the system resolver and gitbayd does not validate DNSSEC itself; | |
| 156 | whoever can forge the resolver's answers can forge the key. With both | |
| 157 | set, either passing is enough; with only =trusted_authserv_id=, the | |
| 158 | reply address may come from any recipient field, as the mail host | |
| 159 | vouches for the sender and not for the fields. With neither, the daemon warns at start, and | |
| 160 | a leaked token plus a forged From posts. The Admin page recommends | |
| 161 | =require_dkim= for any exposed instance. | |
| 130 | 162 | - *Reused ids.* Account and repository ids are reused after a hard |
| 131 | 163 | delete. A reply is refused when the account or repository was |
| 132 | 164 | created after its token was minted, so a token cannot post into a |
CHANGELOG.org +20
| @@ -4,6 +4,26 @@ Versioning follows semver from v0.1.0. Database migrations run | ||
| 4 | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | 5 | anything beyond "replace the binary and restart" is needed. |
| 6 | 6 | |
| 7 | * Unreleased | |
| 8 | ||
| 9 | - =[mail.inbound] require_dkim= makes gitbayd verify a reply's DKIM | |
| 10 | signature itself: one of the first five signatures must verify | |
| 11 | (rsa-sha256 with a key of 1024 bits or more, or ed25519-sha256), have | |
| 12 | a =d== in relaxed alignment with the From domain, and cover From, the | |
| 13 | To or Cc holding the reply address, Content-Type, and Message-ID when | |
| 14 | present; an unsigned Content-Transfer-Encoding must be 7bit, 8bit or | |
| 15 | binary. The reply address is then | |
| 16 | read only from To or Cc. =l==, rsa-sha1, an expired =x== and a =t== | |
| 17 | in the future are refused; a temporary DNS failure retries on the | |
| 18 | next poll; keys are cached for fifteen minutes; a passing signature | |
| 19 | is recorded so a copy posts once. Off by default; either it or | |
| 20 | =trusted_authserv_id= passing is enough. The start-up warning and | |
| 21 | =admin mail inbound check= now warn only when neither is set, and the | |
| 22 | check reports both settings. (#307) | |
| 23 | - A reply whose header has a field name outside RFC 5322 =ftext= | |
| 24 | (=From : x=), no single From, or a repeated To, Cc, Message-ID, | |
| 25 | Content-Type or Content-Transfer-Encoding is refused. (#307) | |
| 26 | ||
| 7 | 27 | * v1.39.0 — 2026-09-29 |
| 8 | 28 | |
| 9 | 29 | Suggested changes, split diffs, reactions, saved |
cmd/gitbayd/main.go +2 −2
| @@ -213,8 +213,8 @@ func serveCmd() *cobra.Command { | ||
| 213 | 213 | if _, err := in.Password(); err != nil { |
| 214 | 214 | return err |
| 215 | 215 | } |
| 216 | if in.TrustedAuthservID == "" { | |
| 217 | slog.Warn("mail reply: [mail.inbound] trusted_authserv_id is unset, so a reply's From is not checked against the mail host's DMARC and DKIM results; set it on any instance reachable from the internet") | |
| 216 | if !in.Authenticated() { | |
| 217 | slog.Warn("mail reply: [mail.inbound] require_dkim and trusted_authserv_id are unset, so a reply's From is not authenticated; set one on any instance reachable from the internet") | |
| 218 | 218 | } |
| 219 | 219 | go (&mailin.Poller{P: &mailin.Processor{St: st, Cfg: cfg}, In: in}).Run(whCtx) |
| 220 | 220 | } |
deploy/audit.sh +1
| @@ -18,6 +18,7 @@ go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/ | ||
| 18 | 18 | go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/ |
| 19 | 19 | go test -run xxx -fuzz FuzzReply -fuzztime 10s ./internal/mailin/ |
| 20 | 20 | go test -run xxx -fuzz FuzzAuthResults -fuzztime 10s ./internal/mailin/ |
| 21 | go test -run xxx -fuzz FuzzDKIM -fuzztime 10s ./internal/mailin/ | |
| 21 | 22 | go test -run xxx -fuzz FuzzReadResponse -fuzztime 10s ./internal/imapc/ |
| 22 | 23 | |
| 23 | 24 | echo "== all clear ==" |
go.mod +1
| @@ -7,6 +7,7 @@ require ( | ||
| 7 | 7 | github.com/BurntSushi/toml v1.6.0 |
| 8 | 8 | github.com/ProtonMail/go-crypto v1.5.2 |
| 9 | 9 | github.com/alecthomas/chroma/v2 v2.27.0 |
| 10 | github.com/emersion/go-msgauth v0.7.0 | |
| 10 | 11 | github.com/microcosm-cc/bluemonday v1.0.27 |
| 11 | 12 | github.com/niklasfasching/go-org v1.9.1 |
| 12 | 13 | github.com/spf13/cobra v1.10.2 |
go.sum +2
| @@ -30,6 +30,8 @@ github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee | ||
| 30 | 30 | github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= |
| 31 | 31 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| 32 | 32 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| 33 | github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc= | |
| 34 | github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck= | |
| 33 | 35 | github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= |
| 34 | 36 | github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= |
| 35 | 37 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
internal/config/config.go +10
| @@ -302,6 +302,16 @@ type MailInbound struct { | ||
| 302 | 302 | // pass, or an aligned DKIM pass, in the topmost such header. Only |
| 303 | 303 | // safe when the mail host removes incoming headers claiming its id. |
| 304 | 304 | TrustedAuthservID string `toml:"trusted_authserv_id"` |
| 305 | // RequireDKIM makes a reply need a DKIM signature, verified by | |
| 306 | // gitbayd, that covers From and whose d= is in relaxed alignment | |
| 307 | // with the From domain. With TrustedAuthservID also set, either | |
| 308 | // passing is enough. | |
| 309 | RequireDKIM bool `toml:"require_dkim"` | |
| 310 | } | |
| 311 | ||
| 312 | // Authenticated reports whether a reply's From is checked at all. | |
| 313 | func (m MailInbound) Authenticated() bool { | |
| 314 | return m.TrustedAuthservID != "" || m.RequireDKIM | |
| 305 | 315 | } |
| 306 | 316 | |
| 307 | 317 | // DefaultInboundPoll is the poll interval when poll_interval is unset. |
internal/config/config_test.go +7
| @@ -423,6 +423,13 @@ func TestMailInbound(t *testing.T) { | ||
| 423 | 423 | if in.Addr() != "imap.example:993" || in.MailboxName() != "INBOX" || in.Poll() != DefaultInboundPoll { |
| 424 | 424 | t.Fatalf("defaults: %q %q %v", in.Addr(), in.MailboxName(), in.Poll()) |
| 425 | 425 | } |
| 426 | if in.RequireDKIM || in.Authenticated() { | |
| 427 | t.Fatalf("require_dkim defaults on or From counts as authenticated: %+v", in) | |
| 428 | } | |
| 429 | cfg, err = Load(writeConfig(t, minimal+smtp+inbound+"require_dkim = true\n")) | |
| 430 | if err != nil || !cfg.Mail.Inbound.RequireDKIM || !cfg.Mail.Inbound.Authenticated() { | |
| 431 | t.Fatalf("require_dkim: %+v, %v", cfg.Mail.Inbound, err) | |
| 432 | } | |
| 426 | 433 | in.TLS = "starttls" |
| 427 | 434 | if in.Addr() != "imap.example:143" { |
| 428 | 435 | t.Fatalf("starttls default port: %q", in.Addr()) |
internal/control/adminmail.go +11 −4
| @@ -17,7 +17,7 @@ func init() { | ||
| 17 | 17 | ReadOnly: true, Run: runAdminMailInboundCheck}) |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | const unauthenticatedWarning = "trusted_authserv_id is unset: a reply's From is not checked against the mail host's DMARC and DKIM results" | |
| 20 | const unauthenticatedWarning = "require_dkim and trusted_authserv_id are unset: a reply's From is not authenticated" | |
| 21 | 21 | |
| 22 | 22 | // runAdminMailInboundCheck logs in to the [mail.inbound] mailbox and |
| 23 | 23 | // opens it with EXAMINE, which changes no flag, so a check never marks a |
| @@ -36,7 +36,11 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int { | ||
| 36 | 36 | Mailbox string `json:"mailbox,omitempty"` |
| 37 | 37 | Messages int `json:"messages"` |
| 38 | 38 | Unseen int `json:"unseen"` |
| 39 | Warning string `json:"warning,omitempty"` | |
| 39 | // RequireDKIM and TrustedAuthservID are how a reply's From | |
| 40 | // is authenticated. | |
| 41 | RequireDKIM bool `json:"require_dkim"` | |
| 42 | TrustedAuthservID string `json:"trusted_authserv_id,omitempty"` | |
| 43 | Warning string `json:"warning,omitempty"` | |
| 40 | 44 | } |
| 41 | 45 | if !in.Enabled { |
| 42 | 46 | return c.emit(out{}, func(w io.Writer) { |
| @@ -52,8 +56,9 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int { | ||
| 52 | 56 | if err != nil { |
| 53 | 57 | return c.fail(protocol.ExitFailure, "%s: %v", in.Addr(), err) |
| 54 | 58 | } |
| 55 | d := out{Enabled: true, Server: in.Addr(), Mailbox: in.MailboxName(), Messages: n, Unseen: len(unseen)} | |
| 56 | if in.TrustedAuthservID == "" { | |
| 59 | d := out{Enabled: true, Server: in.Addr(), Mailbox: in.MailboxName(), Messages: n, Unseen: len(unseen), | |
| 60 | RequireDKIM: in.RequireDKIM, TrustedAuthservID: in.TrustedAuthservID} | |
| 61 | if !in.Authenticated() { | |
| 57 | 62 | d.Warning = unauthenticatedWarning |
| 58 | 63 | fmt.Fprintln(c.Stderr, "warning: "+d.Warning) |
| 59 | 64 | } |
| @@ -63,6 +68,8 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int { | ||
| 63 | 68 | "mailbox", d.Mailbox, |
| 64 | 69 | "messages", fmt.Sprintf("%d", d.Messages), |
| 65 | 70 | "unseen", fmt.Sprintf("%d", d.Unseen), |
| 71 | "require_dkim", fmt.Sprintf("%t", d.RequireDKIM), | |
| 72 | "trusted_authserv_id", d.TrustedAuthservID, | |
| 66 | 73 | ) |
| 67 | 74 | }) |
| 68 | 75 | } |
internal/mailin/dkim.go added +202
| @@ -0,0 +1,202 @@ | ||
| 1 | package mailin | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "context" | |
| 6 | "crypto/sha256" | |
| 7 | "encoding/hex" | |
| 8 | "errors" | |
| 9 | "net" | |
| 10 | "strings" | |
| 11 | "sync" | |
| 12 | "time" | |
| 13 | ||
| 14 | "github.com/emersion/go-msgauth/dkim" | |
| 15 | ) | |
| 16 | ||
| 17 | const ( | |
| 18 | // maxSignatures is how many DKIM-Signature fields are checked; any | |
| 19 | // after them are ignored. | |
| 20 | maxSignatures = 5 | |
| 21 | // dnsTimeout bounds one selector key lookup. | |
| 22 | dnsTimeout = 5 * time.Second | |
| 23 | // futureSkew is how far ahead of this clock a signature's t= may be. | |
| 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 | |
| 29 | keyCacheSize = 256 | |
| 30 | ) | |
| 31 | ||
| 32 | // LookupTXT returns the TXT records at name, one string per record. | |
| 33 | type LookupTXT func(ctx context.Context, name string) ([]string, error) | |
| 34 | ||
| 35 | type cachedKey struct { | |
| 36 | txts []string | |
| 37 | expires time.Time | |
| 38 | } | |
| 39 | ||
| 40 | // keyCache holds selector key records that resolved, for keyCacheTTL, | |
| 41 | // at most keyCacheSize of them. Failures are not cached. | |
| 42 | type keyCache struct { | |
| 43 | mu sync.Mutex | |
| 44 | m map[string]cachedKey | |
| 45 | } | |
| 46 | ||
| 47 | func (c *keyCache) get(name string, now time.Time) ([]string, bool) { | |
| 48 | c.mu.Lock() | |
| 49 | defer c.mu.Unlock() | |
| 50 | e, ok := c.m[name] | |
| 51 | if !ok || now.After(e.expires) { | |
| 52 | return nil, false | |
| 53 | } | |
| 54 | return e.txts, true | |
| 55 | } | |
| 56 | ||
| 57 | func (c *keyCache) put(name string, txts []string, now time.Time) { | |
| 58 | c.mu.Lock() | |
| 59 | defer c.mu.Unlock() | |
| 60 | if c.m == nil { | |
| 61 | c.m = map[string]cachedKey{} | |
| 62 | } | |
| 63 | if len(c.m) >= keyCacheSize { | |
| 64 | for k, e := range c.m { | |
| 65 | if now.After(e.expires) { | |
| 66 | delete(c.m, k) | |
| 67 | } | |
| 68 | } | |
| 69 | for k := range c.m { | |
| 70 | if len(c.m) < keyCacheSize { | |
| 71 | break | |
| 72 | } | |
| 73 | delete(c.m, k) | |
| 74 | } | |
| 75 | } | |
| 76 | c.m[name] = cachedKey{txts: txts, expires: now.Add(keyCacheTTL)} | |
| 77 | } | |
| 78 | ||
| 79 | // lookupKey is the verifier's TXT lookup: cached, bounded by dnsTimeout. | |
| 80 | // The dkim package tells a temporary failure from a permanent one by | |
| 81 | // the error implementing net.Error with Temporary true, so every error | |
| 82 | // returned is a *net.DNSError, and one that is not a plain "no such | |
| 83 | // record" is marked temporary. | |
| 84 | func (p *Processor) lookupKey(name string) ([]string, error) { | |
| 85 | now := p.now() | |
| 86 | if txts, ok := p.keys.get(name, now); ok { | |
| 87 | return txts, nil | |
| 88 | } | |
| 89 | lookup := p.LookupTXT | |
| 90 | if lookup == nil { | |
| 91 | lookup = net.DefaultResolver.LookupTXT | |
| 92 | } | |
| 93 | ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout) | |
| 94 | defer cancel() | |
| 95 | txts, err := lookup(ctx, name) | |
| 96 | if err != nil { | |
| 97 | var de *net.DNSError | |
| 98 | if errors.As(err, &de) && de.IsNotFound { | |
| 99 | return nil, &net.DNSError{Err: "no such record", Name: name, IsNotFound: true} | |
| 100 | } | |
| 101 | return nil, &net.DNSError{Err: "lookup failed", Name: name, IsTemporary: true} | |
| 102 | } | |
| 103 | p.keys.put(name, txts, now) | |
| 104 | return txts, nil | |
| 105 | } | |
| 106 | ||
| 107 | // dkimVerified checks the DKIM signatures on raw, the message as it | |
| 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:]) | |
| 123 | } | |
| 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 | |
| 136 | } | |
| 137 | verifs, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{ | |
| 138 | LookupTXT: p.lookupKey, MaxVerifications: maxSignatures}) | |
| 139 | if err != nil && !errors.Is(err, dkim.ErrTooManySignatures) { | |
| 140 | return nil, "DKIM: unreadable message", false | |
| 141 | } | |
| 142 | if len(verifs) == 0 { | |
| 143 | return nil, "no DKIM-Signature", false | |
| 144 | } | |
| 145 | now := p.now() | |
| 146 | var fails []string | |
| 147 | for i, v := range verifs { | |
| 148 | d := strings.ToLower(v.Domain) | |
| 149 | why := "" | |
| 150 | switch { | |
| 151 | case dkim.IsTempFail(v.Err): | |
| 152 | retry = true | |
| 153 | why = "key lookup failed" | |
| 154 | case v.Err != nil: | |
| 155 | why = strings.TrimPrefix(v.Err.Error(), "dkim: ") | |
| 156 | case !v.Expiration.IsZero() && now.After(v.Expiration): | |
| 157 | why = "signature has expired" | |
| 158 | case !v.Time.IsZero() && v.Time.After(now.Add(futureSkew)): | |
| 159 | why = "signature dated in the future" | |
| 160 | case !aligned(d, fromDomain): | |
| 161 | why = "d= not aligned with the From domain" | |
| 162 | default: | |
| 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 | } | |
| 174 | } | |
| 175 | if len(d) > 100 { | |
| 176 | d = d[:100] | |
| 177 | } | |
| 178 | fails = append(fails, "d="+d+": "+why) | |
| 179 | } | |
| 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 | |
| 184 | } | |
| 185 | ||
| 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 | |
| 199 | } | |
| 200 | } | |
| 201 | return "" | |
| 202 | } | |
internal/mailin/dkim_test.go added +524
| @@ -0,0 +1,524 @@ | ||
| 1 | package mailin | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "context" | |
| 6 | "crypto" | |
| 7 | "crypto/ed25519" | |
| 8 | "crypto/rsa" | |
| 9 | "crypto/x509" | |
| 10 | "encoding/base64" | |
| 11 | "encoding/pem" | |
| 12 | "errors" | |
| 13 | "math/big" | |
| 14 | "net" | |
| 15 | "strings" | |
| 16 | "testing" | |
| 17 | "time" | |
| 18 | ||
| 19 | "github.com/emersion/go-msgauth/dkim" | |
| 20 | ||
| 21 | "gitbay.org/gitbay/internal/mailreply" | |
| 22 | ) | |
| 23 | ||
| 24 | // Fixed test keys: RSA 2048 and Ed25519. | |
| 25 | const rsaPEM = `-----BEGIN PRIVATE KEY----- | |
| 26 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC6i925olmivu5U | |
| 27 | iNMbgSLK4SEZNq4TIPQYPwJ3fHHFa+6V3jtD/2HllkZCrRlBAvra7H8q7TfoCj2K | |
| 28 | lN7hLGkawZM0x9qhxn+AUKuTTL3XeRdl3HQejfuuQvhAQkBU53lF2v0mqzVAEz/k | |
| 29 | cxcNT068yWeCT4GWyT4/A1yy1lfTzyZXrKNcdEDQ5ah8M47kaEYeeRAza19roV3F | |
| 30 | StAggPG/ORC64JXQkwbIEOgNNUYp7hUUWen+kKd5qsuoxaYaWGJ/cEHeEFzsaZN2 | |
| 31 | fA//qFAt3jwfbQvnpPzp1c6txkiDnYJgHXp5gRcdDuZ8q85bmeqZf1OBCR2d7Ery | |
| 32 | q1L1sFdRAgMBAAECggEADoc2DW8HbBVSmmLNjibQftxpp30KsZKvb/P4TTXz5lwx | |
| 33 | iJp2IyWQikDZ1/eDL/z7bHFetgkjgX7KrDBL6116EgthW4r1DARZibS+qAoh/tX/ | |
| 34 | bH9uy7JjF38/tkFyoSol17rmXEyZKRRWtYQBF5hFmY5V8WAfx46EuoOYhJUM4gHt | |
| 35 | 3hN6xqvGEjLTgb1xLf08+ntgTzPW3QSiE3A0b0nyRbu8t8PjP0DoABdORuI16hsi | |
| 36 | 8V1wUoz1iTKGtV1a98moSxyWWC0udBp+tdhZJ+yun5zLqd/cALvXsl8Y7cIIuyLa | |
| 37 | B/mLTkM1SX9swZ06tSa4vRd9fPQz0J5zbgo8Mf3FwQKBgQDH7SuSZ3YpTMhoAmz/ | |
| 38 | V6UVN4NNkYkHqNEB3bv0VRG4bPtLXJH9vyhchtwHQMsKTwbqgsFiUON2VwZ0/pfc | |
| 39 | 0hDooWQHaoVJWRjfZQLD5K42QKQlcDowyvMYT046UViJOSQeTj4IbT0/2EehkHXA | |
| 40 | qCoITU2I6zIfF1Te8yI1wFmdTwKBgQDu3f9rIZq+ihaLrrGMkagofWmVKUzFqVwK | |
| 41 | ckcKXZJ0uQ/ns1er+SITVU3WdOrLsFE+zpE5CH8LG9FIoFhNcCwvzCXV5iACqIi8 | |
| 42 | 4PgkxNDYTo9kMW3yWuWiFcZpLvA7BOCnvW4gmmIofLe1OMBE9F5VquECibeoamPb | |
| 43 | uQgzELnZXwKBgB24BbgXpRryjP/ZDHbQgnuq6tvG/IWk9JzAZ0YktyOhH6HOOu1r | |
| 44 | UwaeDWsOmKAJq0+E7FY/C/D1csJFbjGnEFhkVUg871893VKn40dXYQYzibL/Acdr | |
| 45 | A8PjVg+ZM/4B/np6ywHZqzcoYU2E+dwPo1/kjdgCjkrM3xLdNYKj+y5FAoGAOJaz | |
| 46 | OggeBuHj8XeTbH/dXKpJZyL/oxw6R+dG2TfNyIVHNVcRgBZncjkVVachMNw2gzCg | |
| 47 | yuguYM1YSWJjSQU4EqLEm+YG01pl+ok5gEx4RaZm5g+nwnCyUjHibWzHUNQY/OQt | |
| 48 | wN+SPZE+XFpzgmJ6LsVqxRUnQ2jg+17ciGx/+vUCgYArCRxfa4ecYlZQYTvtbpwY | |
| 49 | pLt6iUht7y69jkwSUTkOn+ZjBDcVWrJwfjt8R9BkMB/2rcwUj4Yo9DcgiWkfHSpM | |
| 50 | W5QuMVb8coft4mC7G37mEoWWdNrc0TLkbfTSr+liNXoWp0QGL7/RQO7HHK+9QVD0 | |
| 51 | FfatkTVO1YuBsyGp9eE5rQ== | |
| 52 | -----END PRIVATE KEY-----` | |
| 53 | ||
| 54 | const edPEM = `-----BEGIN PRIVATE KEY----- | |
| 55 | MC4CAQAwBQYDK2VwBCIEICVufJC+iEzea5y5QlUD39QNmX2n/0c93QCcQrfQH8W8 | |
| 56 | -----END PRIVATE KEY-----` | |
| 57 | ||
| 58 | var rsaKey, edKey = parseKey(rsaPEM), parseKey(edPEM) | |
| 59 | ||
| 60 | func parseKey(s string) crypto.Signer { | |
| 61 | b, _ := pem.Decode([]byte(s)) | |
| 62 | k, err := x509.ParsePKCS8PrivateKey(b.Bytes) | |
| 63 | if err != nil { | |
| 64 | panic(err) | |
| 65 | } | |
| 66 | return k.(crypto.Signer) | |
| 67 | } | |
| 68 | ||
| 69 | func keyRecord(pub crypto.PublicKey) string { | |
| 70 | switch k := pub.(type) { | |
| 71 | case ed25519.PublicKey: | |
| 72 | return "v=DKIM1; k=ed25519; p=" + base64.StdEncoding.EncodeToString(k) | |
| 73 | default: | |
| 74 | b, err := x509.MarshalPKIXPublicKey(k) | |
| 75 | if err != nil { | |
| 76 | panic(err) | |
| 77 | } | |
| 78 | return "v=DKIM1; k=rsa; p=" + base64.StdEncoding.EncodeToString(b) | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | // fakeDNS answers by selector whatever the domain: rsa, ed and key1 are | |
| 83 | // the fixed keys, short is a 512-bit RSA key, temp fails temporarily, | |
| 84 | // and anything else does not exist. | |
| 85 | type fakeDNS struct{ lookups int } | |
| 86 | ||
| 87 | func (d *fakeDNS) lookup(_ context.Context, name string) ([]string, error) { | |
| 88 | d.lookups++ | |
| 89 | sel, _, _ := strings.Cut(name, ".") | |
| 90 | switch sel { | |
| 91 | case "rsa", "key1": | |
| 92 | return []string{keyRecord(rsaKey.Public())}, nil | |
| 93 | case "ed": | |
| 94 | return []string{keyRecord(edKey.Public())}, nil | |
| 95 | case "short": | |
| 96 | n := new(big.Int).Lsh(big.NewInt(1), 511) | |
| 97 | n.Add(n, big.NewInt(0x2f)) | |
| 98 | return []string{keyRecord(&rsa.PublicKey{N: n, E: 65537})}, nil | |
| 99 | case "temp": | |
| 100 | return nil, &net.DNSError{Err: "server misbehaving", Name: name, IsTemporary: true} | |
| 101 | case "timeout": | |
| 102 | return nil, context.DeadlineExceeded | |
| 103 | } | |
| 104 | return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} | |
| 105 | } | |
| 106 | ||
| 107 | var exampleKeys = []string{"from", "to", "subject", "date", "message-id", "mime-version", "content-type"} | |
| 108 | ||
| 109 | func signMsg(t *testing.T, msg, domain, selector string, key crypto.Signer, canon dkim.Canonicalization, mod func(*dkim.SignOptions)) string { | |
| 110 | t.Helper() | |
| 111 | o := dkim.SignOptions{Domain: domain, Selector: selector, Signer: key, | |
| 112 | HeaderCanonicalization: canon, BodyCanonicalization: canon, HeaderKeys: exampleKeys} | |
| 113 | if mod != nil { | |
| 114 | mod(&o) | |
| 115 | } | |
| 116 | var b bytes.Buffer | |
| 117 | if err := dkim.Sign(&b, strings.NewReader(msg), &o); err != nil { | |
| 118 | t.Fatal(err) | |
| 119 | } | |
| 120 | return b.String() | |
| 121 | } | |
| 122 | ||
| 123 | func dkimSetup(t *testing.T) (*fixture, *fakeDNS) { | |
| 124 | t.Helper() | |
| 125 | f := setup(t) | |
| 126 | dns := &fakeDNS{} | |
| 127 | f.p.LookupTXT = dns.lookup | |
| 128 | f.p.Cfg.Mail.Inbound.RequireDKIM = true | |
| 129 | for _, a := range []string{"bob@cleberg.net", "bob@mail.example.test", "bob@user.github.io"} { | |
| 130 | if err := f.st.AddEmail(f.bob, a, "admin", true); err != nil { | |
| 131 | t.Fatal(err) | |
| 132 | } | |
| 133 | } | |
| 134 | return f, dns | |
| 135 | } | |
| 136 | ||
| 137 | func TestDKIM(t *testing.T) { | |
| 138 | type tc struct { | |
| 139 | name, from, domain, selector string | |
| 140 | key crypto.Signer | |
| 141 | canon dkim.Canonicalization | |
| 142 | mod func(*dkim.SignOptions) | |
| 143 | after func(string) string // applied to the signed message | |
| 144 | reason string // "" posts | |
| 145 | retry bool | |
| 146 | } | |
| 147 | var relaxed, simple dkim.Canonicalization = dkim.CanonicalizationRelaxed, dkim.CanonicalizationSimple | |
| 148 | for _, c := range []tc{ | |
| 149 | {name: "rsa relaxed", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed}, | |
| 150 | {name: "rsa simple", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: simple}, | |
| 151 | {name: "ed25519 relaxed", from: "bob@example.test", domain: "example.test", selector: "ed", key: edKey, canon: relaxed}, | |
| 152 | {name: "ed25519 simple", from: "bob@example.test", domain: "example.test", selector: "ed", key: edKey, canon: simple}, | |
| 153 | {name: "the shape Migadu sends", from: "bob@cleberg.net", domain: "cleberg.net", selector: "key1", key: rsaKey, canon: simple, | |
| 154 | after: func(m string) string { | |
| 155 | if !strings.Contains(m, "d=cleberg.net;") || !strings.Contains(m, "s=key1;") || !strings.Contains(m, "c=simple/simple;") || | |
| 156 | !strings.Contains(m, "h=from:to:subject:date:message-id:mime-version:content-type;") || !strings.Contains(m, "a=rsa-sha256;") { | |
| 157 | panic("signature not in the expected shape:\n" + m) | |
| 158 | } | |
| 159 | return m | |
| 160 | }}, | |
| 161 | {name: "signing domain a subdomain of From's", from: "bob@example.test", domain: "mail.example.test", selector: "rsa", key: rsaKey, canon: relaxed}, | |
| 162 | {name: "From a subdomain of the signing domain", from: "bob@mail.example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed}, | |
| 163 | {name: "body altered", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed, | |
| 164 | after: func(m string) string { return m + "appended\r\n" }, reason: "body hash did not verify"}, | |
| 165 | {name: "header altered", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed, | |
| 166 | after: func(m string) string { return strings.Replace(m, "Subject: Re:", "Subject: Fwd:", 1) }, reason: "signature did not verify"}, | |
| 167 | {name: "From not in h=", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed, | |
| 168 | after: func(m string) string { return strings.Replace(m, "h=from:to:", "h=to:", 1) }, reason: "From field not signed"}, | |
| 169 | {name: "misaligned d=", from: "bob@example.test", domain: "attacker.example", selector: "rsa", key: rsaKey, canon: relaxed, | |
| 170 | reason: "d=attacker.example: d= not aligned"}, | |
| 171 | {name: "public suffix d=", from: "bob@user.github.io", domain: "github.io", selector: "rsa", key: rsaKey, canon: relaxed, | |
| 172 | reason: "d=github.io: d= not aligned"}, | |
| 173 | {name: "expired x=", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed, | |
| 174 | mod: func(o *dkim.SignOptions) { o.Expiration = time.Now().Add(-time.Minute) }, reason: "expired"}, | |
| 175 | {name: "l= refused", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed, | |
| 176 | after: func(m string) string { return strings.Replace(m, "DKIM-Signature: ", "DKIM-Signature: l=4; ", 1) }, | |
| 177 | reason: "body length"}, | |
| 178 | {name: "rsa-sha1 refused", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed, | |
| 179 | after: func(m string) string { return strings.Replace(m, "a=rsa-sha256", "a=rsa-sha1", 1) }, reason: "too weak"}, | |
| 180 | {name: "512-bit key refused", from: "bob@example.test", domain: "example.test", selector: "short", key: rsaKey, canon: relaxed, | |
| 181 | reason: "too short"}, | |
| 182 | {name: "no key", from: "bob@example.test", domain: "example.test", selector: "gone", key: rsaKey, canon: relaxed, | |
| 183 | reason: "no key for signature"}, | |
| 184 | {name: "second From field", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed, | |
| 185 | after: func(m string) string { return "From: bob@example.test\r\n" + m }, reason: "more than one From field"}, | |
| 186 | {name: "DNS temporary failure", from: "bob@example.test", domain: "example.test", selector: "temp", key: rsaKey, canon: relaxed, | |
| 187 | reason: "key lookup failed", retry: true}, | |
| 188 | {name: "DNS timeout", from: "bob@example.test", domain: "example.test", selector: "timeout", key: rsaKey, canon: relaxed, | |
| 189 | reason: "key lookup failed", retry: true}, | |
| 190 | } { | |
| 191 | t.Run(c.name, func(t *testing.T) { | |
| 192 | f, _ := dkimSetup(t) | |
| 193 | m := signMsg(t, f.messageAs(t, f.bob, c.from, "<d@x>", "", "hi"), c.domain, c.selector, c.key, c.canon, c.mod) | |
| 194 | if c.after != nil { | |
| 195 | m = c.after(m) | |
| 196 | } | |
| 197 | res := f.p.Handle([]byte(m)) | |
| 198 | if c.reason == "" { | |
| 199 | if !res.Posted { | |
| 200 | t.Fatalf("not posted: %+v", res) | |
| 201 | } | |
| 202 | return | |
| 203 | } | |
| 204 | if res.Posted || res.Retry != c.retry || !strings.Contains(res.Reason, c.reason) { | |
| 205 | t.Fatalf("result %+v, want reason %q retry %v", res, c.reason, c.retry) | |
| 206 | } | |
| 207 | audit := f.refusalReasons(t) | |
| 208 | if c.retry { | |
| 209 | if audit != "" { | |
| 210 | t.Fatalf("a retry was audited:\n%s", audit) | |
| 211 | } | |
| 212 | return | |
| 213 | } | |
| 214 | if !strings.Contains(audit, c.reason) { | |
| 215 | t.Fatalf("refusal not audited:\n%s", audit) | |
| 216 | } | |
| 217 | if strings.Contains(audit, "appended") || strings.Contains(audit, `"hi`) { | |
| 218 | t.Fatalf("audit carries content:\n%s", audit) | |
| 219 | } | |
| 220 | }) | |
| 221 | } | |
| 222 | } | |
| 223 | ||
| 224 | func TestDKIMNoSignature(t *testing.T) { | |
| 225 | f, _ := dkimSetup(t) | |
| 226 | res := f.p.Handle([]byte(f.message(t, "bob@example.test", "hi"))) | |
| 227 | if res.Posted || res.Retry || !strings.Contains(res.Reason, "no DKIM-Signature") { | |
| 228 | t.Fatalf("result %+v", res) | |
| 229 | } | |
| 230 | } | |
| 231 | ||
| 232 | func TestDKIMFutureTime(t *testing.T) { | |
| 233 | f, _ := dkimSetup(t) | |
| 234 | m := signMsg(t, f.message(t, "bob@example.test", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil) | |
| 235 | f.p.Now = func() time.Time { return time.Now().Add(-time.Hour) } | |
| 236 | if res := f.p.Handle([]byte(m)); res.Posted || !strings.Contains(res.Reason, "dated in the future") { | |
| 237 | t.Fatalf("result %+v", res) | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | // Only the first maxSignatures signatures are checked. | |
| 242 | func TestDKIMTooManySignatures(t *testing.T) { | |
| 243 | for _, bad := range []int{maxSignatures - 1, maxSignatures} { | |
| 244 | f, _ := dkimSetup(t) | |
| 245 | msg := f.messageAs(t, f.bob, "bob@example.test", "<many@x>", "", "hi") | |
| 246 | m := signMsg(t, msg, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil) | |
| 247 | for i := 0; i < bad; i++ { | |
| 248 | s := signMsg(t, msg, "attacker.example", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil) | |
| 249 | m = strings.TrimSuffix(s, msg) + m | |
| 250 | } | |
| 251 | res := f.p.Handle([]byte(m)) | |
| 252 | if want := bad < maxSignatures; res.Posted != want { | |
| 253 | t.Fatalf("%d misaligned signatures first: posted %v, want %v (%+v)", bad, res.Posted, want, res) | |
| 254 | } | |
| 255 | } | |
| 256 | } | |
| 257 | ||
| 258 | // With both set, either passing is enough. | |
| 259 | func TestDKIMOrAuthenticationResults(t *testing.T) { | |
| 260 | f, _ := dkimSetup(t) | |
| 261 | f.p.Cfg.Mail.Inbound.TrustedAuthservID = "mx.example.net" | |
| 262 | signed := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<one@x>", "", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil) | |
| 263 | if res := f.p.Handle([]byte(signed)); !res.Posted { | |
| 264 | t.Fatalf("DKIM pass, no Authentication-Results: %+v", res) | |
| 265 | } | |
| 266 | ar := "Authentication-Results: mx.example.net; dmarc=pass header.from=example.test\r\n" | |
| 267 | if res := f.p.Handle([]byte(f.messageAs(t, f.bob, "bob@example.test", "<two@x>", ar, "hi"))); !res.Posted { | |
| 268 | t.Fatalf("Authentication-Results pass, no signature: %+v", res) | |
| 269 | } | |
| 270 | res := f.p.Handle([]byte(f.messageAs(t, f.bob, "bob@example.test", "<three@x>", "", "hi"))) | |
| 271 | if res.Posted || !strings.Contains(res.Reason, "no Authentication-Results") || !strings.Contains(res.Reason, "no DKIM-Signature") { | |
| 272 | t.Fatalf("neither: %+v", res) | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 276 | func TestDKIMKeyCache(t *testing.T) { | |
| 277 | f, dns := dkimSetup(t) | |
| 278 | for _, id := range []string{"<c1@x>", "<c2@x>"} { | |
| 279 | m := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", id, "", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil) | |
| 280 | if res := f.p.Handle([]byte(m)); !res.Posted { | |
| 281 | t.Fatalf("%s: %+v", id, res) | |
| 282 | } | |
| 283 | } | |
| 284 | if dns.lookups != 1 { | |
| 285 | t.Fatalf("%d lookups, want 1", dns.lookups) | |
| 286 | } | |
| 287 | f.p.Now = func() time.Time { return time.Now().Add(keyCacheTTL + time.Minute) } | |
| 288 | f.p.lookupKey("rsa._domainkey.example.test") | |
| 289 | if dns.lookups != 2 { | |
| 290 | t.Fatalf("%d lookups after the TTL, want 2", dns.lookups) | |
| 291 | } | |
| 292 | } | |
| 293 | ||
| 294 | func TestKeyCacheBounded(t *testing.T) { | |
| 295 | var c keyCache | |
| 296 | now := time.Now() | |
| 297 | for i := 0; i < keyCacheSize*2; i++ { | |
| 298 | c.put(strings.Repeat("x", i+1), nil, now) | |
| 299 | } | |
| 300 | if len(c.m) > keyCacheSize { | |
| 301 | t.Fatalf("%d entries", len(c.m)) | |
| 302 | } | |
| 303 | } | |
| 304 | ||
| 305 | // A temporary DNS failure leaves the message unseen for the next poll. | |
| 306 | func TestDKIMRetryInDrain(t *testing.T) { | |
| 307 | f, _ := dkimSetup(t) | |
| 308 | m := signMsg(t, f.message(t, "bob@example.test", "hi"), "example.test", "temp", rsaKey, dkim.CanonicalizationRelaxed, nil) | |
| 309 | mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{1: []byte(m)}} | |
| 310 | if err := f.p.Drain(mb); err != nil { | |
| 311 | t.Fatal(err) | |
| 312 | } | |
| 313 | if mb.seen[1] || f.p.tries[1] != 1 { | |
| 314 | t.Fatalf("seen %v tries %d", mb.seen[1], f.p.tries[1]) | |
| 315 | } | |
| 316 | } | |
| 317 | ||
| 318 | func TestLookupKeyErrors(t *testing.T) { | |
| 319 | p := &Processor{LookupTXT: (&fakeDNS{}).lookup} | |
| 320 | var de *net.DNSError | |
| 321 | if _, err := p.lookupKey("gone._domainkey.x"); !errors.As(err, &de) || !de.IsNotFound || de.Temporary() { | |
| 322 | t.Fatalf("not found: %v", err) | |
| 323 | } | |
| 324 | for _, sel := range []string{"temp", "timeout"} { | |
| 325 | if _, err := p.lookupKey(sel + "._domainkey.x"); !errors.As(err, &de) || !de.Temporary() { | |
| 326 | t.Fatalf("%s: %v", sel, err) | |
| 327 | } | |
| 328 | } | |
| 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 +37 −1
| @@ -4,9 +4,12 @@ import ( | ||
| 4 | 4 | "bytes" |
| 5 | 5 | "net/mail" |
| 6 | 6 | "net/textproto" |
| 7 | "strings" | |
| 7 | 8 | "testing" |
| 8 | 9 | "time" |
| 9 | 10 | |
| 11 | "github.com/emersion/go-msgauth/dkim" | |
| 12 | ||
| 10 | 13 | "gitbay.org/gitbay/internal/mailreply" |
| 11 | 14 | ) |
| 12 | 15 | |
| @@ -18,12 +21,13 @@ func FuzzReply(f *testing.F) { | ||
| 18 | 21 | f.Add([]byte("Subject: x\r\n\r\nOn Mon wrote:\r\n> a\r\n-- \r\nsig\r\n")) |
| 19 | 22 | key := [][]byte{[]byte("0123456789abcdef0123456789abcdef")} |
| 20 | 23 | f.Fuzz(func(t *testing.T, raw []byte) { |
| 24 | parseRawHeader(raw) | |
| 21 | 25 | msg, err := mail.ReadMessage(bytes.NewReader(raw)) |
| 22 | 26 | if err != nil { |
| 23 | 27 | return |
| 24 | 28 | } |
| 25 | 29 | automatic(msg.Header) |
| 26 | if tok := findToken(msg.Header, "reply@x.example"); tok != "" { | |
| 30 | if tok, _ := findToken(msg.Header, "reply@x.example", recipientFields); tok != "" { | |
| 27 | 31 | mailreply.Verify(key, tok, fuzzNow) |
| 28 | 32 | } |
| 29 | 33 | if s, err := textBody(textproto.MIMEHeader(msg.Header), msg.Body, 1<<16); err == nil { |
| @@ -33,3 +37,35 @@ func FuzzReply(f *testing.F) { | ||
| 33 | 37 | } |
| 34 | 38 | |
| 35 | 39 | var fuzzNow = time.Date(2026, 9, 29, 0, 0, 0, 0, time.UTC) |
| 40 | ||
| 41 | // FuzzDKIM runs the DKIM check, the dkim package's signature and key | |
| 42 | // record parsing included, on arbitrary messages: no input panics. | |
| 43 | func FuzzDKIM(f *testing.F) { | |
| 44 | msg := "From: bob@example.test\r\nTo: x@y\r\nSubject: s\r\n\r\nhi\r\n" | |
| 45 | for _, canon := range []dkim.Canonicalization{dkim.CanonicalizationSimple, dkim.CanonicalizationRelaxed} { | |
| 46 | var b bytes.Buffer | |
| 47 | o := dkim.SignOptions{Domain: "example.test", Selector: "rsa", Signer: rsaKey, | |
| 48 | HeaderCanonicalization: canon, BodyCanonicalization: canon} | |
| 49 | if err := dkim.Sign(&b, strings.NewReader(msg), &o); err != nil { | |
| 50 | f.Fatal(err) | |
| 51 | } | |
| 52 | f.Add(b.Bytes()) | |
| 53 | } | |
| 54 | f.Add([]byte("DKIM-Signature: v=1; a=ed25519-sha256; d=example.test; s=temp; h=from; bh=; b=\r\nFrom: a@example.test\r\n\r\n")) | |
| 55 | f.Fuzz(func(t *testing.T, raw []byte) { | |
| 56 | msg, err := mail.ReadMessage(bytes.NewReader(raw)) | |
| 57 | if err != nil { | |
| 58 | return | |
| 59 | } | |
| 60 | from, err := msg.Header.AddressList("From") | |
| 61 | if err != nil || len(from) != 1 { | |
| 62 | return | |
| 63 | } | |
| 64 | rh, reason := parseRawHeader(raw) | |
| 65 | if reason != "" { | |
| 66 | return | |
| 67 | } | |
| 68 | p := &Processor{LookupTXT: (&fakeDNS{}).lookup} | |
| 69 | p.dkimVerified(raw, rh, from[0].Address, "to") | |
| 70 | }) | |
| 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 +86 −19
| @@ -48,7 +48,11 @@ type Processor struct { | ||
| 48 | 48 | St *store.Store |
| 49 | 49 | Cfg config.Config |
| 50 | 50 | Now func() time.Time |
| 51 | // LookupTXT resolves DKIM selector keys; nil is the system | |
| 52 | // resolver. | |
| 53 | LookupTXT LookupTXT | |
| 51 | 54 | |
| 55 | keys keyCache | |
| 52 | 56 | tries map[uint32]int |
| 53 | 57 | // A window of refusal rows, bounded because anyone can send mail |
| 54 | 58 | // to the mailbox. |
| @@ -136,6 +140,10 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 136 | 140 | if len(bytes.TrimSpace(raw)) == 0 { |
| 137 | 141 | return p.refuse(0, "", "empty message") |
| 138 | 142 | } |
| 143 | rh, reason := parseRawHeader(raw) | |
| 144 | if reason != "" { | |
| 145 | return p.refuse(0, "", reason) | |
| 146 | } | |
| 139 | 147 | msg, err := mail.ReadMessage(bytes.NewReader(raw)) |
| 140 | 148 | if err != nil { |
| 141 | 149 | return p.refuse(0, "", "unreadable message") |
| @@ -148,7 +156,7 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 148 | 156 | return p.refuse(0, msgID, "automatic reply") |
| 149 | 157 | } |
| 150 | 158 | in := p.Cfg.Mail.Inbound |
| 151 | token := findToken(msg.Header, in.ReplyAddress) | |
| 159 | token, _ := findToken(msg.Header, in.ReplyAddress, recipientFields) | |
| 152 | 160 | if token == "" { |
| 153 | 161 | return p.refuse(0, msgID, "not addressed to a reply address") |
| 154 | 162 | } |
| @@ -197,10 +205,12 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 197 | 205 | if !ok { |
| 198 | 206 | return p.refuse(u.ID, msgID, "From is not a verified address of the account") |
| 199 | 207 | } |
| 200 | if id := p.Cfg.Mail.Inbound.TrustedAuthservID; id != "" { | |
| 201 | if reason := authenticated(msg.Header, id, from[0].Address); reason != "" { | |
| 202 | return p.refuse(u.ID, msgID, reason) | |
| 208 | sigIDs, res := p.authenticate(raw, rh, msg.Header, from[0].Address, token) | |
| 209 | if res != nil { | |
| 210 | if res.Retry { | |
| 211 | return *res | |
| 203 | 212 | } |
| 213 | return p.refuse(u.ID, msgID, res.Reason) | |
| 204 | 214 | } |
| 205 | 215 | if on, err := p.St.ReplyEnabled(u.ID); err != nil { |
| 206 | 216 | return Result{Retry: true, Reason: err.Error()} |
| @@ -243,13 +253,24 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 243 | 253 | sum := sha256.Sum256(raw) |
| 244 | 254 | id = "sha256:" + hex.EncodeToString(sum[:]) |
| 245 | 255 | } |
| 246 | key := fmt.Sprintf("%d/%s/%d/%d/%s", u.ID, target.Kind, target.RepoID, target.Number, id) | |
| 247 | claimed, err := p.St.ClaimMailReply(key) | |
| 248 | if err != nil { | |
| 249 | return Result{Retry: true, Reason: err.Error()} | |
| 250 | } | |
| 251 | if !claimed { | |
| 252 | 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 | } | |
| 253 | 274 | } |
| 254 | 275 | |
| 255 | 276 | var stdout, stderr bytes.Buffer |
| @@ -261,8 +282,10 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 261 | 282 | if code == protocol.ExitOK { |
| 262 | 283 | return Result{Posted: true} |
| 263 | 284 | } |
| 264 | p.St.ReleaseMailReply(key) | |
| 265 | reason := strings.TrimSpace(stderr.String()) | |
| 285 | for _, k := range claims { | |
| 286 | p.St.ReleaseMailReply(k) | |
| 287 | } | |
| 288 | reason = strings.TrimSpace(stderr.String()) | |
| 266 | 289 | if code == protocol.ExitFailure { |
| 267 | 290 | return Result{Retry: true, Reason: reason} |
| 268 | 291 | } |
| @@ -271,6 +294,47 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 271 | 294 | return p.refuse(u.ID, msgID, "comment refused: "+reason) |
| 272 | 295 | } |
| 273 | 296 | |
| 297 | // authenticate checks that the mail host or the sender's domain vouches | |
| 298 | // for From: an Authentication-Results pass from trusted_authserv_id, or | |
| 299 | // a DKIM signature that verifies here with require_dkim. When both are | |
| 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) { | |
| 306 | in := p.Cfg.Mail.Inbound | |
| 307 | var reasons []string | |
| 308 | if id := in.TrustedAuthservID; id != "" { | |
| 309 | reason := authenticated(h, id, from) | |
| 310 | if reason == "" { | |
| 311 | return nil, nil | |
| 312 | } | |
| 313 | reasons = append(reasons, reason) | |
| 314 | } | |
| 315 | if in.RequireDKIM { | |
| 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) | |
| 330 | } | |
| 331 | } | |
| 332 | if len(reasons) == 0 { | |
| 333 | return nil, nil | |
| 334 | } | |
| 335 | return nil, &Result{Reason: strings.Join(reasons, "; ")} | |
| 336 | } | |
| 337 | ||
| 274 | 338 | // createdAfter refuses when the row was created after the token was |
| 275 | 339 | // minted: a later account or repository that took a freed id. Created |
| 276 | 340 | // times are compared to the second, the token's precision. |
| @@ -334,10 +398,13 @@ func automatic(h mail.Header) bool { | ||
| 334 | 398 | return h.Get("X-Autoreply") != "" || h.Get("X-Autorespond") != "" |
| 335 | 399 | } |
| 336 | 400 | |
| 337 | // findToken returns the reply token from the first recipient header | |
| 338 | // that carries one. | |
| 339 | func findToken(h mail.Header, base string) string { | |
| 340 | 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 { | |
| 341 | 408 | for _, v := range h[textproto.CanonicalMIMEHeaderKey(name)] { |
| 342 | 409 | addrs, err := mail.ParseAddressList(v) |
| 343 | 410 | if err != nil { |
| @@ -345,12 +412,12 @@ func findToken(h mail.Header, base string) string { | ||
| 345 | 412 | } |
| 346 | 413 | for _, a := range addrs { |
| 347 | 414 | if tok, ok := mailreply.TokenFrom(base, a.Address); ok { |
| 348 | return tok | |
| 415 | return tok, strings.ToLower(name) | |
| 349 | 416 | } |
| 350 | 417 | } |
| 351 | 418 | } |
| 352 | 419 | } |
| 353 | return "" | |
| 420 | return "", "" | |
| 354 | 421 | } |
| 355 | 422 | |
| 356 | 423 | // Poller reads the configured mailbox every poll interval. |