.gitbay/wiki/Threat-Model.org
296 lines · 17262 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 Browser sessions are not covered yet (#297). Git transport never runs
65 over the API.
66- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
67 enabled, the read-only web UI — carry no credentials and expose only
68 public data. HTTP push is refused via a pkt-line =ERR=, never a 401.
69
70* Attacker-controlled parsers
71
72Every parser that eats bytes from a pusher, a key registrant, or an
73anonymous client has a fuzz target and must never panic:
74
75- =internal/protocol= — the SSH command tokenizer (fuzzed against argv
76 round-tripping).
77- =internal/gitd= — the =git://= pkt-line reader.
78- =internal/sig= — the commit parser, the SSHSIG armor decoder and blob
79 parser, and the OpenPGP armored-key reader.
80
81Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=.
82
83* Secret handling
84
85Tokens (web sessions, login links, email verification, API bearer tokens,
86deploy/CI) are random 256-bit values. Only their SHA-256 hash is stored,
87and verification is a database index lookup on that hash — the secret
88itself is never compared in Go, so there is no timing oracle to exploit.
89Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies
90no inbound HMAC.
91
92* Network-facing request forgery
93
94Webhook delivery, GitHub-history import =--api-base= and mirror
95remotes, which make the *server* open an outbound connection to a
96user-supplied address, pass the same SSRF guard: the scheme
97must be http/https and, unless =webhooks.allow_local= is set, the
98resolved address must not be loopback, private, shared
99(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks
100at connect time, and the mirror worker resolves and checks before each
101sync and pins git to the checked addresses, so a DNS answer that
102changes after validation still cannot reach private space. Redirects
103are never followed. =repo import --from= is the exception: its clone
104checks the scheme but not the address, and follows git's default
105redirect rule (#298).
106
107* Rendering pushed markup
108
109Rendered markup is attacker-controlled: a README, a wiki page and a
110profile's about text are all whatever someone pushed or typed. The risk
111is not only what the output contains but what the *parser* is willing to
112go and fetch — the filesystem counterpart of the SSRF guard above.
113
114Org is rendered by go-org, whose default configuration resolves
115=#+INCLUDE:= and =#+SETUPFILE:= targets with =os.ReadFile=. Both are
116refused outright (=orgConfig()= in =internal/httpd=): the file is never
117opened and the keyword stays the inert text it already was, so the rest
118of the document renders normally. There is no safe subset to allow
119instead — an absolute path skips go-org's relative-path join, a relative
120one resolves against the daemon's working directory, and the content
121came from a git object rather than a checkout, so there is no directory
122to scope a read to. Markdown is goldmark, which has no include
123mechanism. go-org's parse warnings are discarded rather than logged, so
124pushed content cannot write to the server's log.
125
126Org output is then sanitized (bluemonday UGC policy) because go-org
127passes raw HTML through — export blocks and inline export snippets —
128while goldmark drops it and needs no pass. The policy admits chroma's
129short token classes and nothing else.
130
131* Web responses
132
133Every response carries =Content-Security-Policy= (no scripts, no plugins,
134no embedding; inline styles allowed for chroma and label chips; images
135from any origin so external README images render), =X-Frame-Options:
136DENY=, =X-Content-Type-Options: nosniff=, =Referrer-Policy: no-referrer=,
137and =Strict-Transport-Security= when TLS is on. The UI needs no
138JavaScript, so =script-src 'none'= costs nothing.
139
140* The CI runner
141
142=gitbay-runner= is the one component that executes repository content.
143gitbayd never does: it reads =.gitbay/ci.yml= and queues a build, and a
144runner, polling over SSH, clones the commit and runs its steps.
145
146- *What the runner holds.* A key of scope =runner=, which the dispatcher
147 confines to =runner next=, =runner log= and =runner done= and to
148 read-only git, and which claims, logs and finishes builds only for
149 the repositories it is attached to (=repo runner add=). A step that
150 reads the key off the disk gets exactly that: it cannot administer
151 the instance, push, read a repository the runner's account cannot, or
152 touch another repository's builds. An admin key still works for the
153 runner protocol so an operator can rotate at their own pace; a runner
154 host should not hold one. Untrusted builds are skipped unless the
155 runner asks with =-untrusted=, so a runner on a user's machine never
156 executes a stranger's branch by default.
157- *What a build sees.* The commit, the =GITBAY_*= variables and the
158 repository's secrets — unless the head came from another repository.
159 A merge request from a fork is built in the target as untrusted, with
160 no secrets, so a stranger's branch cannot read the target's deploy
161 credentials. The step environment is *constructed*, not inherited: a
162 build gets =PATH=, =HOME=, =LANG=, =CI=, its own variables and its
163 secrets, and nothing the operator set on the service. =HOME= is a build
164 home under the runner's =-workdir=, not the runner's own home, so a
165 build cannot read the =.netrc=, =.npmrc= or =.gitconfig= where tools
166 keep credentials. A trusted build's home belongs to its repository
167 and persists, so caches survive; an untrusted build's home is new,
168 empty and removed when the build ends, so nothing a fork's build
169 writes is read by a later build (krz/gitbay#255). The claim names a
170 build's trust explicitly, and a runner that finds no trust flag treats
171 the build as untrusted.
172- *Where it runs.* Steps run in a rootless podman container, one per
173 job, with the workspace bind mounted and nothing else. The clone
174 happens outside it with the runner's key, so the container never sees
175 =GIT_SSH_COMMAND=, the key, or the runner's environment. =-isolation
176 none= runs steps on the host as before, for an instance where every
177 repository is trusted; there is no automatic fallback to it — a runner
178 configured for podman that cannot find one refuses to start, because
179 dropping isolation silently is worse than a stopped runner. The
180 systemd drop-in still adds =ProtectSystem=full= and the cgroup
181 protections, and =-repos= still limits a runner to named
182 repositories. =ProtectKernelTunables= is *off*: it overmounts =/proc=
183 in the unit's namespace and the kernel then refuses a proc mount in
184 any child user namespace, which every rootless container needs; the
185 container masks the same paths for the build itself.
186 =NoNewPrivileges= is *off*: rootless podman sets up its
187 namespace with the setuid =newuidmap=, which that flag blocks, so the
188 choice is between it and containers at all. Containers are the stronger
189 boundary — the flag constrained a process that was already running
190 arbitrary repository code, and under podman that code no longer runs in
191 the runner's process context. Under =-isolation none= there is no
192 container and the flag should be on.
193- *Images are provisioned by the operator, not fetched by a build.* The
194 runner passes =--pull=never=, so =image:= chooses among what the host
195 already has rather than naming anything on the internet. On an
196 instance with open registration that is the difference between a
197 curated set and arbitrary code from a registry nobody vetted.
198- *What a build can reach.* Outbound internet, trusted or not: a fork's
199 merge request to a Go repository has to fetch its modules. On the
200 runner's host, the rules below limit it to the forge's public ports
201 22, 80 and 443. Under pasta a build's container holds the host's own
202 public address, and pasta translates =169.254.1.2= (its
203 =--map-guest-addr=) to that address. A runner that polls the daemon
204 over loopback starts its containers with =--network
205 pasta:--no-map-gw=, which is podman's default stated explicitly, and
206 gives them =GITBAY_SSH= at =169.254.1.2=, with the forge's port when
207 it is not 22. The runner's source address is =127.0.0.1=. An nftables
208 table (=deploy/gitbay-runner-egress.nft=) rejects every connection
209 the runner's user makes to the host's own addresses except
210 =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public
211 address: the operator's sshd on 2222 and anything bound to loopback
212 are closed to it. Under rootless podman a build's connections are
213 made by pasta as the runner's user, so the table cannot tell a build
214 from its runner and leaves =127.0.0.1:22= open; that no build reaches
215 the host's loopback is measured from inside a build by runbook R3.
216 The runner does not start without the table. The SSH auth limiter
217 counts failures per source address and, once an address is over the
218 limit, refuses every key from it until the window passes, the
219 runner's included; with registration open or by invite an unknown
220 key never counts (krz/gitbay#260). Under =-isolation none= a build
221 runs on the host and shares its loopback; the table still applies,
222 since it runs as the same user.
223
224Under =-isolation none=, anything a step can do as the runner's user a
225pushed =ci.yml= can do. Under podman a step is confined to its
226container, the bind-mounted workspace and its build home: a trusted
227build's cache is read only by later trusted builds of the same
228repository, and an untrusted build's home is discarded with it. Treat
229the runner host as executing untrusted code all the same: keep it off
230the daemon's host where the database lives, or scope it to repositories
231whose writers you trust. gitbay.org does the latter — its runner builds
232only the repositories the operator names.
233
234* What has not been audited
235
236The 2026-09 sweep (krz/gitbay#149) read this repository against the
237claims above. Its two findings — review verdicts from accounts without
238write access deciding merge gates (#147), and control commands having no
239write rate limit while the API had one (#148) — are fixed, as is #173,
240found afterwards in the same milestone. What it did not reach, and why,
241is recorded here rather than in a closed issue:
242
243- *The host.* systemd sandboxing, sshd on 2222, the firewall,
244 unattended-upgrades, fail2ban, disk and file modes, restic append-only
245 credentials. Reading production configuration is a separate exercise
246 from auditing the source, and needs doing against bay1.
247- *The supply chain beyond =govulncheck=.* No review of what the
248 dependency set is, who maintains it, or what a compromised release of
249 any of it would reach.
250- *The runner host as an execution environment.* Nobody has tried to
251 escape what is there. #144 covers the missing isolation.
252- *Timing and traffic analysis.* Token comparison is a hash index lookup
253 by design, but nothing has been measured.
254- *Denial of service by resource exhaustion* beyond rate. Concurrent
255 clones, fetches and web archives are bounded by the pack limit
256 (#262); pushes are not, beyond =max_pack_bytes= on each one. Nor are
257 pathological diffs, deep histories, or zip bombs in LFS.
258
259A sweep is a point in time. This section says what a reader should not
260assume has been checked.
261
262* Residual risks, accepted
263
264- External images in rendered READMEs and profile about text load from
265 their origin (no image proxy), which the author can use as a tracking
266 pixel against a viewer. A profile is the wider surface of the two: it
267 is linked from every commit and issue its owner touches. Documented;
268 proxying is future work.
269- Backups snapshot the database first, and repository deletes and
270 moves wait out a full backup. Each repository's refs are archived
271 before its objects, so every archived ref finds the objects it
272 reaches, unless git's own automatic gc after a push repacks during
273 the walk: the archive can then miss objects, and =--verify= reports
274 it. A push during a backup may be missing from the archive, or
275 present as objects no archived ref names, and a repository's refs may
276 be newer than the database snapshot (see [[Admin]]).
277- The audit log lives in the database the daemon writes, so anyone with
278 the daemon user's access can change it. The hash chain is unkeyed:
279 whoever can write the database can edit a row and recompute every
280 later hash. =gitbayd admin audit verify= catches an edited or removed
281 row only when the later hashes were not recomputed, and never catches
282 removing the newest rows or writing new rows under their freed ids.
283 Comparing verify's last id and hash with the daemon's journal copy is
284 the check for any change; rows written outside the daemon (=gitbayd
285 shell= under =ssh.mode = "system"=, host =gitbayd admin= commands)
286 have no journal copy.
287- A global signature-verification epoch over-invalidates the cache on any
288 trust-input change. Correct, not a leak; a performance tradeoff.
289- A build's secrets are environment variables inside its container, so
290 they are visible to =podman inspect= as the runner's user — the same
291 user that already holds them in memory. They reach podman through a
292 0600 env file rather than argv, since =/proc= is world-readable. A
293 value with a newline in it — a private key — cannot go in that file;
294 it is named on podman's command line with =--env NAME= and valued in
295 the runner-owned podman process's environment, so it never touches
296 argv either.