Commit e977337c14

e977337c141d5bc5acbe0bccfa97c657a13feee3

parent: a4c429937c

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-03 21:42 UTC

Threat model: the CI runner; Admin: runner key scope, MR builds, trusted_proxies

Ref krz/gitbay#138

Layout: unified · split

Admin.org +29 −7
@@ -75,6 +75,12 @@ validation still prints, followed by the contradiction.
75- =files=: =cert_file= + =key_file=. 75- =files=: =cert_file= + =key_file=.
76- =off=: plain HTTP — development, or behind a TLS-terminating proxy. 76- =off=: plain HTTP — development, or behind a TLS-terminating proxy.
77 77
78=trusted_proxies= lists the addresses or CIDRs of reverse proxies in
79front of the daemon. A request from one of them is attributed, for API
80rate limiting, to the last =X-Forwarded-For= hop that is not itself a
81trusted proxy; from anyone else the header is ignored. Empty, the
82default, is right when gitbayd terminates TLS itself.
83
78** [web] 84** [web]
79- =mode= — =view_only= (default) | =accounts=. In view_only the mutating 85- =mode= — =view_only= (default) | =accounts=. In view_only the mutating
80 web routes are never registered; in accounts, browser sessions are 86 web routes are never registered; in accounts, browser sessions are
@@ -302,23 +308,39 @@ startup to point at the current binary path.
302 308
303* CI runner 309* CI runner
304 310
305=gitbay-runner= executes builds queued by pushes. It polls over SSH as 311=gitbay-runner= executes builds queued by pushes and merge requests. It
306an admin account (runner commands are admin-only: a runner executes 312polls over SSH with a key added by =keys add --scope runner=, which
307arbitrary repo code), clones, runs the steps, streams the log back, and 313reaches only the runner protocol and read-only git (a runner executes
308resolves the commit status. Run it as a dedicated unprivileged user: 314arbitrary repository code, so the key it holds must not do more), then
315clones, runs the steps, streams the log back and resolves the commit
316status. Run it as a dedicated unprivileged user on a non-admin account.
317=admin user create --key= registers a full-scope key, so the runner key
318is added afterwards through a bootstrap key that is then removed:
309 319
310#+begin_src sh 320#+begin_src sh
311useradd --system --create-home --home-dir /var/lib/gitbay-runner ci-runner 321useradd --system --create-home --home-dir /var/lib/gitbay-runner ci-runner
312sudo -u ci-runner ssh-keygen -t ed25519 -N "" -f /var/lib/gitbay-runner/.ssh/id_ed25519 322sudo -u ci-runner ssh-keygen -t ed25519 -N "" -f /var/lib/gitbay-runner/.ssh/id_ed25519
313gitbayd --config /etc/gitbay/config.toml admin user create ci --admin \ 323ssh-keygen -t ed25519 -N "" -f /tmp/ci-bootstrap
314 --key /var/lib/gitbay-runner/.ssh/id_ed25519.pub 324gitbayd --config /etc/gitbay/config.toml admin user create ci --key /tmp/ci-bootstrap.pub
325ssh -i /tmp/ci-bootstrap git@127.0.0.1 keys add --scope runner < /var/lib/gitbay-runner/.ssh/id_ed25519.pub
326ssh -i /tmp/ci-bootstrap git@127.0.0.1 keys remove "$(ssh-keygen -lf /tmp/ci-bootstrap.pub | awk '{print $2}')"
327rm /tmp/ci-bootstrap /tmp/ci-bootstrap.pub
315gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work 328gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work
316#+end_src 329#+end_src
317 330
331=admin runners= shows which account each runner polls as; an admin key
332still works for the protocol during a rotation. A merge request head
333from a fork is built in the target repository as untrusted: the claim
334carries no secrets. Same-repository heads were built by their branch
335push and are not built again.
336
318=make deploy-runner= also installs 337=make deploy-runner= also installs
319=deploy/gitbay-runner.override.conf= as a systemd drop-in: =Nice=10=, 338=deploy/gitbay-runner.override.conf= as a systemd drop-in: =Nice=10=,
320=CPUWeight=30=, =IOWeight=30=, so a build never starves the host's sshd, 339=CPUWeight=30=, =IOWeight=30=, so a build never starves the host's sshd,
321the daemon or the backup timers. The e2e suite alone starts sixty 340the daemon or the backup timers, and =NoNewPrivileges=,
341=ProtectSystem=full=, =ProtectKernelTunables=, =ProtectControlGroups=
342and =RestrictSUIDSGID=, so a step cannot reach outside its workspace
343and the runner's home. The e2e suite alone starts sixty
322daemon instances; without the drop-in a deploy's copy over the admin 344daemon instances; without the drop-in a deploy's copy over the admin
323sshd stalled. Both deploy targets copy with =rsync --partial=, which 345sshd stalled. Both deploy targets copy with =rsync --partial=, which
324resumes a stalled transfer. 346resumes a stalled transfer.
Threat-Model.org +33
@@ -104,6 +104,36 @@ DENY=, =X-Content-Type-Options: nosniff=, =Referrer-Policy: no-referrer=,
104and =Strict-Transport-Security= when TLS is on. The UI needs no 104and =Strict-Transport-Security= when TLS is on. The UI needs no
105JavaScript, so =script-src 'none'= costs nothing. 105JavaScript, so =script-src 'none'= costs nothing.
106 106
107* The CI runner
108
109=gitbay-runner= is the one component that executes repository content.
110gitbayd never does: it reads =.gitbay/ci.yml= and queues a build, and a
111runner, polling over SSH, clones the commit and runs its steps.
112
113- *What the runner holds.* A key added with =keys add --scope runner=,
114 which the dispatcher confines to =runner next=, =runner log= and
115 =runner done= and to read-only git. A step that reads the key off the
116 disk gets exactly that: it cannot administer the instance, push, or
117 read a repository the runner's account cannot. An admin key still
118 works for the runner protocol so an operator can rotate at their own
119 pace; a runner host should not hold one.
120- *What a build sees.* The commit, the =GITBAY_*= variables and the
121 repository's secrets — unless the head came from another repository.
122 A merge request from a fork is built in the target as untrusted, with
123 no secrets, so a stranger's branch cannot read the target's deploy
124 credentials.
125- *Where it runs.* Steps run as the runner's own user on the runner
126 host, with no container; the systemd drop-in adds =NoNewPrivileges=,
127 =ProtectSystem=full= and the kernel and cgroup protections. =-repos=
128 limits a runner to named repositories, which is the control that
129 matters on an open instance: without it a runner builds whatever
130 anyone pushes.
131
132Anything a step can do as the runner's user, a pushed =ci.yml= can do.
133Treat the runner host as executing untrusted code: keep it off the
134daemon's host where the database lives, or scope it to repositories
135whose writers you trust.
136
107* Residual risks, accepted 137* Residual risks, accepted
108 138
109- External images in rendered READMEs and profile about text load from 139- External images in rendered READMEs and profile about text load from
@@ -116,3 +146,6 @@ JavaScript, so =script-src 'none'= costs nothing.
116 harmless (see [[Admin]]). 146 harmless (see [[Admin]]).
117- A global signature-verification epoch over-invalidates the cache on any 147- A global signature-verification epoch over-invalidates the cache on any
118 trust-input change. Correct, not a leak; a performance tradeoff. 148 trust-input change. Correct, not a leak; a performance tradeoff.
149- Build steps run as the runner's user with no container. Isolation is
150 the key scope, the sandboxing drop-in and =-repos=; containers are
151 future work.