Commit 90d0cc2aad

90d0cc2aad477fa379e6460ad16f36466b8302d4

parent: 85af941e0a

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-29 06:20 UTC

wiki, changelog: reply by mail; fuzz the reply parsers

Admin documents [mail.inbound] and the mailbox setup, Users the reply
setting, Threat-Model the token and From binding and the absence of
backscatter. Parity and the Architecture tables gain their rows.
FuzzReply and FuzzReadResponse join deploy/audit.sh.

Closes #295

Layout: unified · split

.gitbay/wiki/Admin.org +88
@@ -147,6 +147,94 @@ build page's live log arrives only when the build ends;
147 =localhost= and loopback addresses; set =false= to allow plaintext 147 =localhost= and loopback addresses; set =false= to allow plaintext
148 to a remote relay. 148 to a remote relay.
149 149
150** [mail.inbound]
151Reply by mail: a reply to issue or merge request mail posts a comment
152(#295). gitbayd polls a mailbox over IMAP; no port is opened for it.
153Off unless =enabled=, and it requires =[mail] smtp_host=, since the
154replies answer mail gitbayd sends.
155
156#+begin_src toml
157[mail.inbound]
158enabled = true
159imap_host = "imap.example.org" # host:port; 993, or 143 with tls = "starttls"
160tls = "implicit" # or "starttls"; there is no plaintext setting
161user = "reply@gitbay.example"
162password_file = "/etc/gitbay/imap.pass" # one line, mode 0600, owned by gitbayd's user
163mailbox = "INBOX" # default
164poll_interval = "1m" # default; at least 10s
165reply_address = "reply@gitbay.example"
166trusted_authserv_id = "mx.example.org" # the mail host's Authentication-Results id
167#+end_src
168
169- The password is read from =password_file= at every connection and
170 never appears in the config, argv or the log. An unreadable file, or
171 one readable by group or others, stops the daemon at start.
172- =reply_address= is what each Reply-To is built from:
173 =reply@gitbay.example= becomes =reply+<token>@gitbay.example=. The
174 mailbox must receive that: give it a plus-addressing (subaddress)
175 mailbox, as most hosted mail does by default, or a catch-all for the
176 domain. Point the domain's MX at the mail host as for any other
177 mailbox; nothing about it involves gitbayd.
178- Use a mailbox that holds nothing else. gitbayd reads every unseen
179 message in it and marks each one =\Seen= when it is handled, posted
180 or refused. A message that fails for a reason that may pass (the
181 database busy, the server refusing that one fetch) stays unseen and is
182 tried again on the next poll, up to five times, then is marked seen
183 and audited. A dropped connection or a timeout ends the poll and
184 counts against no message. A
185 message over 10 MiB is refused by its size without being fetched. A
186 server that sends more than about 11 MiB, or more than a thousand
187 untagged responses, for one command has its connection closed; one
188 poll handles at most ten thousand messages.
189- =trusted_authserv_id= is required on any instance reachable from the
190 internet. It names the authserv-id the mail host writes at the start
191 of its =Authentication-Results= header (Gmail's is =mx.google.com=).
192 With it set, a reply is posted only when the topmost header with that
193 id shows =dmarc=pass= with =header.from= equal to the From domain, or
194 =dkim=pass= with a =header.d= in relaxed alignment with it (the same
195 organizational domain by the public suffix list; a public suffix such
196 as =github.io= aligns with nothing). The header is parsed per RFC
197 8601, so text inside a quoted string or a comment (a quoted MAIL FROM
198 local part, a reason) is never read as a result. Lower headers
199 claiming the same id are the sender's and are not read. This is only safe when the mail host
200 removes incoming =Authentication-Results= headers that claim its id,
201 as RFC 8601 asks; Gmail, Fastmail and Migadu do. Check yours before
202 relying on it. Unset, the daemon logs a warning at start and =admin
203 mail inbound check= repeats it: without it, =From= is whatever the
204 sender wrote.
205- =gitbay admin mail inbound check= logs in, opens the mailbox
206 read-only (EXAMINE) and reports the message and unseen counts, so a
207 check never marks a reply seen before the poller reads it. With
208 inbound off it says so and exits 0. Poll failures are logged as
209 =mail reply: poll failed= with the server and the IMAP error.
210
211A reply is posted when all of these hold, checked when it is read:
212
2131. It is not an automatic reply (=Auto-Submitted=, =Precedence: bulk=
214 and the like).
2152. A recipient header carries a reply address whose token verifies
216 and has not expired (thirty days from the mail it came on).
2173. The token's account exists, is active and not disabled, still has
218 reply by mail on, and was created before the token was: an id freed
219 by a delete and reused is not the account the token named. The same
220 holds for the repository.
2214. =From= is one of that account's verified addresses, and, with
222 =trusted_authserv_id= set, the mail host authenticated it.
2235. The message has a =text/plain= part (HTML-only mail is refused, not
224 converted), and what is left after quoted text and the signature
225 are removed is not empty and fits a comment (64 KiB).
2266. No earlier copy of the message (same =Message-ID=, account and
227 thread) posted.
2287. =issue comment= or =mr comment=, dispatched as the account, accepts
229 it: the account can still read the repository, the thread exists,
230 the repository is not archived, the write budget is not spent.
231
232The comment's audit row is =cmd issue comment= (or =mr comment=) with
233=source: mail=. A refusal writes a =refused mail reply= row with the
234reason and the =Message-ID=, never the message's content, at most sixty
235a minute, and sends nothing back. See Threat-Model for why the token
236and the sender address are both required.
237
150** [push] 238** [push]
151Push notifications to Apple devices, delivered by gitbayd talking to 239Push notifications to Apple devices, delivered by gitbayd talking to
152APNs directly over HTTP/2, authenticated by an ES256 JWT signed with an 240APNs directly over HTTP/2, authenticated by an ES256 JWT signed with an
.gitbay/wiki/Architecture/01-System-Context.org +1
@@ -35,6 +35,7 @@ do not reimplement logic (=internal/httpd/control.go=,
35| ACME CA (Let's Encrypt) | out | TLS certificates | =cmd/gitbayd/main.go= | 35| ACME CA (Let's Encrypt) | out | TLS certificates | =cmd/gitbayd/main.go= |
36| SMTP relay | out | verification, login links, notifications | =internal/mail/mail.go= | 36| SMTP relay | out | verification, login links, notifications | =internal/mail/mail.go= |
37| Apple Push Notification | out | iOS notifications | =internal/push/apns.go= | 37| Apple Push Notification | out | iOS notifications | =internal/push/apns.go= |
38| IMAP mailbox | out | replies to notification mail (opt-in) | =internal/mailin=, =internal/imapc= |
38| Webhook endpoints | out | event delivery, user-configured | =internal/webhook/webhook.go= | 39| Webhook endpoints | out | event delivery, user-configured | =internal/webhook/webhook.go= |
39| Mirror remotes | out / in | push and pull mirrors, user-configured | =internal/mirror/mirror.go= | 40| Mirror remotes | out / in | push and pull mirrors, user-configured | =internal/mirror/mirror.go= |
40| Package registries | out | dependency update checks (opt-in per repo)| =internal/deps/registry.go= | 41| Package registries | out | dependency update checks (opt-in per repo)| =internal/deps/registry.go= |
.gitbay/wiki/Architecture/02-Components.org +2
@@ -35,6 +35,7 @@ the hidden =hook= used by git (=cmd/gitbayd/main.go=,
35| =internal/gitpin= | Resolves and checks a user-supplied http(s) remote and pins git to the checked addresses; mirror sync and =repo import=. | 35| =internal/gitpin= | Resolves and checks a user-supplied http(s) remote and pins git to the checked addresses; mirror sync and =repo import=. |
36| =internal/notify=, =internal/mail= | Mail queue drain and SMTP. | 36| =internal/notify=, =internal/mail= | Mail queue drain and SMTP. |
37| =internal/push= | APNs queue drain and provider-token signing. | 37| =internal/push= | APNs queue drain and provider-token signing. |
38| =internal/mailin=, =internal/imapc=, =internal/mailreply= | Reply by mail: the IMAP client, the reply token, and the processor that posts a reply through =issue comment= / =mr comment=. |
38| =internal/deps= | Dependency manifest parsing and registry checks (opt-in per repository). | 39| =internal/deps= | Dependency manifest parsing and registry checks (opt-in per repository). |
39| =internal/config= | Configuration load and validation. | 40| =internal/config= | Configuration load and validation. |
40| =internal/web= | Embedded templates, stylesheet and fonts. | 41| =internal/web= | Embedded templates, stylesheet and fonts. |
@@ -77,6 +78,7 @@ Started by =gitbayd serve= (=cmd/gitbayd/main.go=):
77| Webhook delivery | always | 2 s poll | =webhook_deliveries= | 78| Webhook delivery | always | 2 s poll | =webhook_deliveries= |
78| Mail | =mail.smtp_host= set | 2 s poll | =notifications= | 79| Mail | =mail.smtp_host= set | 2 s poll | =notifications= |
79| APNs push | =push.enabled= | 2 s poll | =push_queue= | 80| APNs push | =push.enabled= | 2 s poll | =push_queue= |
81| Mail replies | =mail.inbound.enabled= | =mail.inbound.poll_interval= (1 min) | IMAP mailbox, =mail_replies= |
80| Mirrors | always | 10 s tick, per-mirror interval | =mirrors= | 82| Mirrors | always | 10 s tick, per-mirror interval | =mirrors= |
81| CI scheduler | always | 1 min tick; reaps stale builds | =build_schedules=, =builds= | 83| CI scheduler | always | 1 min tick; reaps stale builds | =build_schedules=, =builds= |
82| Dependency checks | always (repos opt in) | =deps.check_interval_hours= | =dep_checks= | 84| Dependency checks | always (repos opt in) | =deps.check_interval_hours= | =dep_checks= |
.gitbay/wiki/Architecture/03-Deployment.org +2
@@ -51,6 +51,7 @@ a database check.
51| =<root>/hooks= | generated hook scripts | 0755 | 51| =<root>/hooks= | generated hook scripts | 0755 |
52| =/etc/gitbay/config.toml= | configuration, including SMTP password | 0640 (cloud-init) | 52| =/etc/gitbay/config.toml= | configuration, including SMTP password | 0640 (cloud-init) |
53| =/etc/gitbay/secret.key= | keys sealing secret columns | 0600, owner =gitbay= (=deploy/install.sh=) | 53| =/etc/gitbay/secret.key= | keys sealing secret columns | 0600, owner =gitbay= (=deploy/install.sh=) |
54| =mail.inbound.password_file= | IMAP mailbox password | 0600 required; the daemon refuses to start otherwise |
54| =/var/backups/gitbay= | backup archives | 0750 (cloud-init) | 55| =/var/backups/gitbay= | backup archives | 0750 (cloud-init) |
55 56
56* Outbound connections from gitbayd 57* Outbound connections from gitbayd
@@ -60,6 +61,7 @@ a database check.
60| ACME directory | certificate issue and renewal | yes | host policy limits names to the site and claimed pages domains (=main.go=) | 61| ACME directory | certificate issue and renewal | yes | host policy limits names to the site and claimed pages domains (=main.go=) |
61| 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=) | 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=) |
62| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key | 63| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key |
64| IMAP server | =mail.inbound.poll_interval= | implicit TLS or STARTTLS, certificate verified; no plaintext setting | operator-configured host only (=internal/imapc=) |
63| 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=) | 65| 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=) |
64| 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=) | 66| 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=) |
65| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) | 67| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) |
.gitbay/wiki/Architecture/05-Identity-and-Access.org +1
@@ -22,6 +22,7 @@
22| Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use | 22| Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use |
23| Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use | 23| Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use |
24| Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use | 24| Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use |
25| Mail reply token | HMAC-SHA256 (96 bits) over account, repository, thread, expiry, in the Reply-To local part | not stored (stateless); a =Message-ID= that posted is kept | one comment thread, as one account, only from that account's verified =From= | 30 days | with the account's access, checked when the reply is read; =notifications settings reply off= |
25| LFS transfer token | HMAC-SHA256 over repo, SSH key, operation, expiry | not stored (stateless) | one repository, upload or download | 1 h | with its key: refused once the key is removed or expires, its account is disabled, or it loses the access the operation needs | 26| LFS transfer token | HMAC-SHA256 over repo, SSH key, operation, expiry | not stored (stateless) | one repository, upload or download | 1 h | with its key: refused once the key is removed or expires, its account is disabled, or it loses the access the operation needs |
26 27
27Generation and hashing: =internal/store/sessions.go= (=NewToken=, 28Generation and hashing: =internal/store/sessions.go= (=NewToken=,
.gitbay/wiki/Architecture/06-Data-and-Cryptography.org +3 −1
@@ -16,7 +16,7 @@ content (as confidential as the repository), *O* operational.
16| CI | =builds= (includes logs), =build_schedules=, =runner_repos=, =runner_seen= | R | build logs can echo anything a step prints | 16| CI | =builds= (includes logs), =build_schedules=, =runner_repos=, =runner_seen= | R | build logs can echo anything a step prints |
17| CI secrets | =build_secrets= | C | sealed (AES-256-GCM) | 17| CI secrets | =build_secrets= | C | sealed (AES-256-GCM) |
18| Integrations | =webhooks= (secret), =webhook_deliveries=, =mirrors= (username, token) | C | webhook secret and mirror token sealed | 18| Integrations | =webhooks= (secret), =webhook_deliveries=, =mirrors= (username, token) | C | webhook secret and mirror token sealed |
19| Notifications | =notifications= (mail queue), =inbox=, =push_devices= (APNs token), =push_queue= | P | device tokens sealed; looked up by SHA-256 | 19| Notifications | =notifications= (mail queue), =inbox=, =push_devices= (APNs token), =push_queue=, =mail_replies= (Message-IDs of posted replies) | P | device tokens sealed; looked up by SHA-256 |
20| Signatures | =commit_signatures=, =settings.key_epoch= | O | verification cache | 20| Signatures | =commit_signatures=, =settings.key_epoch= | O | verification cache |
21| Audit and feed | =audit_log=, =events= | O, P | actor ids, pruned argv, fingerprints and IPs in some audit rows, a hash chain (=prev_hash=, =hash=) | 21| Audit and feed | =audit_log=, =events= | O, P | actor ids, pruned argv, fingerprints and IPs in some audit rows, a hash chain (=prev_hash=, =hash=) |
22| Dependencies | =dep_checks=, =dep_reports= | O | | 22| Dependencies | =dep_checks=, =dep_reports= | O | |
@@ -31,6 +31,7 @@ Outside the database:
31| TLS keys (ACME) | =<root>/acme= | C | 31| TLS keys (ACME) | =<root>/acme= | C |
32| SMTP password | =/etc/gitbay/config.toml= | C | 32| SMTP password | =/etc/gitbay/config.toml= | C |
33| APNs signing key (.p8) | path in =push.key_file= | C | 33| APNs signing key (.p8) | path in =push.key_file= | C |
34| IMAP password | path in =mail.inbound.password_file= | C |
34| Secret key file | =server.secret_key_file= (=/etc/gitbay/secret.key=) | C | 35| Secret key file | =server.secret_key_file= (=/etc/gitbay/secret.key=) | C |
35| Backups | =/var/backups/gitbay=, offsite | all of the above | 36| Backups | =/var/backups/gitbay=, offsite | all of the above |
36 37
@@ -64,6 +65,7 @@ token without the key file, which neither carries. Rotation:
64| Runner ↔ server | SSH | 65| Runner ↔ server | SSH |
65| SMTP | STARTTLS required unless the relay is local (=mail.require_tls=), or implicit TLS (=mail.tls=) | 66| SMTP | STARTTLS required unless the relay is local (=mail.require_tls=), or implicit TLS (=mail.tls=) |
66| APNs | TLS, HTTP/2 | 67| APNs | TLS, HTTP/2 |
68| IMAP | implicit TLS or STARTTLS, TLS 1.2 minimum, certificate verified (=internal/imapc=) |
67| Webhooks | TLS when the URL is https; HMAC-SHA256 body signature in =X-Gitbay-Signature-256= (=internal/webhook/webhook.go=) | 69| Webhooks | TLS when the URL is https; HMAC-SHA256 body signature in =X-Gitbay-Signature-256= (=internal/webhook/webhook.go=) |
68| Mirrors | per URL; token passed through =GIT_ASKPASS=, never argv (=internal/mirror/mirror.go=) | 70| Mirrors | per URL; token passed through =GIT_ASKPASS=, never argv (=internal/mirror/mirror.go=) |
69 71
.gitbay/wiki/Parity.org +11
@@ -395,6 +395,7 @@ client has no use for one (krz/gitbay#57).
395| dashboard aggregate | yes | yes | yes | 395| dashboard aggregate | yes | yes | yes |
396| notification inbox | yes | yes | yes | 396| notification inbox | yes | yes | yes |
397| activity mail on, off | yes | yes | yes | 397| activity mail on, off | yes | yes | yes |
398| reply by mail on, off | yes | yes | no |
398| watch writable repos | yes | yes | yes | 399| watch writable repos | yes | yes | yes |
399| push device add | yes | yes | yes | 400| push device add | yes | yes | yes |
400| push device list | yes | yes | yes | 401| push device list | yes | yes | yes |
@@ -430,6 +431,15 @@ it, because only the iOS app can produce an APNs device token — a
430browser has no way to ask Apple for one. =device list= and =device 431browser has no way to ask Apple for one. =device list= and =device
431remove= have no such limit and are yes on the web like the rest. 432remove= have no such limit and are yes on the web like the rest.
432 433
434Replying to issue and merge request mail posts a comment (#295) when
435the instance reads a reply mailbox (=[mail.inbound]=) and the account
436ran =notifications settings reply on=. The account page shows the
437switch only on such an instance. The reply is itself a fourth surface
438for =issue comment= and =mr comment= and nothing else: it is dispatched
439as that command, so it cannot do what the command would refuse.
440=admin mail inbound check= has no page, like the rest of instance
441administration.
442
433A login link is requested from the login page by username or verified 443A login link is requested from the login page by username or verified
434address, and arrives by mail: it works once and expires in fifteen 444address, and arrives by mail: it works once and expires in fifteen
435minutes. The row is =n/a= for the CLI because a terminal with a 445minutes. The row is =n/a= for the CLI because a terminal with a
@@ -474,6 +484,7 @@ when there is none.
474| rebuild a symbol index | yes | no | no | 484| rebuild a symbol index | yes | no | no |
475| audit log | yes | no | no | 485| audit log | yes | no | no |
476| instance statistics | yes | no | no | 486| instance statistics | yes | no | no |
487| inbound mail check | yes | no | no |
477 488
478=/admin/users= carries the account list with the command's state 489=/admin/users= carries the account list with the command's state
479filter and cursor, and promote, demote, disable and enable per row; 490filter and cursor, and promote, demote, disable and enable per row;
.gitbay/wiki/Threat-Model.org +63
@@ -78,6 +78,10 @@ anonymous client has a fuzz target and must never panic:
78- =internal/gitd= — the =git://= pkt-line reader. 78- =internal/gitd= — the =git://= pkt-line reader.
79- =internal/sig= — the commit parser, the SSHSIG armor decoder and blob 79- =internal/sig= — the commit parser, the SSHSIG armor decoder and blob
80 parser, and the OpenPGP armored-key reader. 80 parser, and the OpenPGP armored-key reader.
81- =internal/mailin= — an inbound reply's headers, reply token, MIME
82 body and quote stripping, where anyone who can send mail to the
83 reply mailbox chooses the bytes.
84- =internal/imapc= — the IMAP response reader, literals included.
81 85
82Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=. 86Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=.
83 87
@@ -90,6 +94,65 @@ itself is never compared in Go, so there is no timing oracle to exploit.
90Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies 94Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies
91no inbound HMAC. 95no inbound HMAC.
92 96
97* Reply by mail
98
99When =[mail.inbound]= is on (#295), anyone can send mail to the reply
100mailbox, and a posted reply is a comment written as an account. Two
101things are required together, because neither is enough alone:
102
103- *The reply token.* Each Reply-To is =reply+<token>@<domain>=; the
104 token names the recipient, the repository, the issue or merge
105 request, and an expiry thirty days out, under an HMAC-SHA256
106 truncated to 96 bits. Its key is derived from the secret key file
107 (=seal.Keyring.Derive=), which lives outside =server.root= and out of
108 backups; no row is stored per message. Verification is
109 =hmac.Equal=, against every key in the file so a rotation does not
110 break mail already sent, and only the canonical encoding is
111 accepted. A token is per recipient: it is not a credential for
112 anyone else's account or any other thread.
113- *The sender address.* =From= must be one of the token's account's
114 verified addresses. A leaked token (a forwarded notification, a
115 mailing-list archive, a shared inbox) is not enough to post without
116 also sending as that person. =From= is only what the sender wrote
117 unless the mail host vouches for it. With =[mail.inbound]
118 trusted_authserv_id= set, gitbay reads the topmost
119 =Authentication-Results= header carrying that id and requires DMARC
120 pass for the From domain or a DKIM pass whose =header.d= has the same
121 organizational domain (public suffix list); a forged header lower
122 down, claiming the same id, is ignored. Quoted strings and comments
123 are tokenized as RFC 8601 defines them, so sender-controlled text the
124 mail host echoes into its header (a quoted MAIL FROM local part, a
125 reason) cannot read as a result; =FuzzAuthResults= checks that. That rests on
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.
130- *Reused ids.* Account and repository ids are reused after a hard
131 delete. A reply is refused when the account or repository was
132 created after its token was minted, so a token cannot post into a
133 later repository, or as a later account, that took the id.
134
135Access is judged when the reply is read, not when the mail was sent:
136the reply is posted by dispatching =issue comment= or =mr comment= as
137the account, so a revoked grant, a private repository, an archived
138repository, a disabled or pending account, or reply by mail turned
139off all refuse it. A =Message-ID= that already posted to a thread as an
140account is not posted there again. Automatic replies (=Auto-Submitted=, =Precedence: bulk=) are
141refused so an out-of-office responder cannot post.
142
143Refusals send nothing back: no bounce, no error mail, so the mailbox
144cannot be used to make the instance mail a forged sender
145(backscatter). Each refusal is an audit row, =refused mail reply=, with
146the reason and the =Message-ID= and none of the message's content,
147bounded at sixty rows a minute. The IMAP connection is TLS or STARTTLS
148with certificate verification; there is no plaintext setting. The
149mailbox password is read from a 0600 file and never logged. The client
150bounds what the server can make it hold: 10 MiB a message (refused by
151size before fetching), about 11 MiB and a thousand responses a
152command, after which the connection is closed; literals other than
153the message body are read and discarded. The Reply-To address is
154blanked from the mail queue once the mail is sent or dead-lettered.
155
93* Network-facing request forgery 156* Network-facing request forgery
94 157
95Webhook delivery, GitHub-history import =--api-base=, mirror remotes 158Webhook delivery, GitHub-history import =--api-base=, mirror remotes
.gitbay/wiki/Users.org +16
@@ -934,6 +934,22 @@ someone else.
934You are never mailed about your own actions, and only verified primary 934You are never mailed about your own actions, and only verified primary
935addresses receive anything. Delivery retries on relay failure. 935addresses receive anything. Delivery retries on relay failure.
936 936
937=notifications settings reply on= lets you answer that mail: issue and
938merge request mail then carries a =Reply-To= address, and replying to
939it posts your reply as a comment on the thread, as you. Off by
940default; the account page has the switch when the instance reads
941replies, and turning it on is refused where it does not
942(=[mail.inbound]= off). A reply is posted only when it comes from one
943of your verified addresses, you can still comment on the thread, and
944the mail it answers is less than thirty days old. Quoted text (lines
945starting with =>=, the "On … wrote:" line and what follows it, the
946header block Outlook quotes) and everything after a =-- = signature
947line are removed, and the rest is stored as markdown. HTML-only mail is
948not accepted; send plain text or the usual plain-and-HTML pair. A
949refused reply gets no answer: nothing is mailed back. Each reply
950address names you, so do not forward notification mail you would not
951want answered from your own address.
952
937Push is the same activity again, delivered to a phone: the iOS app 953Push is the same activity again, delivered to a phone: the iOS app
938registers a device, and =notifications settings push off= silences it 954registers a device, and =notifications settings push off= silences it
939the way =mail off= silences mail, without deregistering anything. 955the way =mail off= silences mail, without deregistering anything.
CHANGELOG.org +19
@@ -16,6 +16,25 @@ anything beyond "replace the binary and restart" is needed.
16 field passed on stdin and never shown again), rename, transfer and 16 field passed on stdin and never shown again), rename, transfer and
17 delete with typed confirmation. =/new= gains an import form for 17 delete with typed confirmation. =/new= gains an import form for
18 =repo import= (#296). 18 =repo import= (#296).
19- Reply to notification mail to comment. With =[mail.inbound]=
20 configured (an IMAP mailbox over TLS or STARTTLS, polled; password
21 from =password_file=) and =notifications settings reply on= (per
22 account, off by default; also on the account page), issue and merge
23 request mail carries =Reply-To: reply+<token>@<domain>=. The token
24 names the recipient, repository and thread, expires after thirty
25 days, and is an HMAC under a key derived from the secret key file. A
26 reply is posted as =issue comment= or =mr comment= by that account
27 when it comes from one of the account's verified addresses and the
28 account may still comment; quoted text and signatures are removed,
29 HTML-only mail and automatic replies are refused, and a
30 =Message-ID= posts once. Refusals mail nothing back and are audited
31 as =refused mail reply= with the reason. =admin mail inbound check=
32 tests the mailbox read-only. =trusted_authserv_id= makes a reply
33 also need the mail host's DMARC pass or aligned DKIM pass in its
34 topmost =Authentication-Results= header; unset, the daemon warns.
35 A reply is refused when its account or repository was created after
36 the token (a reused id). A migration adds =users.notify_reply=,
37 =notifications.reply_to= and =mail_replies=. (#295)
19- =web diff set unified|split= and a Diff layout control on the account 38- =web diff set unified|split= and a Diff layout control on the account
20 page choose how the merge request, commit and compare pages draw a 39 page choose how the merge request, commit and compare pages draw a
21 diff; =?layout=split|unified= overrides it per request. The split 40 diff; =?layout=split|unified= overrides it per request. The split
deploy/audit.sh +3
@@ -16,5 +16,8 @@ go test -run xxx -fuzz FuzzParseCommit -fuzztime 10s ./internal/sig/
16go test -run xxx -fuzz FuzzDecodeArmorAndParseSSHSig -fuzztime 10s ./internal/sig/ 16go test -run xxx -fuzz FuzzDecodeArmorAndParseSSHSig -fuzztime 10s ./internal/sig/
17go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/ 17go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/
18go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/ 18go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/
19go test -run xxx -fuzz FuzzReply -fuzztime 10s ./internal/mailin/
20go test -run xxx -fuzz FuzzAuthResults -fuzztime 10s ./internal/mailin/
21go test -run xxx -fuzz FuzzReadResponse -fuzztime 10s ./internal/imapc/
19 22
20echo "== all clear ==" 23echo "== all clear =="
internal/imapc/fuzz_test.go added +25
@@ -0,0 +1,25 @@
1package imapc
2
3import (
4 "bufio"
5 "bytes"
6 "net"
7 "testing"
8)
9
10// FuzzReadResponse feeds server bytes to the response reader, literals
11// included.
12func FuzzReadResponse(f *testing.F) {
13 f.Add([]byte("* 1 FETCH (UID 3 BODY[] {5}\r\nhello)\r\ng1 OK done\r\n"))
14 f.Add([]byte("* SEARCH 1 2 3\r\n* OK {99999999999}\r\n"))
15 f.Fuzz(func(t *testing.T, in []byte) {
16 a, b := net.Pipe()
17 defer b.Close()
18 c := &Client{conn: a, r: bufio.NewReader(bytes.NewReader(in)), left: cmdBudget}
19 for i := 0; i < 8; i++ {
20 if _, err := c.readResponse(); err != nil {
21 return
22 }
23 }
24 })
25}
internal/mailin/fuzz_test.go added +35
@@ -0,0 +1,35 @@
1package mailin
2
3import (
4 "bytes"
5 "net/mail"
6 "net/textproto"
7 "testing"
8 "time"
9
10 "gitbay.org/gitbay/internal/mailreply"
11)
12
13// FuzzReply runs what an inbound message goes through before any
14// account is looked up: header parsing, token extraction and
15// verification, body extraction and quote stripping.
16func FuzzReply(f *testing.F) {
17 f.Add([]byte("From: a@b\r\nTo: reply+abc@x.example\r\nContent-Type: multipart/alternative; boundary=b\r\n\r\n--b\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nhi=\r\n\r\n> q\r\n--b--\r\n"))
18 f.Add([]byte("Subject: x\r\n\r\nOn Mon wrote:\r\n> a\r\n-- \r\nsig\r\n"))
19 key := [][]byte{[]byte("0123456789abcdef0123456789abcdef")}
20 f.Fuzz(func(t *testing.T, raw []byte) {
21 msg, err := mail.ReadMessage(bytes.NewReader(raw))
22 if err != nil {
23 return
24 }
25 automatic(msg.Header)
26 if tok := findToken(msg.Header, "reply@x.example"); tok != "" {
27 mailreply.Verify(key, tok, fuzzNow)
28 }
29 if s, err := textBody(textproto.MIMEHeader(msg.Header), msg.Body, 1<<16); err == nil {
30 stripQuoted(s)
31 }
32 })
33}
34
35var fuzzNow = time.Date(2026, 9, 29, 0, 0, 0, 0, time.UTC)