Commit f6981be6cb

f6981be6cb0fc43f165faaa491c1bc234b744390

parent: 7f2fd45218

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

cmc <hello@cleberg.net> · 2026-09-06 18:13 UTC

notify, web, wiki: log dead-lettered mail by queue row, not recipient

The dead-letter path logged the plaintext address, and the relay's error
usually quotes it too, so dropping the field alone would have moved the
leak rather than closed it. Both are now redacted; the row id identifies
the mail and /admin maps it back.

Closes #173

Layout: unified · split

.gitbay/wiki/Admin.org +6
@@ -252,6 +252,12 @@ no =queues= key at all.
252In accounts mode the same read renders at =/admin=, linked from the rail 252In accounts mode the same read renders at =/admin=, linked from the rail
253for admins. Anyone else gets a 404 there. 253for admins. Anyone else gets a 404 there.
254 254
255A dead-lettered mail is logged as =notification dead-lettered mail=<id>=,
256with the address redacted out of the relay's error. The id is the queue
257row: find it in the Mail table on =/admin=, or in =dashboard --json=,
258where the recipient and the unredacted error are. That is deliberate —
259see the Threat-Model page.
260
255* Maintenance 261* Maintenance
256 262
257#+begin_src sh 263#+begin_src sh
.gitbay/wiki/Threat-Model.org +7
@@ -20,6 +20,13 @@ log and hardening notes in [[Admin]].
20 registration invites, and API tokens travel on stdin or in request 20 registration invites, and API tokens travel on stdin or in request
21 bodies, never as command arguments (visible in =/proc=) or query 21 bodies, never as command arguments (visible in =/proc=) or query
22 strings. Tokens are stored only as SHA-256 hashes. 22 strings. Tokens are stored only as SHA-256 hashes.
23- *Name a mail recipient in the log.* A queued mail is logged by its
24 queue row id, never by address, and the relay's own error is redacted
25 before it is logged because a rejection usually quotes the address it
26 rejected. The unredacted error and the address stay on the row, which
27 an instance admin reads on =/admin=: the database holds who, the log
28 holds which. One rule for every mail type — notifications, dependency
29 reports and login links all drain the same queue (krz/gitbay#173).
23- *Confirm the existence of private repositories.* Every surface answers 30- *Confirm the existence of private repositories.* Every surface answers
24 "not found" identically for a private repo and a nonexistent one — web 31 "not found" identically for a private repo and a nonexistent one — web
25 pages, git transport, control commands, release asset downloads. 32 pages, git transport, control commands, release asset downloads.
internal/notify/notify.go +16 −1
@@ -6,6 +6,7 @@ package notify
6import ( 6import (
7 "context" 7 "context"
8 "log/slog" 8 "log/slog"
9 "regexp"
9 "time" 10 "time"
10 11
11 "gitbay.org/gitbay/internal/config" 12 "gitbay.org/gitbay/internal/config"
@@ -53,7 +54,8 @@ func (m *Mailer) Run(ctx context.Context) {
53 attempt := q.Attempts + 1 54 attempt := q.Attempts + 1
54 if attempt >= m.MaxAttempts { 55 if attempt >= m.MaxAttempts {
55 m.St.MarkMailFailed(q.ID, err.Error(), nil) 56 m.St.MarkMailFailed(q.ID, err.Error(), nil)
56 slog.Warn("notification dead-lettered", "recipient", q.Recipient, "err", err) 57 slog.Warn("notification dead-lettered",
58 "mail", q.ID, "attempts", attempt, "err", redactAddresses(err.Error()))
57 } else { 59 } else {
58 next := time.Now().Add(m.RetryBase << (attempt - 1)) 60 next := time.Now().Add(m.RetryBase << (attempt - 1))
59 m.St.MarkMailFailed(q.ID, err.Error(), &next) 61 m.St.MarkMailFailed(q.ID, err.Error(), &next)
@@ -65,3 +67,16 @@ func (m *Mailer) Run(ctx context.Context) {
65 } 67 }
66 } 68 }
67} 69}
70
71// A relay's rejection usually quotes the address it rejected — "550 5.1.1
72// <x@y>: Recipient address rejected" — so dropping the recipient field
73// alone would not keep an address out of the log.
74var addressPat = regexp.MustCompile(`[^\s<>@,;:"]+@[^\s<>@,;:"]+`)
75
76// redactAddresses removes mail addresses from text bound for the log. The
77// unredacted error is still recorded on the queue row, where an instance
78// admin reads it on /admin: the database holds the address, the log does
79// not (#173).
80func redactAddresses(s string) string {
81 return addressPat.ReplaceAllString(s, "<address>")
82}
internal/notify/redact_test.go added +39
@@ -0,0 +1,39 @@
1package notify
2
3import "testing"
4
5// The log names the queue row, not the person. A relay's rejection quotes
6// the address it rejected, so the error text is redacted too — otherwise
7// dropping the recipient field would move the leak rather than close it
8// (#173).
9func TestRedactAddresses(t *testing.T) {
10 cases := []struct{ in, want string }{
11 {"550 5.1.1 <alice@example.test>: Recipient address rejected",
12 "550 5.1.1 <<address>>: Recipient address rejected"},
13 {"dial tcp 10.0.0.1:587: connect: connection refused",
14 "dial tcp 10.0.0.1:587: connect: connection refused"},
15 {"554 alice@a.test, bob@b.test both unknown",
16 "554 <address>, <address> both unknown"},
17 {"x509: certificate signed by unknown authority", "x509: certificate signed by unknown authority"},
18 }
19 for _, c := range cases {
20 if got := redactAddresses(c.in); got != c.want {
21 t.Errorf("redactAddresses(%q) = %q, want %q", c.in, got, c.want)
22 }
23 }
24}
25
26// Whatever the relay says, no @ survives into the log line.
27func TestRedactLeavesNoAddress(t *testing.T) {
28 for _, s := range []string{
29 "550 <a.b+tag@sub.example.co.uk> over quota",
30 `smtp: 553 "weird name"@host.test refused`,
31 "relay said: alice@example.test; bob@example.test",
32 } {
33 if got := redactAddresses(s); containsAddress(got) {
34 t.Errorf("address survived redaction: %q -> %q", s, got)
35 }
36 }
37}
38
39func containsAddress(s string) bool { return addressPat.MatchString(s) }
internal/web/templates/admin.html +2 −2
@@ -14,8 +14,8 @@
14{{with .Queues.Mail}} 14{{with .Queues.Mail}}
15<h2>Mail <span class="count">{{.Pending}}</span></h2> 15<h2>Mail <span class="count">{{.Pending}}</span></h2>
16<p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} failed{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p> 16<p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} failed{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p>
17{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Recipient</th><th>Subject</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody> 17{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Mail</th><th>Recipient</th><th>Subject</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody>
18{{range .Items}}<tr><td>{{.Recipient}}</td><td>{{.Subject}}</td><td>{{.Attempts}}</td><td>{{if .FailedAt}}failed {{when .FailedAt}}{{else}}retrying{{end}}</td><td>{{.LastError}}</td></tr> 18{{range .Items}}<tr><td class="mono">{{.ID}}</td><td>{{.Recipient}}</td><td>{{.Subject}}</td><td>{{.Attempts}}</td><td>{{if .FailedAt}}failed {{when .FailedAt}}{{else}}retrying{{end}}</td><td>{{.LastError}}</td></tr>
19{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or failed</p>{{end}} 19{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or failed</p>{{end}}
20{{end}} 20{{end}}
21 21