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 | |