.gitbay/wiki/Threat-Model.org

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

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