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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Harden the browser sign-in surface

edf9db6e07e5fd676049abcd41e78790b0953652 · Keenan Nemetz · 12d 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.go +64−10

@@ -135,7 +135,7 @@ func (s *statusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter }
135 135 // cleared rather than left to be retried on every page.
136 136 func (w *Web) Middleware(next http.Handler) http.Handler {
137 137 return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
138 w.securityHeaders(rw)
138 w.securityHeaders(rw, r)
139 139 if w.Sessions != nil {
140 140 sess, cookieName := w.sessionFromCookie(r)
141 141 if sess != nil {
@@ -165,7 +165,7 @@ func (w *Web) Middleware(next http.Handler) http.Handler {
165 165 return
166 166 }
167 167 if w.Logger != nil {
168 w.Logger("panic %s %s: %v", r.Method, r.URL.Path, v)
168 w.Logger("panic %s %s: %v", r.Method, loggablePath(r.URL.Path), v)
169 169 }
170 170 if sw.wroteHeader {
171 171 // The reply is already partial; let net/http close the connection
@@ -340,17 +340,46 @@ func splitHostPort(addr string) (host, port string) {
340 340 return addr, ""
341 341 }
342 342
343 func (w *Web) securityHeaders(rw http.ResponseWriter) {
343 func (w *Web) securityHeaders(rw http.ResponseWriter, r *http.Request) {
344 344 rw.Header().Set("X-Content-Type-Options", "nosniff")
345 345 rw.Header().Set("X-Frame-Options", "DENY")
346 346 rw.Header().Set("Referrer-Policy", "same-origin")
347 347 rw.Header().Set("Content-Security-Policy",
348 348 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
349 if w.Auth != nil && w.Auth.Enabled() {
349 // HSTS belongs to the transport, not to passkeys: an instance behind a TLS
350 // proxy with no browser sign-in is just as much an HTTPS instance, and a
351 // browser that learns that here cannot be downgraded later.
352 if w.servesHTTPS(r) {
350 353 rw.Header().Set("Strict-Transport-Security", "max-age=31536000")
351 354 }
352 355 }
353 356
357 // servesHTTPS reports whether browsers reach this instance over TLS: either this
358 // request arrived over it, or the canonical URL says a proxy terminates it. The
359 // configured https_addr is deliberately not enough on its own, because the same
360 // process may be answering ACME challenges on plain HTTP.
361 func (w *Web) servesHTTPS(r *http.Request) bool {
362 if r.TLS != nil {
363 return true
364 }
365 if w.Cfg == nil {
366 return false
367 }
368 if u, err := config.ParsePublicURL(w.Cfg.PublicURL); err == nil && u != nil {
369 return u.Scheme == "https"
370 }
371 return false
372 }
373
374 // noStore marks a page that must not be kept anywhere: an enrollment link, a
375 // sign-in form, an account page. The referrer is dropped as well, because the
376 // page's URL may itself be a secret a click would carry to somebody else.
377 func (w *Web) noStore(rw http.ResponseWriter) {
378 rw.Header().Set("Cache-Control", "no-store")
379 rw.Header().Set("Pragma", "no-cache")
380 rw.Header().Set("Referrer-Policy", "no-referrer")
381 }
382
354 383 func (w *Web) enabled() bool { return w.Auth != nil && w.Auth.Enabled() }
355 384
356 385 // registrationEnabled is a policy switch on top of passkey availability: an
@@ -441,16 +470,28 @@ func (w *Web) clearCookieFor(rw http.ResponseWriter, name string, secure bool) {
441 470 })
442 471 }
443 472
473 // checkCSRF is the JSON endpoints' check: the token must arrive in the
474 // X-Vole-CSRF header. It never parses the body, so a handler that has not bounded
475 // the request cannot be made to parse one here.
444 476 func (w *Web) checkCSRF(r *http.Request) bool {
445 pr := w.principal(r)
446 if pr == nil || pr.session == nil || pr.session.CSRF == "" {
447 return false
448 }
477 return w.csrfToken(r, r.Header.Get("X-Vole-CSRF"))
478 }
479
480 // csrfOK is the form endpoints' check: the header, or the csrf field of a body
481 // that readForm has already bounded and parsed.
482 func (w *Web) csrfOK(r *http.Request) bool {
449 483 got := r.Header.Get("X-Vole-CSRF")
450 484 if got == "" {
451 _ = r.ParseForm()
452 485 got = r.PostFormValue("csrf")
453 486 }
487 return w.csrfToken(r, got)
488 }
489
490 func (w *Web) csrfToken(r *http.Request, got string) bool {
491 pr := w.principal(r)
492 if pr == nil || pr.session == nil || pr.session.CSRF == "" || got == "" {
493 return false
494 }
454 495 return subtle.ConstantTimeCompare([]byte(got), []byte(pr.session.CSRF)) == 1
455 496 }
456 497
@@ -494,6 +535,13 @@ type ceremony struct {
494 535 Expires time.Time
495 536 }
496 537
538 // maxCeremonies bounds the in-flight ceremony map. Without a bound the map is
539 // memory an anonymous client can grow: each begin is a few hundred bytes, and a
540 // client that never finishes one never releases it. Five minutes of TTL and a cap
541 // mean the store is a fixed-size window, and a begin past the cap is refused
542 // rather than accepted into an unbounded table.
543 const maxCeremonies = 4096
544
497 545 type ceremonyStore struct {
498 546 mu sync.Mutex
499 547 m map[string]ceremony
@@ -508,13 +556,19 @@ func (s *ceremonyStore) put(c ceremony) (string, error) {
508 556 }
509 557 key := base64.RawURLEncoding.EncodeToString(id)
510 558 c.Expires = time.Now().UTC().Add(ceremonyTTL)
559 now := time.Now().UTC()
511 560 s.mu.Lock()
512 561 defer s.mu.Unlock()
562 // Sweep on every put: expired ceremonies are dropped the moment they are no
563 // longer usable, so a slow client cannot keep the table full.
513 564 for k, v := range s.m {
514 if time.Now().UTC().After(v.Expires) {
565 if now.After(v.Expires) {
515 566 delete(s.m, k)
516 567 }
517 568 }
569 if len(s.m) >= maxCeremonies {
570 return "", fmt.Errorf("too many sign-in attempts in progress; try again in a few minutes")
571 }
518 572 s.m[key] = c
519 573 return key, nil
520 574 }