Commit 7f2fd45218

7f2fd45218889d43a158c2dc496c62cea7a17308

parent: 387fadad65

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-06 18:01 UTC

control, config, wiki: bound writes per account in the dispatcher

Authentication failures were throttled; commands were not, so a key that
authenticated could run unlimited writes over SSH while the same command
was limited through the JSON API.

limits.write_rate (60/min, negative to disable) is spent in Dispatch, so
every surface shares one budget. Read-only commands, the runner protocol
and the host CLI are exempt.

Closes #148

Layout: unified · split

.gitbay/wiki/Admin.org +14 −1
@@ -221,6 +221,18 @@ per minute — successful auths never count and clear the slate.
221=limits.max_pack_bytes= is enforced as =receive.maxInputSize= on every 221=limits.max_pack_bytes= is enforced as =receive.maxInputSize= on every
222push. 222push.
223 223
224=limits.write_rate= (60) bounds *mutating commands per account per
225minute*. It is counted in the dispatcher, so SSH, the JSON API and the
226web spend one budget and a caller cannot refresh it by changing surface;
227=limits.api_rate= stays in front of it, bounding a network source rather
228than an account. A command is one token whatever it writes, so a bundle
229import costs one and only a loop of separate commands spends the budget.
230Read-only commands, the runner protocol (a build streams its log in many
231small writes) and the host CLI are exempt. Refusals exit 4 and say when
232to retry. A negative value turns the limit off; it matters most with
233=registration = "open"=, where every write also queues notification mail
234and webhook deliveries.
235
224* Queues 236* Queues
225 237
226Every background worker keeps a backlog and a failure state. An instance 238Every background worker keeps a backlog and a failure state. An instance
@@ -436,7 +448,8 @@ trusts and refuses to do. Operational checklist:
436 auto-reboot 04:30 if required). 448 auto-reboot 04:30 if required).
437- *Admin sshd (2222)* is throttled by =MaxStartups=/=MaxAuthTries= and 449- *Admin sshd (2222)* is throttled by =MaxStartups=/=MaxAuthTries= and
438 watched by =fail2ban=; gitbayd's own port 22 is throttled by 450 watched by =fail2ban=; gitbayd's own port 22 is throttled by
439 =limits.ssh_auth_rate= (auth failures per IP per minute). 451 =limits.ssh_auth_rate= (auth failures per IP per minute), and every
452 account's writes by =limits.write_rate=.
440- *Monitoring.* =gitbay-monitor.timer= writes a reading hourly to 453- *Monitoring.* =gitbay-monitor.timer= writes a reading hourly to
441journald and, when =/etc/gitbay/monitor.url= exists, posts it to that 454journald and, when =/etc/gitbay/monitor.url= exists, posts it to that
442webhook: disk, service, the daemon's own =/healthz= answer, certificate 455webhook: disk, service, the daemon's own =/healthz= answer, certificate
e2e/writerate_test.go added +78
@@ -0,0 +1,78 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// Mutating commands are bounded per account in the dispatcher, so the
11// budget is the same one whichever surface spends it. Reads are not
12// charged (#148).
13func TestWriteRateLimit(t *testing.T) {
14 inst := startInstanceWith(t, "[limits]\nwrite_rate = 4\n")
15 aliceKey := inst.newKey(t, "alice")
16 bobKey := inst.newKey(t, "bob")
17 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
18 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
19
20 // repo create plus three issues is the whole budget.
21 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
22 t.Fatalf("repo create: %s", errOut)
23 }
24 for i := 0; i < 3; i++ {
25 if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "t"); code != 0 {
26 t.Fatalf("issue %d within the budget was refused: %s", i+1, errOut)
27 }
28 }
29 _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "over")
30 if code != 4 {
31 t.Fatalf("write past the budget exited %d, want 4:\n%s", code, errOut)
32 }
33 if !strings.Contains(errOut, "too many writes") || !strings.Contains(errOut, "try again in") {
34 t.Errorf("refusal does not say what happened or when to retry: %s", errOut)
35 }
36
37 // Reads are not charged, so a throttled account can still look.
38 if out, _, code := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app"); code != 0 {
39 t.Fatalf("a read was refused while throttled: %s", out)
40 }
41
42 // The budget is per account, not per instance.
43 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "create", "bob/app"); code != 0 {
44 t.Fatalf("bob was charged for alice's writes: %s", errOut)
45 }
46}
47
48// The runner protocol is exempt: a build streams its log in many small
49// writes, and throttling those would throttle CI itself.
50func TestWriteRateLimitSparesTheRunner(t *testing.T) {
51 inst := startInstanceWith(t, "[limits]\nwrite_rate = 2\n")
52 inst.runner = buildRunner(t)
53 aliceKey := inst.newKey(t, "alice")
54 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
55 runnerKey := inst.newKey(t, "ci")
56 inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin")
57
58 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
59 t.Fatalf("repo create: %s", errOut)
60 }
61 env := inst.gitEnv(aliceKey)
62 work := t.TempDir()
63 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
64 dir := filepath.Join(work, "w")
65 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
66 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"),
67 []byte("jobs:\n smoke:\n steps:\n - echo ok\n"), 0o644)
68 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
69 mustGit(t, dir, env, "add", ".")
70 mustGit(t, dir, env, "commit", "-q", "-m", "base")
71 mustGit(t, dir, env, "push", "-q", "origin", "main")
72
73 inst.runnerOnce(t, runnerKey)
74 out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app")
75 if !strings.Contains(out, "success") {
76 t.Fatalf("the build did not run under a tight write budget:\n%s", out)
77 }
78}
internal/config/config.go +10
@@ -13,6 +13,12 @@ import (
13 "github.com/BurntSushi/toml" 13 "github.com/BurntSushi/toml"
14) 14)
15 15
16// DefaultWriteRate is the per-account write budget when the config leaves
17// write_rate at zero: generous for a person at a terminal, and a bound on
18// what one account can enqueue — every write also queues notification mail
19// and webhook deliveries.
20const DefaultWriteRate = 60
21
16type Config struct { 22type Config struct {
17 Server Server `toml:"server"` 23 Server Server `toml:"server"`
18 SSH SSH `toml:"ssh"` 24 SSH SSH `toml:"ssh"`
@@ -175,6 +181,10 @@ type Limits struct {
175 // APIRate is sustained JSON-API requests per minute per caller; writes 181 // APIRate is sustained JSON-API requests per minute per caller; writes
176 // draw on a tenth of it. 0 uses the default. 182 // draw on a tenth of it. 0 uses the default.
177 APIRate int `toml:"api_rate"` 183 APIRate int `toml:"api_rate"`
184 // WriteRate is sustained mutating commands per minute per account,
185 // counted in the dispatcher so every surface shares one budget. 0 uses
186 // the default; a negative value turns the limit off.
187 WriteRate int `toml:"write_rate"`
178 // Per-account quotas on what a user owns directly (organizations are 188 // Per-account quotas on what a user owns directly (organizations are
179 // not capped). 0 means unlimited; admin user limits overrides per 189 // not capped). 0 means unlimited; admin user limits overrides per
180 // account. 190 // account.
internal/control/control.go +30
@@ -11,6 +11,7 @@ import (
11 "reflect" 11 "reflect"
12 "slices" 12 "slices"
13 "strings" 13 "strings"
14 "time"
14 15
15 "gitbay.org/gitbay/internal/config" 16 "gitbay.org/gitbay/internal/config"
16 "gitbay.org/gitbay/internal/protocol" 17 "gitbay.org/gitbay/internal/protocol"
@@ -120,6 +121,9 @@ func Dispatch(c *Ctx, argv []string) int {
120 return c.fail(protocol.ExitDenied, 121 return c.fail(protocol.ExitDenied,
121 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") 122 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
122 } 123 }
124 if code := limitWrites(c, cmd); code >= 0 {
125 return code
126 }
123 if !cmd.ReadsStdin { 127 if !cmd.ReadsStdin {
124 c.Stdin = emptyReader{} 128 c.Stdin = emptyReader{}
125 } 129 }
@@ -165,6 +169,32 @@ func auditArgs(args []string) []string {
165} 169}
166 170
167// pendingAllowed lists what an unverified self-registered account may do. 171// pendingAllowed lists what an unverified self-registered account may do.
172// limitWrites spends one token of the account's write budget, and refuses
173// with the wait when it is empty. Returns -1 when the command may run.
174//
175// Exempt: read-only commands, which cost the instance nothing to serve
176// twice; the runner protocol, which streams a build's log in many small
177// writes and would throttle CI; and the host CLI on the server, which has
178// no account to key on and is already root-equivalent.
179func limitWrites(c *Ctx, cmd Command) int {
180 if cmd.ReadOnly || cmd.Path[0] == "runner" || c.Source == "host" || c.User.ID == 0 {
181 return -1
182 }
183 perMinute := c.Cfg.Limits.WriteRate
184 if perMinute == 0 {
185 perMinute = config.DefaultWriteRate
186 }
187 if perMinute < 0 {
188 return -1
189 }
190 if ok, wait := writes.allow(c.User.ID, perMinute); !ok {
191 return c.fail(protocol.ExitDenied,
192 "too many writes: %d a minute per account; try again in %s",
193 perMinute, wait.Round(time.Second))
194 }
195 return -1
196}
197
168func pendingAllowed(path []string) bool { 198func pendingAllowed(path []string) bool {
169 key := joinPath(path) 199 key := joinPath(path)
170 return key == "email verify" || key == "email add" || key == "whoami" || key == "help" 200 return key == "email verify" || key == "email add" || key == "whoami" || key == "help"
internal/control/ratelimit.go added +66
@@ -0,0 +1,66 @@
1package control
2
3import (
4 "sync"
5 "time"
6)
7
8// writeLimiter bounds mutating commands per account. It lives in the
9// dispatcher rather than in a surface because every surface reaches the
10// same registry: SSH, the JSON API and the web draw on one budget, so a
11// caller cannot get a fresh allowance by changing how it connects (#148).
12//
13// The API's own limiter stays in front of it, keyed by token or IP: that
14// one bounds a network source, this one bounds an account.
15//
16// A command is one token whatever it writes. `account import-bundle`
17// replays a whole bundle in a single dispatch, so bulk work costs one
18// write and only a loop of separate commands spends the budget.
19type writeLimiter struct {
20 mu sync.Mutex
21 buckets map[int64]*writeBucket
22}
23
24type writeBucket struct {
25 tokens float64
26 last time.Time
27}
28
29var writes = &writeLimiter{buckets: map[int64]*writeBucket{}}
30
31// allow reports whether this account may write now, and how long until it
32// can if not. perMinute is both the sustained rate and the burst, so an
33// idle account gets a full minute's worth at once.
34func (l *writeLimiter) allow(user int64, perMinute int) (bool, time.Duration) {
35 l.mu.Lock()
36 defer l.mu.Unlock()
37 now := time.Now()
38
39 // Bounded by the number of accounts that wrote in the last ten
40 // minutes; a busy instance never grows this without also using it.
41 if len(l.buckets) > 4096 {
42 for k, b := range l.buckets {
43 if now.Sub(b.last) > 10*time.Minute {
44 delete(l.buckets, k)
45 }
46 }
47 }
48
49 burst := float64(perMinute)
50 rate := burst / 60
51 b := l.buckets[user]
52 if b == nil {
53 b = &writeBucket{tokens: burst, last: now}
54 l.buckets[user] = b
55 }
56 elapsed := now.Sub(b.last).Seconds()
57 b.last = now
58 if b.tokens += elapsed * rate; b.tokens > burst {
59 b.tokens = burst
60 }
61 if b.tokens < 1 {
62 return false, time.Duration((1-b.tokens)/rate*float64(time.Second)) + time.Second
63 }
64 b.tokens--
65 return true, 0
66}
internal/control/ratelimit_test.go added +55
@@ -0,0 +1,55 @@
1package control
2
3import (
4 "testing"
5 "time"
6)
7
8// The budget is per account: spending one account's does not touch
9// another's, which is the property that makes the limit meaningful when
10// registration is open (#148).
11func TestWriteLimiterIsPerAccount(t *testing.T) {
12 l := &writeLimiter{buckets: map[int64]*writeBucket{}}
13 for i := 0; i < 3; i++ {
14 if ok, _ := l.allow(1, 3); !ok {
15 t.Fatalf("write %d of the burst refused", i+1)
16 }
17 }
18 if ok, wait := l.allow(1, 3); ok || wait <= 0 {
19 t.Errorf("the fourth write was allowed, or gave no wait: %v", wait)
20 }
21 if ok, _ := l.allow(2, 3); !ok {
22 t.Error("a second account was charged for the first's writes")
23 }
24}
25
26// An idle account refills at the sustained rate, so the limit throttles
27// rather than locks out.
28func TestWriteLimiterRefills(t *testing.T) {
29 l := &writeLimiter{buckets: map[int64]*writeBucket{}}
30 if ok, _ := l.allow(1, 60); !ok {
31 t.Fatal("first write refused")
32 }
33 // 60 a minute is one a second: rewind the clock a second and the
34 // spent token is back.
35 l.buckets[1].tokens = 0
36 l.buckets[1].last = time.Now().Add(-time.Second)
37 if ok, wait := l.allow(1, 60); !ok {
38 t.Errorf("no refill after a second: wait %v", wait)
39 }
40}
41
42// The wait a refusal reports is the time until a token exists, so a
43// caller that honours it is not refused again immediately.
44func TestWriteLimiterReportsAUsableWait(t *testing.T) {
45 l := &writeLimiter{buckets: map[int64]*writeBucket{}}
46 l.allow(1, 60)
47 l.buckets[1].tokens = 0
48 ok, wait := l.allow(1, 60)
49 if ok {
50 t.Fatal("allowed with an empty bucket")
51 }
52 if wait < time.Second || wait > 3*time.Second {
53 t.Errorf("wait %v is not the ~1s a 60/min bucket needs", wait)
54 }
55}