.gitbay/wiki/Threat-Model.org

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

253 lines · 14357 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
 87Webhook delivery, GitHub-history import =--api-base= and mirror
 88remotes, which make the *server* open an outbound connection to a
 89user-supplied address, pass 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, shared
 92(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks
 93at connect time, and the mirror worker resolves and checks before each
 94sync and pins git to the checked addresses, so a DNS answer that
 95changes after validation still cannot reach private space. Redirects
 96are never followed. =repo import --from= is the exception: its clone
 97checks the scheme but not the address, and follows git's default
 98redirect rule (#298).
 99
100* Rendering pushed markup
101
102Rendered markup is attacker-controlled: a README, a wiki page and a
103profile's about text are all whatever someone pushed or typed. The risk
104is not only what the output contains but what the *parser* is willing to
105go and fetch — the filesystem counterpart of the SSRF guard above.
106
107Org is rendered by go-org, whose default configuration resolves
108=#+INCLUDE:= and =#+SETUPFILE:= targets with =os.ReadFile=. Both are
109refused outright (=orgConfig()= in =internal/httpd=): the file is never
110opened and the keyword stays the inert text it already was, so the rest
111of the document renders normally. There is no safe subset to allow
112instead — an absolute path skips go-org's relative-path join, a relative
113one resolves against the daemon's working directory, and the content
114came from a git object rather than a checkout, so there is no directory
115to scope a read to. Markdown is goldmark, which has no include
116mechanism. go-org's parse warnings are discarded rather than logged, so
117pushed content cannot write to the server's log.
118
119Org output is then sanitized (bluemonday UGC policy) because go-org
120passes raw HTML through — export blocks and inline export snippets —
121while goldmark drops it and needs no pass. The policy admits chroma's
122short token classes and nothing else.
123
124* Web responses
125
126Every response carries =Content-Security-Policy= (no scripts, no plugins,
127no embedding; inline styles allowed for chroma and label chips; images
128from any origin so external README images render), =X-Frame-Options:
129DENY=, =X-Content-Type-Options: nosniff=, =Referrer-Policy: no-referrer=,
130and =Strict-Transport-Security= when TLS is on. The UI needs no
131JavaScript, so =script-src 'none'= costs nothing.
132
133* The CI runner
134
135=gitbay-runner= is the one component that executes repository content.
136gitbayd never does: it reads =.gitbay/ci.yml= and queues a build, and a
137runner, polling over SSH, clones the commit and runs its steps.
138
139- *What the runner holds.* A key of scope =runner=, which the dispatcher
140  confines to =runner next=, =runner log= and =runner done= and to
141  read-only git, and which claims, logs and finishes builds only for
142  the repositories it is attached to (=repo runner add=). A step that
143  reads the key off the disk gets exactly that: it cannot administer
144  the instance, push, read a repository the runner's account cannot, or
145  touch another repository's builds. An admin key still works for the
146  runner protocol so an operator can rotate at their own pace; a runner
147  host should not hold one. Untrusted builds are skipped unless the
148  runner asks with =-untrusted=, so a runner on a user's machine never
149  executes a stranger's branch by default.
150- *What a build sees.* The commit, the =GITBAY_*= variables and the
151  repository's secrets — unless the head came from another repository.
152  A merge request from a fork is built in the target as untrusted, with
153  no secrets, so a stranger's branch cannot read the target's deploy
154  credentials. The step environment is *constructed*, not inherited: a
155  build gets =PATH=, =HOME=, =LANG=, =CI=, its own variables and its
156  secrets, and nothing the operator set on the service. =HOME= is a build
157  home under the runner's =-workdir=, not the runner's own home, so a
158  build cannot read the =.netrc=, =.npmrc= or =.gitconfig= where tools
159  keep credentials. That home is shared by every build on the runner —
160  one build can poison a cache another reads, which is no more than
161  anything a step can already do as this user, and is what isolation
162  (krz/gitbay#144) is for.
163- *Where it runs.* Steps run in a rootless podman container, one per
164  job, with the workspace bind mounted and nothing else. The clone
165  happens outside it with the runner's key, so the container never sees
166  =GIT_SSH_COMMAND=, the key, or the runner's environment. =-isolation
167  none= runs steps on the host as before, for an instance where every
168  repository is trusted; there is no automatic fallback to it — a runner
169  configured for podman that cannot find one refuses to start, because
170  dropping isolation silently is worse than a stopped runner. The
171  systemd drop-in still adds =ProtectSystem=full= and the cgroup
172  protections, and =-repos= still limits a runner to named
173  repositories. =ProtectKernelTunables= is *off*: it overmounts =/proc=
174  in the unit's namespace and the kernel then refuses a proc mount in
175  any child user namespace, which every rootless container needs; the
176  container masks the same paths for the build itself.
177  =NoNewPrivileges= is *off*: rootless podman sets up its
178  namespace with the setuid =newuidmap=, which that flag blocks, so the
179  choice is between it and containers at all. Containers are the stronger
180  boundary — the flag constrained a process that was already running
181  arbitrary repository code, and under podman that code no longer runs in
182  the runner's process context. Under =-isolation none= there is no
183  container and the flag should be on.
184- *Images are provisioned by the operator, not fetched by a build.* The
185  runner passes =--pull=never=, so =image:= chooses among what the host
186  already has rather than naming anything on the internet. On an
187  instance with open registration that is the difference between a
188  curated set and arbitrary code from a registry nobody vetted.
189
190Under =-isolation none=, anything a step can do as the runner's user a
191pushed =ci.yml= can do. Under podman a step is confined to its
192container, the bind-mounted workspace and the repository's own build
193home, so what a build leaves in a cache is read only by later builds of
194the same repository. Treat the runner host as executing untrusted code
195all the same: keep it off the daemon's host where the database lives,
196or scope it to repositories whose writers you trust. gitbay.org does
197the latter — its runner builds only the repositories the operator
198names.
199
200* What has not been audited
201
202The 2026-09 sweep (krz/gitbay#149) read this repository against the
203claims above. Its two findings — review verdicts from accounts without
204write access deciding merge gates (#147), and control commands having no
205write rate limit while the API had one (#148) — are fixed, as is #173,
206found afterwards in the same milestone. What it did not reach, and why,
207is recorded here rather than in a closed issue:
208
209- *The host.* systemd sandboxing, sshd on 2222, the firewall,
210  unattended-upgrades, fail2ban, disk and file modes, restic append-only
211  credentials. Reading production configuration is a separate exercise
212  from auditing the source, and needs doing against bay1.
213- *The supply chain beyond =govulncheck=.* No review of what the
214  dependency set is, who maintains it, or what a compromised release of
215  any of it would reach.
216- *The runner host as an execution environment.* Nobody has tried to
217  escape what is there. #144 covers the missing isolation.
218- *Timing and traffic analysis.* Token comparison is a hash index lookup
219  by design, but nothing has been measured.
220- *Denial of service by resource exhaustion* beyond rate: large pushes,
221  pathological diffs, deep histories, zip bombs in LFS.
222
223A sweep is a point in time. This section says what a reader should not
224assume has been checked.
225
226* Residual risks, accepted
227
228- External images in rendered READMEs and profile about text load from
229  their origin (no image proxy), which the author can use as a tracking
230  pixel against a viewer. A profile is the wider surface of the two: it
231  is linked from every commit and issue its owner touches. Documented;
232  proxying is future work.
233- Backups are consistent per the DB-snapshot-first ordering but are not a
234  single atomic snapshot; a few orphaned git objects are possible and
235  harmless (see [[Admin]]).
236- The audit log lives in the database the daemon writes, so anyone with
237  the daemon user's access can change it. The hash chain makes an edited
238  or removed row show as a break under =gitbayd admin audit verify=,
239  except at the end: removing the newest rows, and writing new rows
240  under their freed ids, leaves a valid chain. Only comparing verify's
241  last id and hash with the daemon's journal copy shows it, and rows
242  written outside the daemon (=gitbayd shell= under =ssh.mode =
243  "system"=, host =gitbayd admin= commands) have no journal copy.
244- A global signature-verification epoch over-invalidates the cache on any
245  trust-input change. Correct, not a leak; a performance tradeoff.
246- A build's secrets are environment variables inside its container, so
247  they are visible to =podman inspect= as the runner's user — the same
248  user that already holds them in memory. They reach podman through a
249  0600 env file rather than argv, since =/proc= is world-readable. A
250  value with a newline in it — a private key — cannot go in that file;
251  it is named on podman's command line with =--env NAME= and valued in
252  the runner-owned podman process's environment, so it never touches
253  argv either.