.gitbay/wiki/Threat-Model.org
241 lines · 13549 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.* Import and mirror credentials,
21 registration invites, and API tokens travel on stdin or in request
22 bodies, never as command arguments (visible in =/proc=) or query
23 strings. Tokens are stored only as SHA-256 hashes.
24- *Name a mail recipient in the log.* A queued mail is logged by its
25 queue row id, never by address, and the relay's own error is redacted
26 before it is logged because a rejection usually quotes the address it
27 rejected. The unredacted error and the address stay on the row, which
28 an instance admin reads on =/admin=: the database holds who, the log
29 holds which. One rule for every mail type — notifications, dependency
30 reports and login links all drain the same queue (krz/gitbay#173).
31- *Confirm the existence of private repositories.* Every surface answers
32 "not found" identically for a private repo and a nonexistent one — web
33 pages, git transport, control commands, release asset downloads.
34
35* Trust boundaries
36
37- *SSH public key = identity.* The SSH username is ignored; the presented
38 key's fingerprint resolves to an account. Key uniqueness is global.
39- *Revocation is immediate.* Every exec and every git transport session
40 re-reads its key. Removing a key, removing a deploy key, disabling or
41 deleting an account closes the connections the affected keys opened:
42 a git transport is killed with its children, and a push killed before
43 its pre-receive hook answers moves no ref. A control command already
44 inside its database write finishes it; its output is lost. A
45 revocation made by =gitbayd admin= on the host, another process, is
46 found within 15 seconds. In =ssh.mode = "system"= each exec is its
47 own process: the next exec is refused, one already running is not cut.
48- *Per-instance trust.* Email verification and key registration are local
49 to an instance and never transfer. Account migration re-registers keys
50 and re-verifies emails on the target by design.
51- *The control plane is one command registry*, fully usable from stock
52 OpenSSH and fronted unchanged by the JSON API and the web. No command
53 belongs to one surface (#234): what a caller may do is the account's
54 rights narrowed by its credential's scope, decided in one place, so a
55 bearer token is worth exactly its scope and no more, and a token or
56 SSH key with an expiry cannot create a credential that outlives it.
57 Browser sessions are not covered yet (#297). Git transport never runs
58 over the API.
59- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
60 enabled, the read-only web UI — carry no credentials and expose only
61 public data. HTTP push is refused via a pkt-line =ERR=, never a 401.
62
63* Attacker-controlled parsers
64
65Every parser that eats bytes from a pusher, a key registrant, or an
66anonymous client has a fuzz target and must never panic:
67
68- =internal/protocol= — the SSH command tokenizer (fuzzed against argv
69 round-tripping).
70- =internal/gitd= — the =git://= pkt-line reader.
71- =internal/sig= — the commit parser, the SSHSIG armor decoder and blob
72 parser, and the OpenPGP armored-key reader.
73
74Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=.
75
76* Secret handling
77
78Tokens (web sessions, login links, email verification, API bearer tokens,
79deploy/CI) are random 256-bit values. Only their SHA-256 hash is stored,
80and verification is a database index lookup on that hash — the secret
81itself is never compared in Go, so there is no timing oracle to exploit.
82Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies
83no inbound HMAC.
84
85* Network-facing request forgery
86
87Anything that makes the *server* open an outbound connection to a
88user-supplied address — webhook delivery, GitHub-history import
89=--api-base=, mirror remotes — passes the same SSRF guard: the scheme
90must be http/https and, unless =webhooks.allow_local= is set, the
91resolved address must not be loopback, private, or link-local. The
92webhook dialer re-checks at connect time so a DNS answer that changes
93after validation still cannot reach private space. Redirects are never
94followed.
95
96* Rendering pushed markup
97
98Rendered markup is attacker-controlled: a README, a wiki page and a
99profile's about text are all whatever someone pushed or typed. The risk
100is not only what the output contains but what the *parser* is willing to
101go and fetch — the filesystem counterpart of the SSRF guard above.
102
103Org is rendered by go-org, whose default configuration resolves
104=#+INCLUDE:= and =#+SETUPFILE:= targets with =os.ReadFile=. Both are
105refused outright (=orgConfig()= in =internal/httpd=): the file is never
106opened and the keyword stays the inert text it already was, so the rest
107of the document renders normally. There is no safe subset to allow
108instead — an absolute path skips go-org's relative-path join, a relative
109one resolves against the daemon's working directory, and the content
110came from a git object rather than a checkout, so there is no directory
111to scope a read to. Markdown is goldmark, which has no include
112mechanism. go-org's parse warnings are discarded rather than logged, so
113pushed content cannot write to the server's log.
114
115Org output is then sanitized (bluemonday UGC policy) because go-org
116passes raw HTML through — export blocks and inline export snippets —
117while goldmark drops it and needs no pass. The policy admits chroma's
118short token classes and nothing else.
119
120* Web responses
121
122Every response carries =Content-Security-Policy= (no scripts, no plugins,
123no embedding; inline styles allowed for chroma and label chips; images
124from any origin so external README images render), =X-Frame-Options:
125DENY=, =X-Content-Type-Options: nosniff=, =Referrer-Policy: no-referrer=,
126and =Strict-Transport-Security= when TLS is on. The UI needs no
127JavaScript, so =script-src 'none'= costs nothing.
128
129* The CI runner
130
131=gitbay-runner= is the one component that executes repository content.
132gitbayd never does: it reads =.gitbay/ci.yml= and queues a build, and a
133runner, polling over SSH, clones the commit and runs its steps.
134
135- *What the runner holds.* A key of scope =runner=, which the dispatcher
136 confines to =runner next=, =runner log= and =runner done= and to
137 read-only git, and which claims, logs and finishes builds only for
138 the repositories it is attached to (=repo runner add=). A step that
139 reads the key off the disk gets exactly that: it cannot administer
140 the instance, push, read a repository the runner's account cannot, or
141 touch another repository's builds. An admin key still works for the
142 runner protocol so an operator can rotate at their own pace; a runner
143 host should not hold one. Untrusted builds are skipped unless the
144 runner asks with =-untrusted=, so a runner on a user's machine never
145 executes a stranger's branch by default.
146- *What a build sees.* The commit, the =GITBAY_*= variables and the
147 repository's secrets — unless the head came from another repository.
148 A merge request from a fork is built in the target as untrusted, with
149 no secrets, so a stranger's branch cannot read the target's deploy
150 credentials. The step environment is *constructed*, not inherited: a
151 build gets =PATH=, =HOME=, =LANG=, =CI=, its own variables and its
152 secrets, and nothing the operator set on the service. =HOME= is a build
153 home under the runner's =-workdir=, not the runner's own home, so a
154 build cannot read the =.netrc=, =.npmrc= or =.gitconfig= where tools
155 keep credentials. That home is shared by every build on the runner —
156 one build can poison a cache another reads, which is no more than
157 anything a step can already do as this user, and is what isolation
158 (krz/gitbay#144) is for.
159- *Where it runs.* Steps run in a rootless podman container, one per
160 job, with the workspace bind mounted and nothing else. The clone
161 happens outside it with the runner's key, so the container never sees
162 =GIT_SSH_COMMAND=, the key, or the runner's environment. =-isolation
163 none= runs steps on the host as before, for an instance where every
164 repository is trusted; there is no automatic fallback to it — a runner
165 configured for podman that cannot find one refuses to start, because
166 dropping isolation silently is worse than a stopped runner. The
167 systemd drop-in still adds =ProtectSystem=full= and the cgroup
168 protections, and =-repos= still limits a runner to named
169 repositories. =ProtectKernelTunables= is *off*: it overmounts =/proc=
170 in the unit's namespace and the kernel then refuses a proc mount in
171 any child user namespace, which every rootless container needs; the
172 container masks the same paths for the build itself.
173 =NoNewPrivileges= is *off*: rootless podman sets up its
174 namespace with the setuid =newuidmap=, which that flag blocks, so the
175 choice is between it and containers at all. Containers are the stronger
176 boundary — the flag constrained a process that was already running
177 arbitrary repository code, and under podman that code no longer runs in
178 the runner's process context. Under =-isolation none= there is no
179 container and the flag should be on.
180- *Images are provisioned by the operator, not fetched by a build.* The
181 runner passes =--pull=never=, so =image:= chooses among what the host
182 already has rather than naming anything on the internet. On an
183 instance with open registration that is the difference between a
184 curated set and arbitrary code from a registry nobody vetted.
185
186Under =-isolation none=, anything a step can do as the runner's user a
187pushed =ci.yml= can do. Under podman a step is confined to its
188container, the bind-mounted workspace and the repository's own build
189home, so what a build leaves in a cache is read only by later builds of
190the same repository. Treat the runner host as executing untrusted code
191all the same: keep it off the daemon's host where the database lives,
192or scope it to repositories whose writers you trust. gitbay.org does
193the latter — its runner builds only the repositories the operator
194names.
195
196* What has not been audited
197
198The 2026-09 sweep (krz/gitbay#149) read this repository against the
199claims above. Its two findings — review verdicts from accounts without
200write access deciding merge gates (#147), and control commands having no
201write rate limit while the API had one (#148) — are fixed, as is #173,
202found afterwards in the same milestone. What it did not reach, and why,
203is recorded here rather than in a closed issue:
204
205- *The host.* systemd sandboxing, sshd on 2222, the firewall,
206 unattended-upgrades, fail2ban, disk and file modes, restic append-only
207 credentials. Reading production configuration is a separate exercise
208 from auditing the source, and needs doing against bay1.
209- *The supply chain beyond =govulncheck=.* No review of what the
210 dependency set is, who maintains it, or what a compromised release of
211 any of it would reach.
212- *The runner host as an execution environment.* Nobody has tried to
213 escape what is there. #144 covers the missing isolation.
214- *Timing and traffic analysis.* Token comparison is a hash index lookup
215 by design, but nothing has been measured.
216- *Denial of service by resource exhaustion* beyond rate: large pushes,
217 pathological diffs, deep histories, zip bombs in LFS.
218
219A sweep is a point in time. This section says what a reader should not
220assume has been checked.
221
222* Residual risks, accepted
223
224- External images in rendered READMEs and profile about text load from
225 their origin (no image proxy), which the author can use as a tracking
226 pixel against a viewer. A profile is the wider surface of the two: it
227 is linked from every commit and issue its owner touches. Documented;
228 proxying is future work.
229- Backups are consistent per the DB-snapshot-first ordering but are not a
230 single atomic snapshot; a few orphaned git objects are possible and
231 harmless (see [[Admin]]).
232- A global signature-verification epoch over-invalidates the cache on any
233 trust-input change. Correct, not a leak; a performance tradeoff.
234- A build's secrets are environment variables inside its container, so
235 they are visible to =podman inspect= as the runner's user — the same
236 user that already holds them in memory. They reach podman through a
237 0600 env file rather than argv, since =/proc= is world-readable. A
238 value with a newline in it — a private key — cannot go in that file;
239 it is named on podman's command line with =--env NAME= and valued in
240 the runner-owned podman process's environment, so it never touches
241 argv either.