Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Harden the browser sign-in surface

edf9db6e07e5fd676049abcd41e78790b0953652 · Keenan Nemetz · 13d ago · parent 62b59a76

Harden the browser sign-in surface

The passkey ceremonies were the one place an anonymous client could make
the instance work, and they answered more than they had to. Sign-in told a
stranger whether an account existed, nothing bounded how often a client
could ask, an invite token sat in the request path where every access log
would keep it, and a session lookup hashed whatever cookie it was handed.

Sign-in no longer answers the question. An unknown account, a retired one
and an account with no passkey all get an ordinary assertion request
naming one credential that will not be found, which is a shape WebAuthn
cannot distinguish from a real account. Only finishing the ceremony fails,
generically.

The ceremonies are rate-limited per address (20 begins a minute, 60
finishes) and per account name (10 begins), and registration per address
(5), with 429 and Retry-After past the budget. The in-flight ceremony map
is capped at 4096 entries, swept on every put, five-minute TTL, so a
client that never finishes one cannot grow it. The key is the configured
trusted-proxy header when there is one, the connection otherwise.

Enrollment tokens moved into the URL fragment: /invite#<token> is never
sent to the server, so it reaches no log of ours and no proxy access log,
and no referrer carries it. Links in the old /invite/<token> shape are
redirected into the fragment, and the paths this instance does log are
redacted. The page itself renders before the token is known, so the
account name appears only after the ceremony.

CSRF checks no longer parse the request body. JSON endpoints require the
X-Vole-CSRF header; form endpoints parse a body that readForm has already
bounded with MaxBytesReader, and then compare the field or the header.
Logout bounds its body before parsing and answers a client with no
session the same way it answers a successful sign-out.

Sign-in, registration, enrollment, the account page and the admin invite
page are no-store, no-cache and no-referrer. HSTS now follows the
transport rather than the passkey feature: a request over TLS, or an
https public_url, is an HTTPS instance whether or not browser sign-in is
configured.

Sessions refuse a cookie that is not the shape this store issues (base64url,
at most 64 characters) before hashing or querying it, and the stored
User-Agent is cut to 256 bytes.

A passkey whose signature counter does not advance is refused at sign-in
and recorded as auth.clone-warning. The policy is documented; the trade is
that an authenticator with a broken counter needs an admin to remove its
passkey.

Tests: the limiter's window and per-key budgets, 429 with Retry-After on a
real begin, the decoy's shape and its silence about account state, the
ceremony cap and its sweep, the JSON endpoints refusing a form CSRF field,
HSTS across TLS/proxy/http, no-store on the auth pages, the invite token
redacted from a logged server error, the legacy invite redirect, logout
refusing an oversized body, the account page's headers, session token
shape and User-Agent truncation.

server/web/auth_handlers.go +113−33

@@ -1,6 +1,7 @@
1 1 package web
2 2
3 3 import (
4 "crypto/rand"
4 5 "fmt"
5 6 "net/http"
6 7 "net/url"
@@ -18,6 +19,7 @@ import (
18 19 // LoginPage renders the passkey form. When public_url is not HTTPS the page
19 20 // explains the missing configuration instead of pretending login works.
20 21 func (w *Web) LoginPage(rw http.ResponseWriter, r *http.Request) {
22 w.noStore(rw)
21 23 next := safeNext(r.URL.Query().Get("next"))
22 24 w.render(rw, "login", struct {
23 25 Page
@@ -34,41 +36,35 @@ func (w *Web) LoginPage(rw http.ResponseWriter, r *http.Request) {
34 36 }, 0)
35 37 }
36 38
37 // InvitePage shows whose account an enrollment link belongs to. The link is
38 // validated here but not consumed; consuming happens only after the authenticator
39 // has produced a credential.
39 // InvitePage is the enrollment page. The token is in the URL fragment, which the
40 // browser never sends anywhere: it is read by the page's script and posted to
41 // InviteBegin, so the link itself does not put a one-time credential into this
42 // instance's logs, a proxy's access log, or the Referer of anything the page
43 // loads. The page therefore renders the same thing whether or not the token is
44 // valid; the answer comes from InviteBegin, which is where the ceremony starts.
40 45 func (w *Web) InvitePage(rw http.ResponseWriter, r *http.Request) {
41 token := r.PathValue("token")
42 acct, err := w.Ident.InviteAccount(token)
43 if err != nil {
44 w.render(rw, "invite", struct {
45 Page
46 Token string
47 Account *identity.Account
48 Enabled bool
49 Reason string
50 }{
51 Page: w.page(r, "Enrollment link"),
52 Enabled: w.enabled(),
53 Reason: authReason(w.Auth),
54 }, http.StatusNotFound)
55 return
56 }
46 w.noStore(rw)
57 47 w.render(rw, "invite", struct {
58 48 Page
59 Token string
60 Account *identity.Account
61 49 Enabled bool
62 50 Reason string
63 51 }{
64 Page: w.page(r, "Join "+acct.Name),
65 Token: token,
66 Account: acct,
52 Page: w.page(r, "Enrollment link"),
67 53 Enabled: w.enabled(),
68 54 Reason: authReason(w.Auth),
69 55 }, 0)
70 56 }
71 57
58 // LegacyInvitePage moves a link in the old /invite/<token> shape into the fragment
59 // and sends the browser to the page. The token in the request path is the reason
60 // the shape changed; this keeps links that were already sent working, and the
61 // instance's own logs redact the path either way.
62 func (w *Web) LegacyInvitePage(rw http.ResponseWriter, r *http.Request) {
63 w.noStore(rw)
64 token := r.PathValue("token")
65 http.Redirect(rw, r, "/invite#"+url.PathEscape(token), http.StatusSeeOther)
66 }
67
72 68 func authReason(a *Auth) string {
73 69 if a == nil {
74 70 return "browser sign-in is not configured"
@@ -88,10 +84,14 @@ func safeNext(next string) string {
88 84 }
89 85
90 86 func (w *Web) LoginBegin(rw http.ResponseWriter, r *http.Request) {
87 w.noStore(rw)
91 88 if !w.enabled() {
92 89 writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth))
93 90 return
94 91 }
92 if !w.limit(rw, r, w.limits.beginAddress, "login:"+w.clientAddress(r)) {
93 return
94 }
95 95 var body struct {
96 96 Username string `json:"username"`
97 97 }
@@ -99,31 +99,63 @@ func (w *Web) LoginBegin(rw http.ResponseWriter, r *http.Request) {
99 99 writeJSONError(rw, http.StatusBadRequest, err.Error())
100 100 return
101 101 }
102 acct, err := w.Ident.Find(strings.TrimSpace(body.Username))
102 name := strings.TrimSpace(body.Username)
103 if !w.limit(rw, r, w.limits.beginAccount, "login:"+name) {
104 return
105 }
106 acct, err := w.Ident.Find(name)
103 107 if err != nil || !acct.Active() {
104 writeJSONError(rw, http.StatusUnauthorized, "no passkey is available for that account")
108 w.beginDecoyLogin(rw, name)
105 109 return
106 110 }
107 111 user, err := w.waUser(acct)
108 112 if err != nil || len(user.creds) == 0 {
109 writeJSONError(rw, http.StatusUnauthorized, "no passkey is available for that account")
113 w.beginDecoyLogin(rw, name)
110 114 return
111 115 }
116 w.beginLoginCeremony(rw, user, acct.Name)
117 }
118
119 // beginDecoyLogin answers a sign-in for an account that does not exist, is
120 // retired, or has no passkey with the same shape a real one gets: an ordinary
121 // assertion request naming one credential that will not be found. WebAuthn itself
122 // cannot tell "no such account" from "no matching credential", and neither may
123 // this endpoint, or it becomes a way to ask whether somebody has an account here.
124 func (w *Web) beginDecoyLogin(rw http.ResponseWriter, name string) {
125 decoy := make([]byte, 32)
126 if _, err := rand.Read(decoy); err != nil {
127 writeJSONError(rw, http.StatusInternalServerError, "cannot begin sign-in")
128 return
129 }
130 user := &waUser{
131 account: &identity.Account{Name: name, Status: "active"},
132 creds: []webauthn.Credential{{ID: decoy}},
133 }
134 w.beginLoginCeremony(rw, user, name)
135 }
136
137 func (w *Web) beginLoginCeremony(rw http.ResponseWriter, user *waUser, name string) {
112 138 assertion, data, err := w.Auth.WA.BeginLogin(user)
113 139 if err != nil {
114 writeJSONError(rw, http.StatusBadRequest, "cannot begin login: "+err.Error())
140 writeJSONError(rw, http.StatusBadRequest, "cannot begin sign-in")
115 141 return
116 142 }
117 if err := w.beginCeremony(rw, ceremony{Kind: "login", Data: *data, Name: acct.Name}, assertion); err != nil {
118 writeJSONError(rw, http.StatusInternalServerError, "cannot store the ceremony")
143 if err := w.beginCeremony(rw, ceremony{Kind: "login", Data: *data, Name: name}, assertion); err != nil {
144 w.Logger("auth: cannot store a login ceremony: %v", err)
145 writeJSONError(rw, http.StatusServiceUnavailable,
146 "too many sign-in attempts in progress; try again in a few minutes")
119 147 }
120 148 }
121 149
122 150 func (w *Web) LoginFinish(rw http.ResponseWriter, r *http.Request) {
151 w.noStore(rw)
123 152 if !w.enabled() {
124 153 writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth))
125 154 return
126 155 }
156 if !w.limit(rw, r, w.limits.finish, "login:"+w.clientAddress(r)) {
157 return
158 }
127 159 c, ok := w.takeCeremony(rw, r)
128 160 if !ok {
129 161 return
@@ -147,6 +179,18 @@ func (w *Web) LoginFinish(rw http.ResponseWriter, r *http.Request) {
147 179 writeJSONError(rw, http.StatusUnauthorized, "sign-in failed")
148 180 return
149 181 }
182 // A passkey's signature counter only goes up. If it did not, two copies of the
183 // key exist and one of them may not be the account holder's, so the sign-in is
184 // refused rather than answered with a session either copy could have asked for.
185 // The ceremony has already proved possession, which is why this is an audited
186 // event rather than a silent 401.
187 if cred.Authenticator.CloneWarning {
188 w.audit("auth.clone-warning", acct.Name, acct.Name,
189 "the passkey's signature counter did not advance; sign-in refused")
190 writeJSONError(rw, http.StatusUnauthorized,
191 "this passkey looks copied: its signature counter did not advance. Ask an instance admin to review it.")
192 return
193 }
150 194 if err := w.Ident.UpdateCredential(cred.ID, cred.Authenticator.SignCount, time.Now().UTC()); err != nil {
151 195 writeJSONError(rw, http.StatusInternalServerError, "cannot update the passkey")
152 196 return
@@ -161,10 +205,14 @@ func (w *Web) LoginFinish(rw http.ResponseWriter, r *http.Request) {
161 205 }
162 206
163 207 func (w *Web) InviteBegin(rw http.ResponseWriter, r *http.Request) {
208 w.noStore(rw)
164 209 if !w.enabled() {
165 210 writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth))
166 211 return
167 212 }
213 if !w.limit(rw, r, w.limits.beginAddress, "invite:"+w.clientAddress(r)) {
214 return
215 }
168 216 var body struct {
169 217 Token string `json:"token"`
170 218 }
@@ -198,10 +246,14 @@ func (w *Web) InviteBegin(rw http.ResponseWriter, r *http.Request) {
198 246 }
199 247
200 248 func (w *Web) InviteFinish(rw http.ResponseWriter, r *http.Request) {
249 w.noStore(rw)
201 250 if !w.enabled() {
202 251 writeJSONError(rw, http.StatusServiceUnavailable, authReason(w.Auth))
203 252 return
204 253 }
254 if !w.limit(rw, r, w.limits.finish, "invite:"+w.clientAddress(r)) {
255 return
256 }
205 257 c, ok := w.takeCeremony(rw, r)
206 258 if !ok {
207 259 return
@@ -243,6 +295,7 @@ func (w *Web) InviteFinish(rw http.ResponseWriter, r *http.Request) {
243 295 // registration. There is no half-open state where a page loads but the POST is
244 296 // refused for a different reason.
245 297 func (w *Web) RegisterPage(rw http.ResponseWriter, r *http.Request) {
298 w.noStore(rw)
246 299 if !w.registrationEnabled() {
247 300 w.notFound(rw, r)
248 301 return
@@ -268,6 +321,10 @@ func (w *Web) registrationRole() (access.Role, error) {
268 321 }
269 322
270 323 func (w *Web) RegisterBegin(rw http.ResponseWriter, r *http.Request) {
324 w.noStore(rw)
325 if !w.limit(rw, r, w.limits.register, "register:"+w.clientAddress(r)) {
326 return
327 }
271 328 if !w.registrationEnabled() {
272 329 writeJSONError(rw, http.StatusServiceUnavailable, "open registration is disabled")
273 330 return
@@ -309,6 +366,10 @@ func (w *Web) RegisterBegin(rw http.ResponseWriter, r *http.Request) {
309 366 }
310 367
311 368 func (w *Web) RegisterFinish(rw http.ResponseWriter, r *http.Request) {
369 w.noStore(rw)
370 if !w.limit(rw, r, w.limits.register, "register:"+w.clientAddress(r)) {
371 return
372 }
312 373 if !w.registrationEnabled() {
313 374 writeJSONError(rw, http.StatusServiceUnavailable, "open registration is disabled")
314 375 return
@@ -348,9 +409,19 @@ func (w *Web) RegisterFinish(rw http.ResponseWriter, r *http.Request) {
348 409 }
349 410
350 411 func (w *Web) Logout(rw http.ResponseWriter, r *http.Request) {
351 if !w.checkCSRF(r) {
352 http.Error(rw, "bad request", http.StatusForbidden)
353 return
412 // Signing out carries only a CSRF field, so the body is bounded before
413 // anything parses it. A client with no session has nothing to sign out of and
414 // gets the same answer as a successful one.
415 if pr := w.principal(r); pr != nil {
416 r.Body = http.MaxBytesReader(rw, r.Body, maxTinyForm)
417 if err := r.ParseForm(); err != nil {
418 http.Error(rw, "cannot read the form", http.StatusBadRequest)
419 return
420 }
421 if !w.csrfOK(r) {
422 http.Error(rw, "bad request", http.StatusForbidden)
423 return
424 }
354 425 }
355 426 if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" && w.Sessions != nil {
356 427 _ = w.Sessions.Revoke(c.Value)
@@ -372,6 +443,7 @@ func (w *Web) Logout(rw http.ResponseWriter, r *http.Request) {
372 443 }
373 444
374 445 func (w *Web) AccountPage(rw http.ResponseWriter, r *http.Request) {
446 w.noStore(rw)
375 447 pr, ok := w.requireHTMLUser(rw, r)
376 448 if !ok {
377 449 return
@@ -403,6 +475,10 @@ func (w *Web) AccountPage(rw http.ResponseWriter, r *http.Request) {
403 475 }
404 476
405 477 func (w *Web) AddPasskeyBegin(rw http.ResponseWriter, r *http.Request) {
478 w.noStore(rw)
479 if !w.limit(rw, r, w.limits.finish, "passkey:"+w.clientAddress(r)) {
480 return
481 }
406 482 pr, ok := w.requireAPIUser(rw, r)
407 483 if !ok {
408 484 return
@@ -443,6 +519,10 @@ func (w *Web) AddPasskeyBegin(rw http.ResponseWriter, r *http.Request) {
443 519 }
444 520
445 521 func (w *Web) AddPasskeyFinish(rw http.ResponseWriter, r *http.Request) {
522 w.noStore(rw)
523 if !w.limit(rw, r, w.limits.finish, "passkey:"+w.clientAddress(r)) {
524 return
525 }
446 526 pr, ok := w.requireAPIUser(rw, r)
447 527 if !ok {
448 528 return