.gitbay/wiki/Threat-Model.org

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

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