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 · 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/ratelimit.go +168

@@ -0,0 +1,168 @@
1 package web
2
3 // Rate limits for the authentication surface.
4 //
5 // The passkey ceremonies are the only endpoints an anonymous client can make the
6 // instance work on: an assertion costs elliptic-curve verification, a begin costs
7 // a WebAuthn challenge and a row in the ceremony map, and registration writes an
8 // account. They are also the endpoints worth guessing against. Everything else on
9 // the web is a session-authenticated read or a CSRF-guarded write.
10 //
11 // The limiter is a fixed window per key: a counter and the moment it resets. That
12 // is coarse compared with a bucket, but it is one number per address per minute,
13 // it cannot drift, and the size of the map is bounded by sweeping expired entries
14 // every time the table grows. Two keys are used: the client address, and the
15 // account name a begin request claims. The address bound is what stops a spray
16 // from one host; the account bound is what stops a slow guess at one name from
17 // many hosts from getting a faster answer than the same guess from one.
18
19 import (
20 "fmt"
21 "net"
22 "net/http"
23 "strconv"
24 "strings"
25 "sync"
26 "time"
27 )
28
29 const (
30 // Ceremony begins per address and per minute. A person signing in does one;
31 // a client retrying a flaky authenticator does a handful.
32 authBeginsPerAddress = 20
33 // Ceremony begins per account name and per minute. An account with a working
34 // passkey never needs more than a few.
35 authBeginsPerAccount = 10
36 // Ceremony finishes per address and per minute. A finish is one signature
37 // verification, and a browser may retry it.
38 authFinishesPerAddress = 60
39 // Registrations per address and per minute. Each one writes an account, so the
40 // budget is small even for a public instance.
41 registrationsPerAddress = 5
42 // authLimitWindow is the window every budget above is counted in.
43 authLimitWindow = time.Minute
44 // maxLimitKeys bounds the table. Keys are addresses and account names; the
45 // sweep drops the ones whose window has passed, and a table past this size is
46 // cleared rather than grown, because the alternative is memory an anonymous
47 // client chooses.
48 maxLimitKeys = 1 << 16
49 )
50
51 // rateLimiter counts events per key inside one window.
52 type rateLimiter struct {
53 mu sync.Mutex
54 limit int
55 window time.Duration
56 now func() time.Time
57 m map[string]*rateEntry
58 }
59
60 type rateEntry struct {
61 count int
62 until time.Time
63 }
64
65 func newRateLimiter(limit int, window time.Duration) *rateLimiter {
66 return &rateLimiter{limit: limit, window: window, now: time.Now, m: map[string]*rateEntry{}}
67 }
68
69 // allow records one attempt for key and reports whether it is inside the budget.
70 // When it is not, the second result is how long the caller should wait.
71 func (l *rateLimiter) allow(key string) (bool, time.Duration) {
72 if l == nil || l.limit <= 0 || key == "" {
73 return true, 0
74 }
75 now := l.now().UTC()
76 l.mu.Lock()
77 defer l.mu.Unlock()
78 if e, ok := l.m[key]; ok && now.Before(e.until) {
79 if e.count >= l.limit {
80 return false, e.until.Sub(now)
81 }
82 e.count++
83 return true, 0
84 }
85 if len(l.m) >= maxLimitKeys {
86 l.sweep(now)
87 }
88 l.m[key] = &rateEntry{count: 1, until: now.Add(l.window)}
89 return true, 0
90 }
91
92 // sweep drops expired entries. It is called when the table is full, so the work is
93 // proportional to the number of keys a client made us remember.
94 func (l *rateLimiter) sweep(now time.Time) {
95 for k, e := range l.m {
96 if !now.Before(e.until) {
97 delete(l.m, k)
98 }
99 }
100 if len(l.m) >= maxLimitKeys {
101 // Still full of live windows: start over. A client that forced this has
102 // already spent the requests, and the alternative is unbounded memory.
103 l.m = map[string]*rateEntry{}
104 }
105 }
106
107 // authLimits is the set of budgets the authentication handlers share.
108 type authLimits struct {
109 beginAddress *rateLimiter
110 beginAccount *rateLimiter
111 finish *rateLimiter
112 register *rateLimiter
113 }
114
115 func newAuthLimits() authLimits {
116 return authLimits{
117 beginAddress: newRateLimiter(authBeginsPerAddress, authLimitWindow),
118 beginAccount: newRateLimiter(authBeginsPerAccount, authLimitWindow),
119 finish: newRateLimiter(authFinishesPerAddress, authLimitWindow),
120 register: newRateLimiter(registrationsPerAddress, authLimitWindow),
121 }
122 }
123
124 // clientAddress is the key the address budgets count under. Behind a trusted proxy
125 // the configured header is the client; without one it is the connection. The same
126 // header drives the anonymous Git transport's per-client limit, so an instance
127 // with a proxy sets it once and both surfaces follow.
128 func (w *Web) clientAddress(r *http.Request) string {
129 if w.Cfg != nil {
130 if header := strings.TrimSpace(w.Cfg.GitHTTPClientIPHeader); header != "" {
131 if v := strings.TrimSpace(r.Header.Get(header)); v != "" {
132 // X-Forwarded-For grows left to right and the leftmost entry is the
133 // client; a value that is a list is split rather than used whole.
134 if i := strings.IndexByte(v, ','); i >= 0 {
135 v = strings.TrimSpace(v[:i])
136 }
137 return v
138 }
139 }
140 }
141 host, _, err := net.SplitHostPort(r.RemoteAddr)
142 if err != nil {
143 return r.RemoteAddr
144 }
145 return host
146 }
147
148 // limit answers 429 when the budget for key is used up and reports whether the
149 // handler should continue. The message says how long to wait, because a limit
150 // nobody can see is a wall.
151 func (w *Web) limit(rw http.ResponseWriter, r *http.Request, l *rateLimiter, key string) bool {
152 ok, wait := l.allow(key)
153 if ok {
154 return true
155 }
156 seconds := int(wait.Seconds() + 0.5)
157 if seconds < 1 {
158 seconds = 1
159 }
160 rw.Header().Set("Retry-After", strconv.Itoa(seconds))
161 if w.Logger != nil {
162 w.Logger("auth limit: %s %s from %s", r.Method, loggablePath(r.URL.Path), w.clientAddress(r))
163 }
164 w.noStore(rw)
165 writeJSONError(rw, http.StatusTooManyRequests,
166 fmt.Sprintf("too many attempts; try again in %d second(s)", seconds))
167 return false
168 }