Commit 77ff123033

77ff123033d5374a8e17162f96514342049c19df

parent: ead6796abe

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 22:56 UTC

wiki: unkeyed audit chain, stall kill, enforced limits, pack-limit gaps

Ref #275
Ref #262

Layout: unified · split

.gitbay/wiki/Admin.org +18 −6
@@ -218,15 +218,23 @@ push=.
218 at most =pack_concurrency= − 1 slots when it is above 1, so a signed-in 218 at most =pack_concurrency= − 1 slots when it is above 1, so a signed-in
219 client (SSH key, bearer token or web session) can always get the last. 219 client (SSH key, bearer token or web session) can always get the last.
220 Past that an SSH client gets "the server is busy…" and exit 1, HTTP 220 Past that an SSH client gets "the server is busy…" and exit 1, HTTP
221 gets 503 with =Retry-After: 30=, git:// an =ERR= line. A queued 221 gets 503 with =Retry-After: 30=, git:// an =ERR= line. An SSH client
222 client that disconnects leaves the queue; a running clone whose 222 that disconnects while queued leaves the queue; an HTTP or git:// one
223 client disconnects is killed. Ref listings (info/refs, protocol v2 223 keeps its place until the wait runs out. A running clone is killed
224 when its client disconnects, or when no write to the client completes
225 for two minutes: a client reading below about 550 B/s, or an HTTP
226 request body that takes over two minutes with nothing written back,
227 is cut. Ref listings (info/refs, protocol v2
224 =ls-refs=), pushes and =repo download= are outside the budget. For the 228 =ls-refs=), pushes and =repo download= are outside the budget. For the
225 three counts 0 means the default and a negative value turns that 229 three counts 0 means the default and a negative value turns that
226 bound off. The defaults suit a four-core host; see [[Performance]]. 230 bound off. The defaults suit a four-core host; see [[Performance]].
227 With =ssh.mode = "system"= each SSH session is its own process and 231 With =ssh.mode = "system"= each SSH session is its own process and
228 SSH clones are not counted. 232 SSH clones are not counted.
229- =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced. 233- =max_pack_bytes= (2 GiB) — the largest pack one push may send,
234 enforced as =receive.maxInputSize= and lowered to what an owner's
235 storage quota has left.
236- =ssh_auth_rate= (10) — SSH authentication failures per client address
237 per minute on the embedded listener; see below.
230 238
231** [git_daemon] 239** [git_daemon]
232- =enabled= (false), =port= (9418) — the anonymous =git://= listener. 240- =enabled= (false), =port= (9418) — the anonymous =git://= listener.
@@ -294,7 +302,10 @@ Each row carries the SHA-256 of the row before it. =gitbayd admin audit
294verify= opens the store as other admin commands do, applying pending 302verify= opens the store as other admin commands do, applying pending
295migrations, so run it with the binary that matches the daemon. It 303migrations, so run it with the binary that matches the daemon. It
296recomputes the chain and exits 1 naming the first row that was 304recomputes the chain and exits 1 naming the first row that was
297edited or whose predecessor was removed. Retention removing the oldest 305edited or whose predecessor was removed. The chain is unkeyed: whoever
306can write the database can recompute every hash after an edit, and
307verify then finds nothing. It catches an edit only when the later
308hashes were not recomputed. Retention removing the oldest
298rows is not a break. Rows written before the chain existed are counted 309rows is not a break. Rows written before the chain existed are counted
299and skipped; when every row is such a row, verify warns and exits 1, 310and skipped; when every row is such a row, verify warns and exits 1,
300since clearing the hash columns looks the same. After an upgrade that 311since clearing the hash columns looks the same. After an upgrade that
@@ -304,7 +315,8 @@ Removing the newest rows leaves no break, and neither do rows written
304afterwards under the freed ids. The database cannot show either. The 315afterwards under the freed ids. The database cannot show either. The
305daemon logs every row it writes to its journal, outside the database 316daemon logs every row it writes to its journal, outside the database
306(=journalctl -u gitbayd -g 'INFO audit '=), and verify prints the last 317(=journalctl -u gitbayd -g 'INFO audit '=), and verify prints the last
307id and hash: compare them with the newest journal line. Rows written by 318id and hash: comparing them with the newest journal line is the check
319for any change, recomputed hashes included. Rows written by
308host =gitbayd admin= commands, and by =gitbayd shell= when =ssh.mode = 320host =gitbayd admin= commands, and by =gitbayd shell= when =ssh.mode =
309"system"=, are not copied to the journal. 321"system"=, are not copied to the journal.
310 322
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -69,7 +69,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
69| Security-relevant writes audited | in place | every successful mutating command (=control.go=) | 69| Security-relevant writes audited | in place | every successful mutating command (=control.go=) |
70| Authentication failures audited | in place | =auth.failed=, =auth.throttled= | 70| Authentication failures audited | in place | =auth.failed=, =auth.throttled= |
71| Denied attempts audited | in place | refused mutating commands and pushes, ten a minute per actor, 600 in all (=internal/control/auditrefusal.go=) | 71| Denied attempts audited | in place | refused mutating commands and pushes, ten a minute per actor, 600 in all (=internal/control/auditrefusal.go=) |
72| Audit log tamper resistance | partial | hash chain checked by =gitbayd admin audit verify=; every row the daemon writes copied to its journal; the table is writable by the daemon user, and removing the newest rows (or reusing their ids) shows only by comparing verify's last id and hash with the journal | 72| Audit log tamper resistance | partial | unkeyed hash chain checked by =gitbayd admin audit verify=; every row the daemon writes copied to its journal; the table is writable by the daemon user, who can recompute the chain after an edit, so comparing verify's last id and hash with the journal is the check for any change |
73 73
74** Communications and integrations (V9, V10, V12) 74** Communications and integrations (V9, V10, V12)
75 75
.gitbay/wiki/Architecture/10-Known-Gaps.org +4 −1
@@ -19,8 +19,11 @@ what the 2026-09-27 review found; remove a row when its issue closes.
19 19
20| Area | Gap | Severity | 20| Area | Gap | Severity |
21|-------+-------------------------------------------------------------------------------------------------------------+----------| 21|-------+-------------------------------------------------------------------------------------------------------------+----------|
22| Audit | Removing the newest audit rows, or writing new rows under their freed ids, is not detectable from the database; only comparing =gitbayd admin audit verify='s last id and hash with the daemon's journal shows it. Rows written by =gitbayd shell= (=ssh.mode = "system"=) and host admin commands have no journal copy, and the refusal caps are per process, so under that mode each connection counts separately | low | 22| Audit | The hash chain is unkeyed, so whoever can write the database can edit a row and recompute every later hash; removing the newest audit rows, or writing new rows under their freed ids, needs no recomputing at all. Neither is detectable from the database; only comparing =gitbayd admin audit verify='s last id and hash with the daemon's journal shows it. Rows written by =gitbayd shell= (=ssh.mode = "system"=) and host admin commands have no journal copy, and the refusal caps are per process, so under that mode each connection counts separately | low |
23| Availability | Under =ssh.mode = "system"= each SSH session is a separate =gitbayd shell= process, so the pack-generation limit (=internal/packlimit=, #262) cannot count SSH clones across sessions; only HTTP and git:// share a budget there | low | 23| Availability | Under =ssh.mode = "system"= each SSH session is a separate =gitbayd shell= process, so the pack-generation limit (=internal/packlimit=, #262) cannot count SSH clones across sessions; only HTTP and git:// share a budget there | low |
24| Availability | Pushes have no concurrency limit; =max_pack_bytes= bounds each one, not how many run at once | medium |
25| Availability | =repo download= (SSH, API) runs =git archive= outside the pack limit; only its two-minute deadline and 512 MiB cap bound it | low |
26| Availability | An HTTP or git:// client that disconnects while queued for a pack slot keeps its place until =pack_queue_wait= runs out; only SSH notices the disconnect | low |
24 27
25* Questions an auditor will ask that have no answer yet 28* Questions an auditor will ask that have no answer yet
26 29
.gitbay/wiki/Performance.org +9 −7
@@ -47,13 +47,15 @@ The practical ceiling on this hardware is concurrent pack generation:
47full clones of large repositories are CPU-bound in git itself (the 17s 47full clones of large repositories are CPU-bound in git itself (the 17s
48clone ran git at ~156% CPU). =limits.pack_concurrency= bounds how many 48clone ran git at ~156% CPU). =limits.pack_concurrency= bounds how many
49run at once across SSH, HTTP and git://, with a queue behind it (see 49run at once across SSH, HTTP and git://, with a queue behind it (see
50[[Admin]], =[limits]=); the measurements below set its default. 50[[Admin]], =[limits]=).
51 51
52* Concurrent clones 52* Concurrent clones
53 53
54Measured with =deploy/clonebench.sh https://gitbay.org/krz/gitbay.git <n>= 54The defaults (=pack_concurrency= 3, =pack_per_principal= 2,
55from a machine outside bay1 (four cores), before and after the pack 55=pack_queue= 32, =pack_queue_wait= 60s) are set for a four-core host
56limit was deployed with its defaults (=pack_concurrency= 3, 56from the single-clone figure above; no concurrent-clone measurement
57=pack_per_principal= 2, =pack_queue= 32, =pack_queue_wait= 60s). All 57backs them yet. =deploy/clonebench.sh <clone-url> <n>= starts n full
58clones in one run come from one address, so the per-principal cap 58clones at once from a machine other than the server. Clones from one
59applies to them; the "limit off" run sets the counts to -1. 59address count against one principal, so run it from two addresses, or
60against an instance with =pack_per_principal = -1=, or it measures the
61per-principal cap instead of the global one.
.gitbay/wiki/Threat-Model.org +13 −9
@@ -249,8 +249,10 @@ is recorded here rather than in a closed issue:
249 escape what is there. #144 covers the missing isolation. 249 escape what is there. #144 covers the missing isolation.
250- *Timing and traffic analysis.* Token comparison is a hash index lookup 250- *Timing and traffic analysis.* Token comparison is a hash index lookup
251 by design, but nothing has been measured. 251 by design, but nothing has been measured.
252- *Denial of service by resource exhaustion* beyond rate: large pushes, 252- *Denial of service by resource exhaustion* beyond rate. Concurrent
253 pathological diffs, deep histories, zip bombs in LFS. 253 clones, fetches and web archives are bounded by the pack limit
254 (#262); pushes are not, beyond =max_pack_bytes= on each one. Nor are
255 pathological diffs, deep histories, or zip bombs in LFS.
254 256
255A sweep is a point in time. This section says what a reader should not 257A sweep is a point in time. This section says what a reader should not
256assume has been checked. 258assume has been checked.
@@ -271,13 +273,15 @@ assume has been checked.
271 present as objects no archived ref names, and a repository's refs may 273 present as objects no archived ref names, and a repository's refs may
272 be newer than the database snapshot (see [[Admin]]). 274 be newer than the database snapshot (see [[Admin]]).
273- The audit log lives in the database the daemon writes, so anyone with 275- The audit log lives in the database the daemon writes, so anyone with
274 the daemon user's access can change it. The hash chain makes an edited 276 the daemon user's access can change it. The hash chain is unkeyed:
275 or removed row show as a break under =gitbayd admin audit verify=, 277 whoever can write the database can edit a row and recompute every
276 except at the end: removing the newest rows, and writing new rows 278 later hash. =gitbayd admin audit verify= catches an edited or removed
277 under their freed ids, leaves a valid chain. Only comparing verify's 279 row only when the later hashes were not recomputed, and never catches
278 last id and hash with the daemon's journal copy shows it, and rows 280 removing the newest rows or writing new rows under their freed ids.
279 written outside the daemon (=gitbayd shell= under =ssh.mode = 281 Comparing verify's last id and hash with the daemon's journal copy is
280 "system"=, host =gitbayd admin= commands) have no journal copy. 282 the check for any change; rows written outside the daemon (=gitbayd
283 shell= under =ssh.mode = "system"=, host =gitbayd admin= commands)
284 have no journal copy.
281- A global signature-verification epoch over-invalidates the cache on any 285- A global signature-verification epoch over-invalidates the cache on any
282 trust-input change. Correct, not a leak; a performance tradeoff. 286 trust-input change. Correct, not a leak; a performance tradeoff.
283- A build's secrets are environment variables inside its container, so 287- A build's secrets are environment variables inside its container, so
CHANGELOG.org +10 −4
@@ -99,8 +99,11 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
99 before it (migration 0064), and the daemon logs a copy of every row it 99 before it (migration 0064), and the daemon logs a copy of every row it
100 writes to its journal. =gitbayd admin audit verify= prints the row 100 writes to its journal. =gitbayd admin audit verify= prints the row
101 count and the last id and hash, and exits 1 naming the first row that 101 count and the last id and hash, and exits 1 naming the first row that
102 was edited or whose predecessor was removed. Removing the newest rows 102 was edited or whose predecessor was removed. The chain is unkeyed:
103 shows only by comparing that last id and hash with the journal (#275). 103 someone who can write the database can recompute the later hashes,
104 and verify catches an edit only when they were not recomputed.
105 Comparing verify's last id and hash with the journal is the check for
106 any change, including removing the newest rows (#275).
104- Refused mutating commands (exit 3 or 4) are audited as =refused 107- Refused mutating commands (exit 3 or 4) are audited as =refused
105 <command>=, and refused pushes as =refused git-receive-pack=, keeping 108 <command>=, and refused pushes as =refused git-receive-pack=, keeping
106 flag names and the target but no values; ten a minute per account and 109 flag names and the target but no values; ten a minute per account and
@@ -228,8 +231,11 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
228 "the server is busy…" and exits 1, HTTP gets 503 with 231 "the server is busy…" and exits 1, HTTP gets 503 with
229 =Retry-After: 30=, and git:// gets an =ERR= line. *Operators:* the 232 =Retry-After: 30=, and git:// gets an =ERR= line. *Operators:* the
230 defaults are tuned for a four-core host; set the three counts to -1 233 defaults are tuned for a four-core host; set the three counts to -1
231 to turn the limit off. Ref listings, pushes and =repo download= are 234 to turn the limit off. A running clone is killed when no write to
232 unaffected. Under =ssh.mode = "system"= SSH clones are not counted, 235 its client completes for two minutes: a client reading below about
236 550 B/s, or an HTTP request body that takes over two minutes with
237 nothing written back, is cut. Ref listings, pushes and =repo
238 download= are unaffected. Under =ssh.mode = "system"= SSH clones are not counted,
233 since each session is its own process (#262). 239 since each session is its own process (#262).
234- Anonymous clones are counted per IPv4 address or IPv6 /64, and 240- Anonymous clones are counted per IPv4 address or IPv6 /64, and
235 together hold at most =pack_concurrency= − 1 slots, so a signed-in 241 together hold at most =pack_concurrency= − 1 slots, so a signed-in