.gitbay/wiki/Threat-Model.org
379 lines · 22434 bytes
1#+title: gitbay threat model
2
3What the forge trusts, what it refuses to do, and where the boundaries
4are. This is the reference for security review; it complements the audit
5log and hardening notes in [[Admin]]. Diagrams, data flows, a controls
6matrix and the open gaps are in the [[file:Architecture/00-Overview.org][Architecture]] pages.
7
8* What gitbay never does
9
10- *Execute repository content.* Git object contents are never run. Hooks
11 are gitbay's own binary, invoked by git; they compute facts and ask the
12 daemon over a unix socket. Repo files are only ever read.
13- *Hold a signing key.* There is no server-side signing key. "Verified"
14 means a signature made by a key the *user* registered — the server
15 never vouches for a commit it did not receive already signed. Merge
16 commits the server creates are honestly =unsigned=.
17- *Serve repository HTML on its own origin as active content.* Raw file
18 serving is =text/plain= with =nosniff=. Rendered markdown/org is
19 sanitized (bluemonday) and served under a CSP that forbids scripts.
20- *Put secrets in argv, URLs, or logs, with one documented exception.*
21 Import and mirror credentials, registration invites, and API tokens
22 travel on stdin or in request bodies, never as command arguments
23 (visible in =/proc=) or query strings. The one exception is the
24 emailed login link, =/login?token=...=: single-use, 15-minute expiry,
25 and the response that consumes it carries =Cache-Control: no-store= so
26 no intermediary keeps a copy. An operator running gitbay behind a
27 reverse proxy should configure that proxy to strip the query string
28 from its own access logs. Tokens are stored only as SHA-256 hashes.
29- *Name a mail recipient in the log.* A queued mail is logged by its
30 queue row id, never by address, and the relay's own error is redacted
31 before it is logged because a rejection usually quotes the address it
32 rejected. The unredacted error and the address stay on the row, which
33 an instance admin reads on =/admin=: the database holds who, the log
34 holds which. One rule for every mail type — notifications, dependency
35 reports and login links all drain the same queue (krz/gitbay#173).
36- *Confirm the existence of private repositories.* Every surface answers
37 "not found" identically for a private repo and a nonexistent one — web
38 pages, git transport, control commands, release asset downloads.
39
40* Trust boundaries
41
42- *SSH public key = identity.* The SSH username is ignored; the presented
43 key's fingerprint resolves to an account. Key uniqueness is global.
44- *Revocation is immediate.* Every exec and every git transport session
45 re-reads its key. Removing a key, removing a deploy key, disabling or
46 deleting an account closes the connections the affected keys opened:
47 a git transport is killed with its children, and a push killed before
48 its pre-receive hook answers moves no ref.
49 An LFS transfer token names the key that obtained it and is refused
50 from the moment that key is. A control command already
51 inside its database write finishes it; its output is lost. A
52 revocation made by =gitbayd admin= on the host, another process, is
53 found within 15 seconds. In =ssh.mode = "system"= each exec is its
54 own process: the next exec is refused, one already running is not cut.
55- *Per-instance trust.* Email verification and key registration are local
56 to an instance and never transfer. Account migration re-registers keys
57 and re-verifies emails on the target by design.
58- *The control plane is one command registry*, fully usable from stock
59 OpenSSH and fronted unchanged by the JSON API and the web. No command
60 belongs to one surface (#234): what a caller may do is the account's
61 rights narrowed by its credential's scope, decided in one place, so a
62 bearer token is worth exactly its scope and no more, and a token or
63 SSH key with an expiry cannot create a credential that outlives it.
64 A browser session can create one, or grant access, only within 15
65 minutes of signing in (=control.ReauthWindow=, #297). Git transport
66 never runs over the API.
67- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
68 enabled, the read-only web UI — carry no credentials and expose only
69 public data. HTTP push is refused via a pkt-line =ERR=, never a 401.
70
71* Attacker-controlled parsers
72
73Every parser that eats bytes from a pusher, a key registrant, or an
74anonymous client has a fuzz target and must never panic:
75
76- =internal/protocol= — the SSH command tokenizer (fuzzed against argv
77 round-tripping).
78- =internal/gitd= — the =git://= pkt-line reader.
79- =internal/sig= — the commit parser, the SSHSIG armor decoder and blob
80 parser, and the OpenPGP armored-key reader.
81- =internal/mailin= — an inbound reply's headers, reply token, MIME
82 body and quote stripping, where anyone who can send mail to the
83 reply mailbox chooses the bytes.
84- =internal/imapc= — the IMAP response reader, literals included.
85
86Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=.
87
88* Secret handling
89
90Tokens (web sessions, login links, email verification, API bearer tokens,
91deploy/CI) are random 256-bit values. Only their SHA-256 hash is stored,
92and verification is a database index lookup on that hash — the secret
93itself is never compared in Go, so there is no timing oracle to exploit.
94Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies
95no inbound HMAC.
96
97* Reply by mail
98
99When =[mail.inbound]= is on (#295), anyone can send mail to the reply
100mailbox, and a posted reply is a comment written as an account. Two
101things are required together, because neither is enough alone:
102
103- *The reply token.* Each Reply-To is =reply+<token>@<domain>=; the
104 token names the recipient, the repository, the issue or merge
105 request, and an expiry thirty days out, under an HMAC-SHA256
106 truncated to 96 bits. Its key is derived from the secret key file
107 (=seal.Keyring.Derive=), which lives outside =server.root= and out of
108 backups; no row is stored per message. Verification is
109 =hmac.Equal=, against every key in the file so a rotation does not
110 break mail already sent, and only the canonical encoding is
111 accepted. A token is per recipient: it is not a credential for
112 anyone else's account or any other thread.
113- *The sender address.* =From= must be one of the token's account's
114 verified addresses. A leaked token (a forwarded notification, a
115 mailing-list archive, a shared inbox) is not enough to post without
116 also sending as that person. =From= is only what the sender wrote
117 unless the mail host vouches for it. With =[mail.inbound]
118 trusted_authserv_id= set, gitbay reads the topmost
119 =Authentication-Results= header carrying that id and requires DMARC
120 pass for the From domain or a DKIM pass whose =header.d= has the same
121 organizational domain (public suffix list); a forged header lower
122 down, claiming the same id, is ignored. Quoted strings and comments
123 are tokenized as RFC 8601 defines them, so sender-controlled text the
124 mail host echoes into its header (a quoted MAIL FROM local part, a
125 reason) cannot read as a result; =FuzzAuthResults= checks that. That rests on
126 the mail host removing incoming headers that claim its id (RFC 8601
127 §5); Gmail, Fastmail and Migadu do. Unset, the daemon warns at start,
128 and a leaked token plus a forged From posts. The Admin page calls it
129 required for any exposed instance.
130- *Reused ids.* Account and repository ids are reused after a hard
131 delete. A reply is refused when the account or repository was
132 created after its token was minted, so a token cannot post into a
133 later repository, or as a later account, that took the id.
134
135Access is judged when the reply is read, not when the mail was sent:
136the reply is posted by dispatching =issue comment= or =mr comment= as
137the account, so a revoked grant, a private repository, an archived
138repository, a disabled or pending account, or reply by mail turned
139off all refuse it. A =Message-ID= that already posted to a thread as an
140account is not posted there again. Automatic replies (=Auto-Submitted=, =Precedence: bulk=) are
141refused so an out-of-office responder cannot post.
142
143Refusals send nothing back: no bounce, no error mail, so the mailbox
144cannot be used to make the instance mail a forged sender
145(backscatter). Each refusal is an audit row, =refused mail reply=, with
146the reason and the =Message-ID= and none of the message's content,
147bounded at sixty rows a minute. The IMAP connection is TLS or STARTTLS
148with certificate verification; there is no plaintext setting. The
149mailbox password is read from a 0600 file and never logged. The client
150bounds what the server can make it hold: 10 MiB a message (refused by
151size before fetching), about 11 MiB and a thousand responses a
152command, after which the connection is closed; literals other than
153the message body are read and discarded. The Reply-To address is
154blanked from the mail queue once the mail is sent or dead-lettered.
155
156* Network-facing request forgery
157
158Webhook delivery, GitHub-history import =--api-base=, mirror remotes
159and =repo import --from=, which make the *server* open an outbound
160connection to a user-supplied address, pass the same SSRF guard: the
161scheme must be http/https and, unless =webhooks.allow_local= is set,
162the resolved address must not be loopback, private, shared
163(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks
164at connect time, and mirror sync, =repo import= and =repo
165import-issues= resolve and check immediately before running git and pin
166it to the checked addresses (=internal/gitpin=), so a DNS answer that
167changes after validation still cannot reach private space;
168=import-issues= holds its API client to the API host's checked
169addresses the same way, with no proxy taken from the environment.
170Redirects are never followed.
171=repo import= refuses =git://=, which cannot be pinned. Mirror sync
172and =repo import= refuse a host written as a bare number or in
173hex/octal (=0x7f.1=, =2130706433=, =127.1=) rather than dotted
174decimal, since that form resolves differently across parsers; =repo
175mirror add= refuses it when the mirror is saved, and =repo
176import-issues= refuses it in =--api-base=.
177
178* Rendering pushed markup
179
180Rendered markup is attacker-controlled: a README, a wiki page and a
181profile's about text are all whatever someone pushed or typed. The risk
182is not only what the output contains but what the *parser* is willing to
183go and fetch — the filesystem counterpart of the SSRF guard above.
184
185Org is rendered by go-org, whose default configuration resolves
186=#+INCLUDE:= and =#+SETUPFILE:= targets with =os.ReadFile=. Both are
187refused outright (=orgConfig()= in =internal/httpd=): the file is never
188opened and the keyword stays the inert text it already was, so the rest
189of the document renders normally. There is no safe subset to allow
190instead — an absolute path skips go-org's relative-path join, a relative
191one resolves against the daemon's working directory, and the content
192came from a git object rather than a checkout, so there is no directory
193to scope a read to. Markdown is goldmark, which has no include
194mechanism. go-org's parse warnings are discarded rather than logged, so
195pushed content cannot write to the server's log.
196
197Org output is then sanitized (bluemonday UGC policy) because go-org
198passes raw HTML through — export blocks and inline export snippets —
199while goldmark drops it and needs no pass. The policy admits chroma's
200short token classes and nothing else.
201
202* Web responses
203
204Every response carries =Content-Security-Policy= (no scripts, no plugins,
205no embedding; inline styles allowed for chroma and label chips; images
206from any origin so external README images render), =X-Frame-Options:
207DENY=, =X-Content-Type-Options: nosniff=, =Referrer-Policy: no-referrer=,
208and =Strict-Transport-Security= when TLS is on. The UI needs no
209JavaScript, so =script-src 'none'= costs nothing.
210
211* The CI runner
212
213=gitbay-runner= is the one component that executes repository content.
214gitbayd never does: it reads =.gitbay/ci.yml= and queues a build, and a
215runner, polling over SSH, clones the commit and runs its steps.
216
217- *What the runner holds.* A key of scope =runner=, which the dispatcher
218 confines to =runner next=, =runner log= and =runner done= and to
219 read-only git, and which claims, logs and finishes builds only for
220 the repositories it is attached to (=repo runner add=). A step that
221 reads the key off the disk gets exactly that: it cannot administer
222 the instance, push, read a repository the runner's account cannot, or
223 touch another repository's builds. An admin key still works for the
224 runner protocol so an operator can rotate at their own pace; a runner
225 host should not hold one. Untrusted builds are skipped unless the
226 runner asks with =-untrusted=, so a runner on a user's machine never
227 executes a stranger's branch by default.
228- *What a build sees.* The commit, the =GITBAY_*= variables and the
229 repository's secrets — unless the head came from another repository.
230 A merge request from a fork is built in the target as untrusted, with
231 no secrets, so a stranger's branch cannot read the target's deploy
232 credentials. The step environment is *constructed*, not inherited: a
233 build gets =PATH=, =HOME=, =LANG=, =CI=, its own variables and its
234 secrets, and nothing the operator set on the service. =HOME= is a build
235 home under the runner's =-workdir=, not the runner's own home, so a
236 build cannot read the =.netrc=, =.npmrc= or =.gitconfig= where tools
237 keep credentials. A trusted build's home belongs to its repository
238 and persists, so caches survive; an untrusted build's home is new,
239 empty and removed when the build ends, so nothing a fork's build
240 writes is read by a later build (krz/gitbay#255). The claim names a
241 build's trust explicitly, and a runner that finds no trust flag treats
242 the build as untrusted.
243- *Where it runs.* Steps run in a rootless podman container, one per
244 job, with the workspace bind mounted and nothing else. The clone
245 happens outside it with the runner's key, so the container never sees
246 =GIT_SSH_COMMAND=, the key, or the runner's environment. =-isolation
247 none= runs steps on the host as before, for an instance where every
248 repository is trusted; there is no automatic fallback to it — a runner
249 configured for podman that cannot find one refuses to start, because
250 dropping isolation silently is worse than a stopped runner. The
251 systemd drop-in still adds =ProtectSystem=full= and the cgroup
252 protections, and =-repos= still limits a runner to named
253 repositories. =ProtectKernelTunables= is *off*: it overmounts =/proc=
254 in the unit's namespace and the kernel then refuses a proc mount in
255 any child user namespace, which every rootless container needs; the
256 container masks the same paths for the build itself.
257 =NoNewPrivileges= is *off*: rootless podman sets up its
258 namespace with the setuid =newuidmap=, which that flag blocks, so the
259 choice is between it and containers at all. Containers are the stronger
260 boundary — the flag constrained a process that was already running
261 arbitrary repository code, and under podman that code no longer runs in
262 the runner's process context. Under =-isolation none= there is no
263 container and the flag should be on.
264- *Images are provisioned by the operator, not fetched by a build.* The
265 runner passes =--pull=never=, so =image:= chooses among what the host
266 already has rather than naming anything on the internet. On an
267 instance with open registration that is the difference between a
268 curated set and arbitrary code from a registry nobody vetted.
269- *What a build can reach.* A trusted build has the internet; an
270 untrusted one — a fork's merge request head — has TCP 80 and 443 and
271 DNS, enough to fetch modules and packages. Neither reaches private
272 address ranges (RFC 1918, CGNAT, link-local, ULA). On the runner's
273 host a trusted build reaches the forge's public ports 22, 80 and 443
274 and the resolver on loopback; an untrusted build reaches only the
275 resolver. Under pasta a build's container holds the host's own public
276 address, and pasta translates =169.254.1.2= (its =--map-guest-addr=)
277 to that address. A runner that polls the daemon over loopback starts
278 its containers with =--network pasta:--no-map-gw=, which is podman's
279 default stated explicitly, and gives them =GITBAY_SSH= at
280 =169.254.1.2=, with the forge's port when it is not 22. The runner's
281 source address is =127.0.0.1=; a trusted build's is the host's public
282 address. Two nftables tables enforce this. The first
283 (=deploy/gitbay-runner-egress.nft=) matches the runner's user and
284 rejects every connection it makes to the host's own addresses except
285 =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public
286 address: the operator's sshd on 2222 and anything bound to loopback
287 are closed. Under rootless podman a build's connections are made by
288 pasta as that same user, so this table cannot tell a build from its
289 runner. The second (=deploy/gitbay-runner-builds.nft=) can: the runner
290 starts every podman process for a build, pasta included, inside
291 =builds/trusted/build-<id>= or =builds/untrusted/build-<id>= under its
292 service cgroup, and the table matches sockets by those cgroups
293 (=socket cgroupv2=). It closes the host's loopback, =127.0.0.1:22=
294 included, to every build except for DNS, and applies the per-trust
295 rules above. The runner does not start without either table. The SSH auth limiter
296 counts failures per source address and, once an address is over the
297 limit, refuses every key from it until the window passes, the
298 runner's included; an unknown key counts only with registration
299 closed, an expired key always (krz/gitbay#260). No build shares
300 =127.0.0.1= with the runner, and an untrusted build cannot reach sshd
301 at all. Trusted builds share the public address with one another, so
302 one that fails logins can throttle another's push for a minute. Under
303 =-isolation none= a build runs on the host in the runner's cgroup and
304 shares its loopback; only the first table applies, and such a runner
305 must not take =-untrusted=.
306
307Under =-isolation none=, anything a step can do as the runner's user a
308pushed =ci.yml= can do. Under podman a step is confined to its
309container, the bind-mounted workspace and its build home: a trusted
310build's cache is read only by later trusted builds of the same
311repository, and an untrusted build's home is discarded with it. Treat
312the runner host as executing untrusted code all the same: keep it off
313the daemon's host where the database lives, or scope it to repositories
314whose writers you trust. gitbay.org does the latter — its runner builds
315only the repositories the operator names.
316
317* What has not been audited
318
319The 2026-09 sweep (krz/gitbay#149) read this repository against the
320claims above. Its two findings — review verdicts from accounts without
321write access deciding merge gates (#147), and control commands having no
322write rate limit while the API had one (#148) — are fixed, as is #173,
323found afterwards in the same milestone. What it did not reach, and why,
324is recorded here rather than in a closed issue:
325
326- *The host.* systemd sandboxing, sshd on 2222, the firewall,
327 unattended-upgrades, fail2ban, disk and file modes, restic append-only
328 credentials. Reading production configuration is a separate exercise
329 from auditing the source, and needs doing against bay1.
330- *The supply chain beyond =govulncheck=.* No review of what the
331 dependency set is, who maintains it, or what a compromised release of
332 any of it would reach.
333- *The runner host as an execution environment.* Nobody has tried to
334 escape what is there. #144 covers the missing isolation.
335- *Timing and traffic analysis.* Token comparison is a hash index lookup
336 by design, but nothing has been measured.
337- *Denial of service by resource exhaustion* beyond rate. Concurrent
338 clones, fetches and web archives are bounded by the pack limit
339 (#262); pushes are not, beyond =max_pack_bytes= on each one. Nor are
340 pathological diffs, deep histories, or zip bombs in LFS.
341
342A sweep is a point in time. This section says what a reader should not
343assume has been checked.
344
345* Residual risks, accepted
346
347- External images in rendered READMEs and profile about text load from
348 their origin (no image proxy), which the author can use as a tracking
349 pixel against a viewer. A profile is the wider surface of the two: it
350 is linked from every commit and issue its owner touches. Documented;
351 proxying is future work.
352- Backups snapshot the database first, and repository deletes and
353 moves wait out a full backup. Each repository's refs are archived
354 before its objects, so every archived ref finds the objects it
355 reaches, unless git's own automatic gc after a push repacks during
356 the walk: the archive can then miss objects, and =--verify= reports
357 it. A push during a backup may be missing from the archive, or
358 present as objects no archived ref names, and a repository's refs may
359 be newer than the database snapshot (see [[Admin]]).
360- The audit log lives in the database the daemon writes, so anyone with
361 the daemon user's access can change it. The hash chain is unkeyed:
362 whoever can write the database can edit a row and recompute every
363 later hash. =gitbayd admin audit verify= catches an edited or removed
364 row only when the later hashes were not recomputed, and never catches
365 removing the newest rows or writing new rows under their freed ids.
366 Comparing verify's last id and hash with the daemon's journal copy is
367 the check for any change; rows written outside the daemon (=gitbayd
368 shell= under =ssh.mode = "system"=, host =gitbayd admin= commands)
369 have no journal copy.
370- A global signature-verification epoch over-invalidates the cache on any
371 trust-input change. Correct, not a leak; a performance tradeoff.
372- A build's secrets are environment variables inside its container, so
373 they are visible to =podman inspect= as the runner's user — the same
374 user that already holds them in memory. They reach podman through a
375 0600 env file rather than argv, since =/proc= is world-readable. A
376 value with a newline in it — a private key — cannot go in that file;
377 it is named on podman's command line with =--env NAME= and valued in
378 the runner-owned podman process's environment, so it never touches
379 argv either.