Commit 90d0cc2aad
Verified · cmc ci/build: success ci/test: success
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] | ||
| 151 | Reply 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. | ||
| 153 | Off unless =enabled=, and it requires =[mail] smtp_host=, since the | ||
| 154 | replies answer mail gitbayd sends. | ||
| 155 | |||
| 156 | #+begin_src toml | ||
| 157 | [mail.inbound] | ||
| 158 | enabled = true | ||
| 159 | imap_host = "imap.example.org" # host:port; 993, or 143 with tls = "starttls" | ||
| 160 | tls = "implicit" # or "starttls"; there is no plaintext setting | ||
| 161 | user = "reply@gitbay.example" | ||
| 162 | password_file = "/etc/gitbay/imap.pass" # one line, mode 0600, owned by gitbayd's user | ||
| 163 | mailbox = "INBOX" # default | ||
| 164 | poll_interval = "1m" # default; at least 10s | ||
| 165 | reply_address = "reply@gitbay.example" | ||
| 166 | trusted_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 | |||
| 211 | A reply is posted when all of these hold, checked when it is read: | ||
| 212 | |||
| 213 | 1. It is not an automatic reply (=Auto-Submitted=, =Precedence: bulk= | ||
| 214 | and the like). | ||
| 215 | 2. A recipient header carries a reply address whose token verifies | ||
| 216 | and has not expired (thirty days from the mail it came on). | ||
| 217 | 3. 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. | ||
| 221 | 4. =From= is one of that account's verified addresses, and, with | ||
| 222 | =trusted_authserv_id= set, the mail host authenticated it. | ||
| 223 | 5. 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). | ||
| 226 | 6. No earlier copy of the message (same =Message-ID=, account and | ||
| 227 | thread) posted. | ||
| 228 | 7. =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 | |||
| 232 | The 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 | ||
| 234 | reason and the =Message-ID=, never the message's content, at most sixty | ||
| 235 | a minute, and sends nothing back. See Threat-Model for why the token | ||
| 236 | and the sender address are both required. | ||
| 237 | |||
| 150 | ** [push] | 238 | ** [push] |
| 151 | Push notifications to Apple devices, delivered by gitbayd talking to | 239 | Push notifications to Apple devices, delivered by gitbayd talking to |
| 152 | APNs directly over HTTP/2, authenticated by an ES256 JWT signed with an | 240 | APNs 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 | ||
| 27 | Generation and hashing: =internal/store/sessions.go= (=NewToken=, | 28 | Generation 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 | |||
| 430 | browser has no way to ask Apple for one. =device list= and =device | 431 | browser has no way to ask Apple for one. =device list= and =device |
| 431 | remove= have no such limit and are yes on the web like the rest. | 432 | remove= have no such limit and are yes on the web like the rest. |
| 432 | 433 | ||
| 434 | Replying to issue and merge request mail posts a comment (#295) when | ||
| 435 | the instance reads a reply mailbox (=[mail.inbound]=) and the account | ||
| 436 | ran =notifications settings reply on=. The account page shows the | ||
| 437 | switch only on such an instance. The reply is itself a fourth surface | ||
| 438 | for =issue comment= and =mr comment= and nothing else: it is dispatched | ||
| 439 | as that command, so it cannot do what the command would refuse. | ||
| 440 | =admin mail inbound check= has no page, like the rest of instance | ||
| 441 | administration. | ||
| 442 | |||
| 433 | A login link is requested from the login page by username or verified | 443 | A login link is requested from the login page by username or verified |
| 434 | address, and arrives by mail: it works once and expires in fifteen | 444 | address, and arrives by mail: it works once and expires in fifteen |
| 435 | minutes. The row is =n/a= for the CLI because a terminal with a | 445 | minutes. 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 |
| 479 | filter and cursor, and promote, demote, disable and enable per row; | 490 | filter 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 | ||
| 82 | Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=. | 86 | Run =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. | |||
| 90 | Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies | 94 | Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies |
| 91 | no inbound HMAC. | 95 | no inbound HMAC. |
| 92 | 96 | ||
| 97 | * Reply by mail | ||
| 98 | |||
| 99 | When =[mail.inbound]= is on (#295), anyone can send mail to the reply | ||
| 100 | mailbox, and a posted reply is a comment written as an account. Two | ||
| 101 | things 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 | |||
| 135 | Access is judged when the reply is read, not when the mail was sent: | ||
| 136 | the reply is posted by dispatching =issue comment= or =mr comment= as | ||
| 137 | the account, so a revoked grant, a private repository, an archived | ||
| 138 | repository, a disabled or pending account, or reply by mail turned | ||
| 139 | off all refuse it. A =Message-ID= that already posted to a thread as an | ||
| 140 | account is not posted there again. Automatic replies (=Auto-Submitted=, =Precedence: bulk=) are | ||
| 141 | refused so an out-of-office responder cannot post. | ||
| 142 | |||
| 143 | Refusals send nothing back: no bounce, no error mail, so the mailbox | ||
| 144 | cannot be used to make the instance mail a forged sender | ||
| 145 | (backscatter). Each refusal is an audit row, =refused mail reply=, with | ||
| 146 | the reason and the =Message-ID= and none of the message's content, | ||
| 147 | bounded at sixty rows a minute. The IMAP connection is TLS or STARTTLS | ||
| 148 | with certificate verification; there is no plaintext setting. The | ||
| 149 | mailbox password is read from a 0600 file and never logged. The client | ||
| 150 | bounds what the server can make it hold: 10 MiB a message (refused by | ||
| 151 | size before fetching), about 11 MiB and a thousand responses a | ||
| 152 | command, after which the connection is closed; literals other than | ||
| 153 | the message body are read and discarded. The Reply-To address is | ||
| 154 | blanked 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 | ||
| 95 | Webhook delivery, GitHub-history import =--api-base=, mirror remotes | 158 | Webhook delivery, GitHub-history import =--api-base=, mirror remotes |
.gitbay/wiki/Users.org +16
| @@ -934,6 +934,22 @@ someone else. | |||
| 934 | You are never mailed about your own actions, and only verified primary | 934 | You are never mailed about your own actions, and only verified primary |
| 935 | addresses receive anything. Delivery retries on relay failure. | 935 | addresses receive anything. Delivery retries on relay failure. |
| 936 | 936 | ||
| 937 | =notifications settings reply on= lets you answer that mail: issue and | ||
| 938 | merge request mail then carries a =Reply-To= address, and replying to | ||
| 939 | it posts your reply as a comment on the thread, as you. Off by | ||
| 940 | default; the account page has the switch when the instance reads | ||
| 941 | replies, and turning it on is refused where it does not | ||
| 942 | (=[mail.inbound]= off). A reply is posted only when it comes from one | ||
| 943 | of your verified addresses, you can still comment on the thread, and | ||
| 944 | the mail it answers is less than thirty days old. Quoted text (lines | ||
| 945 | starting with =>=, the "On … wrote:" line and what follows it, the | ||
| 946 | header block Outlook quotes) and everything after a =-- = signature | ||
| 947 | line are removed, and the rest is stored as markdown. HTML-only mail is | ||
| 948 | not accepted; send plain text or the usual plain-and-HTML pair. A | ||
| 949 | refused reply gets no answer: nothing is mailed back. Each reply | ||
| 950 | address names you, so do not forward notification mail you would not | ||
| 951 | want answered from your own address. | ||
| 952 | |||
| 937 | Push is the same activity again, delivered to a phone: the iOS app | 953 | Push is the same activity again, delivered to a phone: the iOS app |
| 938 | registers a device, and =notifications settings push off= silences it | 954 | registers a device, and =notifications settings push off= silences it |
| 939 | the way =mail off= silences mail, without deregistering anything. | 955 | the 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/ | |||
| 16 | go test -run xxx -fuzz FuzzDecodeArmorAndParseSSHSig -fuzztime 10s ./internal/sig/ | 16 | go test -run xxx -fuzz FuzzDecodeArmorAndParseSSHSig -fuzztime 10s ./internal/sig/ |
| 17 | go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/ | 17 | go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/ |
| 18 | go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/ | 18 | go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/ |
| 19 | go test -run xxx -fuzz FuzzReply -fuzztime 10s ./internal/mailin/ | ||
| 20 | go test -run xxx -fuzz FuzzAuthResults -fuzztime 10s ./internal/mailin/ | ||
| 21 | go test -run xxx -fuzz FuzzReadResponse -fuzztime 10s ./internal/imapc/ | ||
| 19 | 22 | ||
| 20 | echo "== all clear ==" | 23 | echo "== all clear ==" |
internal/imapc/fuzz_test.go added +25
| @@ -0,0 +1,25 @@ | |||
| 1 | package imapc | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "bytes" | ||
| 6 | "net" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // FuzzReadResponse feeds server bytes to the response reader, literals | ||
| 11 | // included. | ||
| 12 | func 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 @@ | |||
| 1 | package mailin | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 16 | func 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 | |||
| 35 | var fuzzNow = time.Date(2026, 9, 29, 0, 0, 0, 0, time.UTC) | ||