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.

forge/session/session.go +43−2

@@ -23,6 +23,7 @@ import (
23 23 "fmt"
24 24 "os"
25 25 "time"
26 "unicode/utf8"
26 27
27 28 _ "modernc.org/sqlite"
28 29
@@ -39,6 +40,14 @@ const (
39 40 LastSeenEvery = 5 * time.Minute
40 41
41 42 tokenBytes = 32
43 // maxTokenBytes bounds a submitted cookie value. A token is exactly 43 base64
44 // characters; the margin is for a future longer token, not for a client, and
45 // anything past it is refused before it is hashed or looked up.
46 maxTokenBytes = 64
47 // maxUserAgentBytes bounds the stored User-Agent. It is client-supplied and
48 // shown to an operator on the account page, so it is a display field with a
49 // length an attacker chooses unless it is cut.
50 maxUserAgentBytes = 256
42 51 )
43 52
44 53 var (
@@ -133,6 +142,7 @@ func (s *Store) Create(name string, credentialID []byte, userAgent string) (*Ses
133 142 if err != nil {
134 143 return nil, err
135 144 }
145 userAgent = truncateBytes(userAgent, maxUserAgentBytes)
136 146 now := s.now()
137 147 expires := now.Add(s.absolute())
138 148 sess := &Session{
@@ -177,7 +187,7 @@ SELECT ?, name, ?, ?, ?, ?, ? FROM users WHERE name=? AND status='active'`,
177 187 // per LastSeenEvery.
178 188 func (s *Store) Lookup(token string) (*Session, error) {
179 189 token = trimToken(token)
180 if token == "" {
190 if !validToken(token) {
181 191 return nil, ErrNoSession
182 192 }
183 193 now := s.now()
@@ -227,7 +237,7 @@ WHERE s.token_hash = ?`, hashToken(token)).
227 237 // error, and an unknown token reveals nothing.
228 238 func (s *Store) Revoke(token string) error {
229 239 token = trimToken(token)
230 if token == "" {
240 if !validToken(token) {
231 241 return nil
232 242 }
233 243 var name string
@@ -414,6 +424,37 @@ func credentialDetail(id []byte) string {
414 424 return credentialIDDB(id)
415 425 }
416 426
427 // validToken reports whether a cookie value could be a session token at all. The
428 // length check is the point: a value that is not the shape this store issues is
429 // rejected before it is hashed or queried, so an oversized cookie cannot make the
430 // server work for it.
431 func validToken(token string) bool {
432 if token == "" || len(token) > maxTokenBytes {
433 return false
434 }
435 for i := 0; i < len(token); i++ {
436 c := token[i]
437 if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
438 c == '-' || c == '_' {
439 continue
440 }
441 return false
442 }
443 return true
444 }
445
446 // truncateBytes cuts s to at most n bytes without splitting a rune.
447 func truncateBytes(s string, n int) string {
448 if len(s) <= n {
449 return s
450 }
451 cut := n
452 for cut > 0 && !utf8.RuneStart(s[cut]) {
453 cut--
454 }
455 return s[:cut]
456 }
457
417 458 func trimToken(s string) string {
418 459 // Cookie values are already opaque; trimming guards against a stray newline
419 460 // in a hand-written test or CLI call, not against a valid token.