Commit 3f7a91e284

3f7a91e28444fe0ea1c42d105fd7e508f518e4cc

parent: 090dc2eb20

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-05 03:44 UTC

web: close the login-link timing side channel, cover the username path

RequestLoginLink sent mail synchronously, so a hit cost a full SMTP round
trip to the relay while every miss returned after one local query --
separable over the network in a handful of samples, leaking exactly what
the response body was built not to. Mail now goes out from a goroutine so
every case returns on the same DB-bound path.

Also: cover the username branch (untested until now), pin the throttle
test to the exact budget instead of an upper bound that a broken endpoint
would also satisfy, and check the throttled response against the first
rather than assuming the code already does the right thing there.

Ref #155

Layout: unified · split

e2e/emaillogin_test.go +46 −3
@@ -5,6 +5,7 @@ import (
55 "net/url"
66 "strings"
77 "testing"
8 "time"
89)
910
1011// A person with no SSH key can still get into the web UI: they ask for a
@@ -53,6 +54,21 @@ func TestEmailLogin(t *testing.T) {
5354 if _, body := browserGet(t, second, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") {
5455 t.Error("login link worked twice")
5556 }
57
58 // The identifier can also be a bare username; it resolves to the
59 // account's verified address the same way an email address does.
60 status, body = browserPost(t, browser, inst.base()+"/login",
61 url.Values{"identifier": {"dana"}})
62 if status != 200 || !strings.Contains(body, "on its way") {
63 t.Fatalf("POST /login by username: %d", status)
64 }
65 deadline := time.Now().Add(2 * time.Second)
66 for time.Now().Before(deadline) && len(smtp.mailTo("dana@example.test")) < 2 {
67 time.Sleep(25 * time.Millisecond)
68 }
69 if n := len(smtp.mailTo("dana@example.test")); n != 2 {
70 t.Fatalf("login by username did not mail a second link: got %d mails, want 2", n)
71 }
5672}
5773
5874// The response must not say whether an account exists. A different status,
@@ -77,6 +93,8 @@ func TestEmailLoginDoesNotEnumerate(t *testing.T) {
7793 url.Values{"identifier": {"eve@example.test"}})
7894 empty, bodyEmpty := browserPost(t, browser, inst.base()+"/login",
7995 url.Values{"identifier": {""}})
96 absentUser, bodyAbsentUser := browserPost(t, browser, inst.base()+"/login",
97 url.Values{"identifier": {"nosuchuser"}})
8098
8199 for _, c := range []struct {
82100 name string
@@ -86,6 +104,7 @@ func TestEmailLoginDoesNotEnumerate(t *testing.T) {
86104 {"absent", absent, bodyAbsent},
87105 {"unverified", unver, bodyUnver},
88106 {"empty", empty, bodyEmpty},
107 {"absent-username", absentUser, bodyAbsentUser},
89108 } {
90109 if c.status != real1 || c.body != bodyReal {
91110 t.Errorf("%s differs from a real address: status %d vs %d", c.name, c.status, real1)
@@ -110,11 +129,35 @@ func TestEmailLoginThrottled(t *testing.T) {
110129 "--email", "dana@example.test", "--verified")
111130
112131 browser := newBrowser(t)
132 var first, sixth string
113133 for i := 0; i < 6; i++ {
114 browserPost(t, browser, inst.base()+"/login",
134 _, body := browserPost(t, browser, inst.base()+"/login",
115135 url.Values{"identifier": {"dana@example.test"}})
136 switch i {
137 case 0:
138 first = body
139 case 5:
140 sixth = body
141 }
142 }
143 // Being over the throttle is one more class whose response must not
144 // differ from an ordinary request.
145 if sixth != first {
146 t.Error("the throttled response differs from the first")
147 }
148
149 // Mail goes out from a goroutine, not on the request path, so give the
150 // last permitted one time to land before counting.
151 var n int
152 deadline := time.Now().Add(2 * time.Second)
153 for time.Now().Before(deadline) {
154 n = len(smtp.mailTo("dana@example.test"))
155 if n >= 5 {
156 break
157 }
158 time.Sleep(25 * time.Millisecond)
116159 }
117 if n := len(smtp.mailTo("dana@example.test")); n > 5 {
118 t.Fatalf("sent %d login mails in an hour, want at most 5", n)
160 if n != 5 {
161 t.Fatalf("sent %d login mails in an hour, want exactly 5", n)
119162 }
120163}
internal/control/loginlink.go +19 −2
@@ -2,6 +2,7 @@ package control
22
33import (
44 "fmt"
5 "log/slog"
56 "strings"
67 "time"
78
@@ -12,7 +13,11 @@ import (
1213
1314// maxLoginLinksPerHour bounds what one account's address can be made to
1415// receive. It matches maxEmailAddsPerHour: enough for a person who mistypes
15// and retries, nothing for a script.
16// and retries, nothing for a script. The counter is shared with SSH-minted
17// links, not just these: CountLoginTokensSince counts every row in
18// login_tokens, and "web login" over SSH inserts into that same table
19// without consulting this bound, so five "ssh git@host web login" calls in
20// an hour also spend an account's budget here.
1621const maxLoginLinksPerHour = 5
1722
1823// loginLinkTTL is longer than the five minutes an SSH-minted link gets.
@@ -82,5 +87,17 @@ func RequestLoginLink(cfg config.Config, st *store.Store, identifier string) err
8287 "Open this link within 15 minutes. It works once:\n\n %s/login?token=%s\n\n"+
8388 "If this wasn't you, ignore this mail. Nothing has changed on the account.\n",
8489 host, strings.TrimSuffix(cfg.Server.SiteURL, "/"), token)
85 return mail.Send(cfg, address, "log in to "+host, body)
90 subject := "log in to " + host
91
92 // Sent in the background: mail.Send is a synchronous SMTP round trip to
93 // the relay, tens to hundreds of milliseconds against the sub-millisecond
94 // a miss takes to answer. Returning before it completes keeps every case
95 // — hit, miss, unverified, throttled — on the same DB-bound path, so
96 // response time cannot answer what the response body is built not to.
97 go func() {
98 if err := mail.Send(cfg, address, subject, body); err != nil {
99 slog.Error("login link mail", "address", address, "err", err)
100 }
101 }()
102 return nil
86103}