.gitbay/wiki/Threat-Model.org
151 lines · 7724 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- *Confirm the existence of private repositories.* Every surface answers
24 "not found" identically for a private repo and a nonexistent one — web
25 pages, git transport, control commands, release asset downloads.
26
27* Trust boundaries
28
29- *SSH public key = identity.* The SSH username is ignored; the presented
30 key's fingerprint resolves to an account. Key uniqueness is global.
31- *Per-instance trust.* Email verification and key registration are local
32 to an instance and never transfer. Account migration re-registers keys
33 and re-verifies emails on the target by design.
34- *The control plane is one authenticated channel* (SSH), fully usable
35 from stock OpenSSH. The JSON API fronts the same command registry with
36 bearer tokens minted only over SSH; git transport never runs over it.
37- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
38 enabled, the read-only web UI — carry no credentials and expose only
39 public data. HTTP push is refused via a pkt-line =ERR=, never a 401.
40
41* Attacker-controlled parsers
42
43Every parser that eats bytes from a pusher, a key registrant, or an
44anonymous client has a fuzz target and must never panic:
45
46- =internal/protocol= — the SSH command tokenizer (fuzzed against argv
47 round-tripping).
48- =internal/gitd= — the =git://= pkt-line reader.
49- =internal/sig= — the commit parser, the SSHSIG armor decoder and blob
50 parser, and the OpenPGP armored-key reader.
51
52Run =deploy/audit.sh= to exercise them plus =go vet= and =govulncheck=.
53
54* Secret handling
55
56Tokens (web sessions, login links, email verification, API bearer tokens,
57deploy/CI) are random 256-bit values. Only their SHA-256 hash is stored,
58and verification is a database index lookup on that hash — the secret
59itself is never compared in Go, so there is no timing oracle to exploit.
60Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies
61no inbound HMAC.
62
63* Network-facing request forgery
64
65Anything that makes the *server* open an outbound connection to a
66user-supplied address — webhook delivery, GitHub-history import
67=--api-base=, mirror remotes — passes the same SSRF guard: the scheme
68must be http/https and, unless =webhooks.allow_local= is set, the
69resolved address must not be loopback, private, or link-local. The
70webhook dialer re-checks at connect time so a DNS answer that changes
71after validation still cannot reach private space. Redirects are never
72followed.
73
74* Rendering pushed markup
75
76Rendered markup is attacker-controlled: a README, a wiki page and a
77profile's about text are all whatever someone pushed or typed. The risk
78is not only what the output contains but what the *parser* is willing to
79go and fetch — the filesystem counterpart of the SSRF guard above.
80
81Org is rendered by go-org, whose default configuration resolves
82=#+INCLUDE:= and =#+SETUPFILE:= targets with =os.ReadFile=. Both are
83refused outright (=orgConfig()= in =internal/httpd=): the file is never
84opened and the keyword stays the inert text it already was, so the rest
85of the document renders normally. There is no safe subset to allow
86instead — an absolute path skips go-org's relative-path join, a relative
87one resolves against the daemon's working directory, and the content
88came from a git object rather than a checkout, so there is no directory
89to scope a read to. Markdown is goldmark, which has no include
90mechanism. go-org's parse warnings are discarded rather than logged, so
91pushed content cannot write to the server's log.
92
93Org output is then sanitized (bluemonday UGC policy) because go-org
94passes raw HTML through — export blocks and inline export snippets —
95while goldmark drops it and needs no pass. The policy admits chroma's
96short token classes and nothing else.
97
98* Web responses
99
100Every response carries =Content-Security-Policy= (no scripts, no plugins,
101no embedding; inline styles allowed for chroma and label chips; images
102from any origin so external README images render), =X-Frame-Options:
103DENY=, =X-Content-Type-Options: nosniff=, =Referrer-Policy: no-referrer=,
104and =Strict-Transport-Security= when TLS is on. The UI needs no
105JavaScript, so =script-src 'none'= costs nothing.
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
137* Residual risks, accepted
138
139- External images in rendered READMEs and profile about text load from
140 their origin (no image proxy), which the author can use as a tracking
141 pixel against a viewer. A profile is the wider surface of the two: it
142 is linked from every commit and issue its owner touches. Documented;
143 proxying is future work.
144- Backups are consistent per the DB-snapshot-first ordering but are not a
145 single atomic snapshot; a few orphaned git objects are possible and
146 harmless (see [[Admin]]).
147- A global signature-verification epoch over-invalidates the cache on any
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.