.gitbay/wiki/Threat-Model.org
184 lines · 9557 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 authenticated channel* (SSH), fully usable
42 from stock OpenSSH. The JSON API fronts the same command registry with
43 bearer tokens minted only over SSH; git transport never runs over it.
44- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
45 enabled, the read-only web UI — carry no credentials and expose only
46 public data. HTTP push is refused via a pkt-line =ERR=, never a 401.
47
48* Attacker-controlled parsers
49
50Every parser that eats bytes from a pusher, a key registrant, or an
51anonymous client has a fuzz target and must never panic:
52
53- =internal/protocol= — the SSH command tokenizer (fuzzed against argv
54 round-tripping).
55- =internal/gitd= — the =git://= pkt-line reader.
56- =internal/sig= — the commit parser, the SSHSIG armor decoder and blob
57 parser, and the OpenPGP armored-key reader.
58
59Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=.
60
61* Secret handling
62
63Tokens (web sessions, login links, email verification, API bearer tokens,
64deploy/CI) are random 256-bit values. Only their SHA-256 hash is stored,
65and verification is a database index lookup on that hash — the secret
66itself is never compared in Go, so there is no timing oracle to exploit.
67Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies
68no inbound HMAC.
69
70* Network-facing request forgery
71
72Anything that makes the *server* open an outbound connection to a
73user-supplied address — webhook delivery, GitHub-history import
74=--api-base=, mirror remotes — passes the same SSRF guard: the scheme
75must be http/https and, unless =webhooks.allow_local= is set, the
76resolved address must not be loopback, private, or link-local. The
77webhook dialer re-checks at connect time so a DNS answer that changes
78after validation still cannot reach private space. Redirects are never
79followed.
80
81* Rendering pushed markup
82
83Rendered markup is attacker-controlled: a README, a wiki page and a
84profile's about text are all whatever someone pushed or typed. The risk
85is not only what the output contains but what the *parser* is willing to
86go and fetch — the filesystem counterpart of the SSRF guard above.
87
88Org is rendered by go-org, whose default configuration resolves
89=#+INCLUDE:= and =#+SETUPFILE:= targets with =os.ReadFile=. Both are
90refused outright (=orgConfig()= in =internal/httpd=): the file is never
91opened and the keyword stays the inert text it already was, so the rest
92of the document renders normally. There is no safe subset to allow
93instead — an absolute path skips go-org's relative-path join, a relative
94one resolves against the daemon's working directory, and the content
95came from a git object rather than a checkout, so there is no directory
96to scope a read to. Markdown is goldmark, which has no include
97mechanism. go-org's parse warnings are discarded rather than logged, so
98pushed content cannot write to the server's log.
99
100Org output is then sanitized (bluemonday UGC policy) because go-org
101passes raw HTML through — export blocks and inline export snippets —
102while goldmark drops it and needs no pass. The policy admits chroma's
103short token classes and nothing else.
104
105* Web responses
106
107Every response carries =Content-Security-Policy= (no scripts, no plugins,
108no embedding; inline styles allowed for chroma and label chips; images
109from any origin so external README images render), =X-Frame-Options:
110DENY=, =X-Content-Type-Options: nosniff=, =Referrer-Policy: no-referrer=,
111and =Strict-Transport-Security= when TLS is on. The UI needs no
112JavaScript, so =script-src 'none'= costs nothing.
113
114* The CI runner
115
116=gitbay-runner= is the one component that executes repository content.
117gitbayd never does: it reads =.gitbay/ci.yml= and queues a build, and a
118runner, polling over SSH, clones the commit and runs its steps.
119
120- *What the runner holds.* A key added with =keys add --scope runner=,
121 which the dispatcher confines to =runner next=, =runner log= and
122 =runner done= and to read-only git. A step that reads the key off the
123 disk gets exactly that: it cannot administer the instance, push, or
124 read a repository the runner's account cannot. An admin key still
125 works for the runner protocol so an operator can rotate at their own
126 pace; a runner host should not hold one.
127- *What a build sees.* The commit, the =GITBAY_*= variables and the
128 repository's secrets — unless the head came from another repository.
129 A merge request from a fork is built in the target as untrusted, with
130 no secrets, so a stranger's branch cannot read the target's deploy
131 credentials.
132- *Where it runs.* Steps run as the runner's own user on the runner
133 host, with no container; the systemd drop-in adds =NoNewPrivileges=,
134 =ProtectSystem=full= and the kernel and cgroup protections. =-repos=
135 limits a runner to named repositories, which is the control that
136 matters on an open instance: without it a runner builds whatever
137 anyone pushes.
138
139Anything a step can do as the runner's user, a pushed =ci.yml= can do.
140Treat the runner host as executing untrusted code: keep it off the
141daemon's host where the database lives, or scope it to repositories
142whose writers you trust.
143
144* What has not been audited
145
146The 2026-09 sweep (krz/gitbay#149) read this repository against the
147claims above. Its two findings — review verdicts from accounts without
148write access deciding merge gates (#147), and control commands having no
149write rate limit while the API had one (#148) — are fixed, as is #173,
150found afterwards in the same milestone. What it did not reach, and why,
151is recorded here rather than in a closed issue:
152
153- *The host.* systemd sandboxing, sshd on 2222, the firewall,
154 unattended-upgrades, fail2ban, disk and file modes, restic append-only
155 credentials. Reading production configuration is a separate exercise
156 from auditing the source, and needs doing against bay1.
157- *The supply chain beyond =govulncheck=.* No review of what the
158 dependency set is, who maintains it, or what a compromised release of
159 any of it would reach.
160- *The runner host as an execution environment.* Nobody has tried to
161 escape what is there. #144 covers the missing isolation.
162- *Timing and traffic analysis.* Token comparison is a hash index lookup
163 by design, but nothing has been measured.
164- *Denial of service by resource exhaustion* beyond rate: large pushes,
165 pathological diffs, deep histories, zip bombs in LFS.
166
167A sweep is a point in time. This section says what a reader should not
168assume has been checked.
169
170* Residual risks, accepted
171
172- External images in rendered READMEs and profile about text load from
173 their origin (no image proxy), which the author can use as a tracking
174 pixel against a viewer. A profile is the wider surface of the two: it
175 is linked from every commit and issue its owner touches. Documented;
176 proxying is future work.
177- Backups are consistent per the DB-snapshot-first ordering but are not a
178 single atomic snapshot; a few orphaned git objects are possible and
179 harmless (see [[Admin]]).
180- A global signature-verification epoch over-invalidates the cache on any
181 trust-input change. Correct, not a leak; a performance tradeoff.
182- Build steps run as the runner's user with no container. Isolation is
183 the key scope, the sandboxing drop-in and =-repos=; containers are
184 future work.