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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 26m ago 4b8c9e1d
history blame raw 8.3 KiB
1package 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
19import (
20 "crypto/sha256"
21 "encoding/hex"
22 "fmt"
23 "net"
24 "net/http"
25 "strconv"
26 "strings"
27 "sync"
28 "time"
29)
30
31const (
32 // Ceremony begins per address and per minute. A person signing in does one;
33 // a client retrying a flaky authenticator does a handful.
34 authBeginsPerAddress = 20
35 // Ceremony begins per account name and per minute. An account with a working
36 // passkey never needs more than a few.
37 authBeginsPerAccount = 10
38 // Ceremony finishes per address and per minute. A finish is one signature
39 // verification, and a browser may retry it.
40 authFinishesPerAddress = 60
41 // Registrations per address and per minute. Each one writes an account, so the
42 // budget is small even for a public instance.
43 registrationsPerAddress = 5
44 // authLimitWindow is the window every budget above is counted in.
45 authLimitWindow = time.Minute
46 // defaultContributorWritesPerHour bounds an untrusted account's browser writes
47 // (issues, comments, requests, labels, state changes). A person discussing never
48 // comes near it; a script posting in a loop does. Trusted roles are not
49 // budgeted at all; see writeBudgeted.
50 defaultContributorWritesPerHour = 300
51 // maxLimitKeys bounds the table. Keys are addresses and account names; the
52 // sweep drops the ones whose window has passed, and a table past this size is
53 // cleared rather than grown, because the alternative is memory an anonymous
54 // client chooses.
55 maxLimitKeys = 1 << 16
56 // limitSweepEvery bounds how long expired keys can sit in the table before a
57 // sweep. Without it a table below the cap is only swept when it becomes full,
58 // so a client can grow it one key at a time for as long as it likes.
59 limitSweepEvery = 1024
60 // maxRateKeyBytes bounds what one key can add to the table. Account names and
61 // addresses are short; a key longer than this is hashed so a client-supplied
62 // string cannot decide how much memory the map holds.
63 maxRateKeyBytes = 256
64 // maxClientAddressBytes bounds the proxy header before it is used as a key or
65 // written to the log. The header is client-influenced when a proxy does not
66 // replace it, and the rate limit is not the place to trust its length.
67 maxClientAddressBytes = 64
68)
69
70// rateLimiter counts events per key inside one window.
71type rateLimiter struct {
72 mu sync.Mutex
73 limit int
74 window time.Duration
75 now func() time.Time
76 m map[string]*rateEntry
77}
78
79type rateEntry struct {
80 count int
81 until time.Time
82}
83
84func newRateLimiter(limit int, window time.Duration) *rateLimiter {
85 return &rateLimiter{limit: limit, window: window, now: time.Now, m: map[string]*rateEntry{}}
86}
87
88// allow records one attempt for key and reports whether it is inside the budget.
89// When it is not, the second result is how long the caller should wait.
90func (l *rateLimiter) allow(key string) (bool, time.Duration) {
91 if l == nil || l.limit <= 0 || key == "" {
92 return true, 0
93 }
94 key = rateKey(key)
95 now := l.now().UTC()
96 l.mu.Lock()
97 defer l.mu.Unlock()
98 if e, ok := l.m[key]; ok && now.Before(e.until) {
99 if e.count >= l.limit {
100 return false, e.until.Sub(now)
101 }
102 e.count++
103 return true, 0
104 }
105 if len(l.m) >= maxLimitKeys || (len(l.m) > 0 && len(l.m)%limitSweepEvery == 0) {
106 l.sweep(now)
107 }
108 l.m[key] = &rateEntry{count: 1, until: now.Add(l.window)}
109 return true, 0
110}
111
112// sweep drops expired entries. It is called when the table is full, so the work is
113// proportional to the number of keys a client made us remember.
114func (l *rateLimiter) sweep(now time.Time) {
115 for k, e := range l.m {
116 if !now.Before(e.until) {
117 delete(l.m, k)
118 }
119 }
120 if len(l.m) >= maxLimitKeys {
121 // Still full of live windows: start over. A client that forced this has
122 // already spent the requests, and the alternative is unbounded memory.
123 l.m = map[string]*rateEntry{}
124 }
125}
126
127// authLimits is the set of budgets the authentication handlers share.
128type authLimits struct {
129 beginAddress *rateLimiter
130 beginAccount *rateLimiter
131 finish *rateLimiter
132 register *rateLimiter
133 // write bounds an untrusted account's repository writes for an hour. It is
134 // keyed by the account rather than the address, because the account is what a
135 // write is attributed to and because one person may use two browsers.
136 write *rateLimiter
137}
138
139func newAuthLimits(writesPerHour int) authLimits {
140 if writesPerHour <= 0 {
141 writesPerHour = defaultContributorWritesPerHour
142 }
143 return authLimits{
144 beginAddress: newRateLimiter(authBeginsPerAddress, authLimitWindow),
145 beginAccount: newRateLimiter(authBeginsPerAccount, authLimitWindow),
146 finish: newRateLimiter(authFinishesPerAddress, authLimitWindow),
147 register: newRateLimiter(registrationsPerAddress, authLimitWindow),
148 write: newRateLimiter(writesPerHour, time.Hour),
149 }
150}
151
152// limitForm is limit for a form post: the refusal is a plain-text answer a browser
153// form can display, not a JSON error.
154func (w *Web) limitForm(rw http.ResponseWriter, r *http.Request, l *rateLimiter, key string) bool {
155 ok, wait := l.allow(key)
156 if ok {
157 return true
158 }
159 seconds := int(wait.Seconds() + 0.5)
160 if seconds < 1 {
161 seconds = 1
162 }
163 rw.Header().Set("Retry-After", strconv.Itoa(seconds))
164 if w.Logger != nil {
165 w.Logger("write limit: %s %s by %s", r.Method, loggablePath(r.URL.Path), key)
166 }
167 http.Error(rw, fmt.Sprintf("you are writing too quickly; try again in %d second(s)", seconds),
168 http.StatusTooManyRequests)
169 return false
170}
171
172// rateKey bounds the key that reaches the map. A short key is stored as written
173// so the common case stays readable in a test or a trace; a longer one is hashed
174// to a fixed size so its length cannot become the table's memory use.
175func rateKey(key string) string {
176 if len(key) <= maxRateKeyBytes {
177 return key
178 }
179 sum := sha256.Sum256([]byte(key))
180 return "sha256:" + hex.EncodeToString(sum[:])
181}
182
183// clientAddress is the key the address budgets count under. Behind a trusted proxy
184// the configured header is the client; without one it is the connection. The same
185// header drives the anonymous Git transport's per-client limit, so an instance
186// with a proxy sets it once and both surfaces follow.
187func (w *Web) clientAddress(r *http.Request) string {
188 if w.Cfg != nil {
189 if header := strings.TrimSpace(w.Cfg.GitHTTPClientIPHeader); header != "" {
190 if v := strings.TrimSpace(r.Header.Get(header)); v != "" {
191 // X-Forwarded-For grows left to right and the leftmost entry is the
192 // client; a value that is a list is split rather than used whole.
193 if i := strings.IndexByte(v, ','); i >= 0 {
194 v = strings.TrimSpace(v[:i])
195 }
196 if len(v) > maxClientAddressBytes {
197 v = v[:maxClientAddressBytes]
198 }
199 return v
200 }
201 }
202 }
203 host, _, err := net.SplitHostPort(r.RemoteAddr)
204 if err != nil {
205 return r.RemoteAddr
206 }
207 return host
208}
209
210// limit answers 429 when the budget for key is used up and reports whether the
211// handler should continue. The message says how long to wait, because a limit
212// nobody can see is a wall.
213func (w *Web) limit(rw http.ResponseWriter, r *http.Request, l *rateLimiter, key string) bool {
214 ok, wait := l.allow(key)
215 if ok {
216 return true
217 }
218 seconds := int(wait.Seconds() + 0.5)
219 if seconds < 1 {
220 seconds = 1
221 }
222 rw.Header().Set("Retry-After", strconv.Itoa(seconds))
223 if w.Logger != nil {
224 w.Logger("auth limit: %s %s from %s", r.Method, loggablePath(r.URL.Path), w.clientAddress(r))
225 }
226 w.noStore(rw)
227 writeJSONError(rw, http.StatusTooManyRequests,
228 fmt.Sprintf("too many attempts; try again in %d second(s)", seconds))
229 return false
230}
231