.gitbay/wiki/Threat-Model.org

main
gitbay/.gitbay/wiki/Threat-Model.org rendered · source · history · blame · raw

412 lines · 24711 bytes

11 symbols in this file
  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. With =require_dkim= set, gitbayd
128  verifies the message's DKIM signatures itself, on the bytes as
129  fetched, and requires one whose =d= has the same organizational
130  domain as From; this depends on the sender's domain signing, not on
131  the mail host, and is what an instance whose host adds no
132  =Authentication-Results= for some senders (gitbay.org, at Migadu)
133  relies on. The signature's =h= must cover From, the To or Cc the
134  reply address is read from (a signed message cannot be redirected to
135  another token by adding an unsigned Cc or by Bcc), Content-Type, and
136  Message-ID when present. An unsigned Content-Transfer-Encoding is
137  accepted only as 7bit, 8bit or binary, identity encodings, so adding
138  one cannot change what the signed body decodes to; the encodings in
139  MIME parts are inside the body and covered by =bh=. Header field
140  names are checked on the raw header before anything reads it: a name
141  outside RFC 5322 =ftext= such as =From : x= is one net/mail and the
142  DKIM verifier would file under different names, so a forged From
143  could be read while the signature covers another; such a message is
144  refused, as is one without exactly one From or with a repeated To,
145  Cc, Message-ID, Content-Type or Content-Transfer-Encoding. Signatures
146  with a body length tag are refused, since content appended after the
147  signed length would verify, as are rsa-sha1, keys under 1024 bits and
148  expired signatures. Each passing signature's =b= is recorded with the
149  Message-ID, so a replayed copy does not post twice. Keys are cached
150  for fifteen minutes, so a revoked key is still honoured for up to
151  that long. A DNS failure that may pass
152  delays the reply rather than refusing it; the key lookup is bounded by
153  a five-second timeout and only the first five signatures are checked,
154  so a message cannot make gitbayd wait on many lookups. The key comes
155  from the system resolver and gitbayd does not validate DNSSEC itself;
156  whoever can forge the resolver's answers can forge the key. With both
157  set, either passing is enough; with only =trusted_authserv_id=, the
158  reply address may come from any recipient field, as the mail host
159  vouches for the sender and not for the fields. With neither, the daemon warns at start, and
160  a leaked token plus a forged From posts. The Admin page recommends
161  =require_dkim= for any exposed instance.
162- *Reused ids.* Account and repository ids are reused after a hard
163  delete. A reply is refused when the account or repository was
164  created after its token was minted, so a token cannot post into a
165  later repository, or as a later account, that took the id.
166
167Access is judged when the reply is read, not when the mail was sent:
168the reply is posted by dispatching =issue comment= or =mr comment= as
169the account, so a revoked grant, a private repository, an archived
170repository, a disabled or pending account, or reply by mail turned
171off all refuse it. A =Message-ID= that already posted to a thread as an
172account is not posted there again. Automatic replies (=Auto-Submitted=, =Precedence: bulk=) are
173refused so an out-of-office responder cannot post.
174
175Refusals send nothing back: no bounce, no error mail, so the mailbox
176cannot be used to make the instance mail a forged sender
177(backscatter). Each refusal is an audit row, =refused mail reply=, with
178the reason and the =Message-ID= and none of the message's content,
179bounded at sixty rows a minute. The IMAP connection is TLS or STARTTLS
180with certificate verification; there is no plaintext setting. The
181mailbox password is read from a 0600 file and never logged. The client
182bounds what the server can make it hold: 10 MiB a message (refused by
183size before fetching), about 11 MiB and a thousand responses a
184command, after which the connection is closed; literals other than
185the message body are read and discarded. The Reply-To address is
186blanked from the mail queue once the mail is sent or dead-lettered.
187
188* Network-facing request forgery
189
190Webhook delivery, GitHub-history import =--api-base=, mirror remotes
191and =repo import --from=, which make the *server* open an outbound
192connection to a user-supplied address, pass the same SSRF guard: the
193scheme must be http/https and, unless =webhooks.allow_local= is set,
194the resolved address must not be loopback, private, shared
195(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks
196at connect time, and mirror sync, =repo import= and =repo
197import-issues= resolve and check immediately before running git and pin
198it to the checked addresses (=internal/gitpin=), so a DNS answer that
199changes after validation still cannot reach private space;
200=import-issues= holds its API client to the API host's checked
201addresses the same way, with no proxy taken from the environment.
202Redirects are never followed.
203=repo import= refuses =git://=, which cannot be pinned. Mirror sync
204and =repo import= refuse a host written as a bare number or in
205hex/octal (=0x7f.1=, =2130706433=, =127.1=) rather than dotted
206decimal, since that form resolves differently across parsers; =repo
207mirror add= refuses it when the mirror is saved, and =repo
208import-issues= refuses it in =--api-base=.
209
210* Rendering pushed markup
211
212Rendered markup is attacker-controlled: a README, a wiki page and a
213profile's about text are all whatever someone pushed or typed. The risk
214is not only what the output contains but what the *parser* is willing to
215go and fetch — the filesystem counterpart of the SSRF guard above.
216
217Org is rendered by go-org, whose default configuration resolves
218=#+INCLUDE:= and =#+SETUPFILE:= targets with =os.ReadFile=. Both are
219refused outright (=orgConfig()= in =internal/httpd=): the file is never
220opened and the keyword stays the inert text it already was, so the rest
221of the document renders normally. There is no safe subset to allow
222instead — an absolute path skips go-org's relative-path join, a relative
223one resolves against the daemon's working directory, and the content
224came from a git object rather than a checkout, so there is no directory
225to scope a read to. Markdown is goldmark, which has no include
226mechanism. go-org's parse warnings are discarded rather than logged, so
227pushed content cannot write to the server's log.
228
229Org output is then sanitized (bluemonday UGC policy) because go-org
230passes raw HTML through — export blocks and inline export snippets —
231while goldmark drops it and needs no pass. The policy admits chroma's
232short token classes and nothing else.
233
234* Web responses
235
236Every response carries =Content-Security-Policy= (no scripts, no plugins,
237no embedding; inline styles allowed for chroma and label chips; images
238from any origin so external README images render), =X-Frame-Options:
239DENY=, =X-Content-Type-Options: nosniff=, =Referrer-Policy: no-referrer=,
240and =Strict-Transport-Security= when TLS is on. The UI needs no
241JavaScript, so =script-src 'none'= costs nothing.
242
243* The CI runner
244
245=gitbay-runner= is the one component that executes repository content.
246gitbayd never does: it reads =.gitbay/ci.yml= and queues a build, and a
247runner, polling over SSH, clones the commit and runs its steps.
248
249- *What the runner holds.* A key of scope =runner=, which the dispatcher
250  confines to =runner next=, =runner log= and =runner done= and to
251  read-only git, and which claims, logs and finishes builds only for
252  the repositories it is attached to (=repo runner add=). A step that
253  reads the key off the disk gets exactly that: it cannot administer
254  the instance, push, read a repository the runner's account cannot, or
255  touch another repository's builds. An admin key still works for the
256  runner protocol so an operator can rotate at their own pace; a runner
257  host should not hold one. Untrusted builds are skipped unless the
258  runner asks with =-untrusted=, so a runner on a user's machine never
259  executes a stranger's branch by default.
260- *What a build sees.* The commit, the =GITBAY_*= variables and the
261  repository's secrets — unless the head came from another repository.
262  A merge request from a fork is built in the target as untrusted, with
263  no secrets, so a stranger's branch cannot read the target's deploy
264  credentials. The step environment is *constructed*, not inherited: a
265  build gets =PATH=, =HOME=, =LANG=, =CI=, its own variables and its
266  secrets, and nothing the operator set on the service. =HOME= is a build
267  home under the runner's =-workdir=, not the runner's own home, so a
268  build cannot read the =.netrc=, =.npmrc= or =.gitconfig= where tools
269  keep credentials. A trusted build's home belongs to its repository
270  and persists, so caches survive; an untrusted build's home is new,
271  empty and removed when the build ends, so nothing a fork's build
272  writes is read by a later build (krz/gitbay#255). The claim names a
273  build's trust explicitly, and a runner that finds no trust flag treats
274  the build as untrusted.
275- *Where it runs.* Steps run in a rootless podman container, one per
276  job, with the workspace bind mounted and nothing else. The clone
277  happens outside it with the runner's key, so the container never sees
278  =GIT_SSH_COMMAND=, the key, or the runner's environment. =-isolation
279  none= runs steps on the host as before, for an instance where every
280  repository is trusted; there is no automatic fallback to it — a runner
281  configured for podman that cannot find one refuses to start, because
282  dropping isolation silently is worse than a stopped runner. The
283  systemd drop-in still adds =ProtectSystem=full= and the cgroup
284  protections, and =-repos= still limits a runner to named
285  repositories. =ProtectKernelTunables= is *off*: it overmounts =/proc=
286  in the unit's namespace and the kernel then refuses a proc mount in
287  any child user namespace, which every rootless container needs; the
288  container masks the same paths for the build itself.
289  =NoNewPrivileges= is *off*: rootless podman sets up its
290  namespace with the setuid =newuidmap=, which that flag blocks, so the
291  choice is between it and containers at all. Containers are the stronger
292  boundary — the flag constrained a process that was already running
293  arbitrary repository code, and under podman that code no longer runs in
294  the runner's process context. Under =-isolation none= there is no
295  container and the flag should be on.
296- *Images are provisioned by the operator, not fetched by a build.* The
297  runner passes =--pull=never=, so =image:= chooses among what the host
298  already has rather than naming anything on the internet. On an
299  instance with open registration that is the difference between a
300  curated set and arbitrary code from a registry nobody vetted.
301- *What a build can reach.* A trusted build has the internet; an
302  untrusted one — a fork's merge request head — has TCP 80 and 443 and
303  DNS, enough to fetch modules and packages. Neither reaches private
304  address ranges (RFC 1918, CGNAT, link-local, ULA). On the runner's
305  host a trusted build reaches the forge's public ports 22, 80 and 443
306  and the resolver on loopback; an untrusted build reaches only the
307  resolver. Under pasta a build's container holds the host's own public
308  address, and pasta translates =169.254.1.2= (its =--map-guest-addr=)
309  to that address. A runner that polls the daemon over loopback starts
310  its containers with =--network pasta:--no-map-gw=, which is podman's
311  default stated explicitly, and gives them =GITBAY_SSH= at
312  =169.254.1.2=, with the forge's port when it is not 22. The runner's
313  source address is =127.0.0.1=; a trusted build's is the host's public
314  address. Two nftables tables enforce this. The first
315  (=deploy/gitbay-runner-egress.nft=) matches the runner's user and
316  rejects every connection it makes to the host's own addresses except
317  =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public
318  address: the operator's sshd on 2222 and anything bound to loopback
319  are closed. Under rootless podman a build's connections are made by
320  pasta as that same user, so this table cannot tell a build from its
321  runner. The second (=deploy/gitbay-runner-builds.nft=) can: the runner
322  starts every podman process for a build, pasta included, inside
323  =builds/trusted/build-<id>= or =builds/untrusted/build-<id>= under its
324  service cgroup, and the table matches sockets by those cgroups
325  (=socket cgroupv2=). It closes the host's loopback, =127.0.0.1:22=
326  included, to every build except for DNS, and applies the per-trust
327  rules above. The runner does not start without either table. The SSH auth limiter
328  counts failures per source address and, once an address is over the
329  limit, refuses every key from it until the window passes, the
330  runner's included; an unknown key counts only with registration
331  closed, an expired key always (krz/gitbay#260). No build shares
332  =127.0.0.1= with the runner, and an untrusted build cannot reach sshd
333  at all. Trusted builds share the public address with one another, so
334  one that fails logins can throttle another's push for a minute. Under
335  =-isolation none= a build runs on the host in the runner's cgroup and
336  shares its loopback; only the first table applies, and such a runner
337  must not take =-untrusted=.
338
339Under =-isolation none=, anything a step can do as the runner's user a
340pushed =ci.yml= can do. Under podman a step is confined to its
341container, the bind-mounted workspace and its build home: a trusted
342build's cache is read only by later trusted builds of the same
343repository, and an untrusted build's home is discarded with it. Treat
344the runner host as executing untrusted code all the same: keep it off
345the daemon's host where the database lives, or scope it to repositories
346whose writers you trust. gitbay.org does the latter — its runner builds
347only the repositories the operator names.
348
349* What has not been audited
350
351The 2026-09 sweep (krz/gitbay#149) read this repository against the
352claims above. Its two findings — review verdicts from accounts without
353write access deciding merge gates (#147), and control commands having no
354write rate limit while the API had one (#148) — are fixed, as is #173,
355found afterwards in the same milestone. What it did not reach, and why,
356is recorded here rather than in a closed issue:
357
358- *The host.* systemd sandboxing, sshd on 2222, the firewall,
359  unattended-upgrades, fail2ban, disk and file modes, restic append-only
360  credentials. Reading production configuration is a separate exercise
361  from auditing the source, and needs doing against bay1.
362- *The supply chain beyond =govulncheck=.* No review of what the
363  dependency set is, who maintains it, or what a compromised release of
364  any of it would reach.
365- *The runner host as an execution environment.* Nobody has tried to
366  escape what is there. #144 covers the missing isolation.
367- *Timing and traffic analysis.* Token comparison is a hash index lookup
368  by design, but nothing has been measured.
369- *Denial of service by resource exhaustion* beyond rate. Concurrent
370  clones, fetches, web archives and =repo download= are bounded by the
371  pack limit (#262, #308), and pushes by a separate push limit (#308)
372  on top of =max_pack_bytes= on each one. Pathological diffs, deep
373  histories and zip bombs in LFS are not.
374
375A sweep is a point in time. This section says what a reader should not
376assume has been checked.
377
378* Residual risks, accepted
379
380- External images in rendered READMEs and profile about text load from
381  their origin (no image proxy), which the author can use as a tracking
382  pixel against a viewer. A profile is the wider surface of the two: it
383  is linked from every commit and issue its owner touches. Documented;
384  proxying is future work.
385- Backups snapshot the database first, and repository deletes and
386  moves wait out a full backup. Each repository's refs are archived
387  before its objects, so every archived ref finds the objects it
388  reaches, unless git's own automatic gc after a push repacks during
389  the walk: the archive can then miss objects, and =--verify= reports
390  it. A push during a backup may be missing from the archive, or
391  present as objects no archived ref names, and a repository's refs may
392  be newer than the database snapshot (see [[Admin]]).
393- The audit log lives in the database the daemon writes, so anyone with
394  the daemon user's access can change it. The hash chain is unkeyed:
395  whoever can write the database can edit a row and recompute every
396  later hash. =gitbayd admin audit verify= catches an edited or removed
397  row only when the later hashes were not recomputed, and never catches
398  removing the newest rows or writing new rows under their freed ids.
399  Comparing verify's last id and hash with the daemon's journal copy is
400  the check for any change; rows written outside the daemon (=gitbayd
401  shell= under =ssh.mode = "system"=, host =gitbayd admin= commands)
402  have no journal copy.
403- A global signature-verification epoch over-invalidates the cache on any
404  trust-input change. Correct, not a leak; a performance tradeoff.
405- A build's secrets are environment variables inside its container, so
406  they are visible to =podman inspect= as the runner's user — the same
407  user that already holds them in memory. They reach podman through a
408  0600 env file rather than argv, since =/proc= is world-readable. A
409  value with a newline in it — a private key — cannot go in that file;
410  it is named on podman's command line with =--env NAME= and valued in
411  the runner-owned podman process's environment, so it never touches
412  argv either.