.gitbay/wiki/Threat-Model.org
285 lines · 16561 bytes
gitbay threat model
- What gitbay never does
- Trust boundaries
- Attacker-controlled parsers
- Secret handling
- Network-facing request forgery
- Rendering pushed markup
- Web responses
- The CI runner
- What has not been audited
- Residual risks, accepted
What the forge trusts, what it refuses to do, and where the boundaries are. This is the reference for security review; it complements the audit log and hardening notes in Admin. Diagrams, data flows, a controls matrix and the open gaps are in the Architecture pages.
What gitbay never does
- Execute repository content. Git object contents are never run. Hooks are gitbay's own binary, invoked by git; they compute facts and ask the daemon over a unix socket. Repo files are only ever read.
- Hold a signing key. There is no server-side signing key. "Verified"
means a signature made by a key the user registered — the server
never vouches for a commit it did not receive already signed. Merge
commits the server creates are honestly
unsigned. - Serve repository HTML on its own origin as active content. Raw file
serving is
text/plainwithnosniff. Rendered markdown/org is sanitized (bluemonday) and served under a CSP that forbids scripts. - Put secrets in argv, URLs, or logs, with one documented exception.
Import and mirror credentials, registration invites, and API tokens
travel on stdin or in request bodies, never as command arguments
(visible in
/proc) or query strings. The one exception is the emailed login link,/login?token…=: single-use, 15-minute expiry, and the response that consumes it carriesCache-Control: no-storeso no intermediary keeps a copy. An operator running gitbay behind a reverse proxy should configure that proxy to strip the query string from its own access logs. Tokens are stored only as SHA-256 hashes. - Name a mail recipient in the log. A queued mail is logged by its
queue row id, never by address, and the relay's own error is redacted
before it is logged because a rejection usually quotes the address it
rejected. The unredacted error and the address stay on the row, which
an instance admin reads on
/admin: the database holds who, the log holds which. One rule for every mail type — notifications, dependency reports and login links all drain the same queue (krz/gitbay#173). - Confirm the existence of private repositories. Every surface answers "not found" identically for a private repo and a nonexistent one — web pages, git transport, control commands, release asset downloads.
Trust boundaries
- SSH public key = identity. The SSH username is ignored; the presented key's fingerprint resolves to an account. Key uniqueness is global.
- Revocation is immediate. Every exec and every git transport session
re-reads its key. Removing a key, removing a deploy key, disabling or
deleting an account closes the connections the affected keys opened:
a git transport is killed with its children, and a push killed before
its pre-receive hook answers moves no ref. A control command already
inside its database write finishes it; its output is lost. A
revocation made by
gitbayd adminon the host, another process, is found within 15 seconds. Inssh.mode = "system"each exec is its own process: the next exec is refused, one already running is not cut. - Per-instance trust. Email verification and key registration are local to an instance and never transfer. Account migration re-registers keys and re-verifies emails on the target by design.
- The control plane is one command registry, fully usable from stock OpenSSH and fronted unchanged by the JSON API and the web. No command belongs to one surface (#234): what a caller may do is the account's rights narrowed by its credential's scope, decided in one place, so a bearer token is worth exactly its scope and no more, and a token or SSH key with an expiry cannot create a credential that outlives it. Browser sessions are not covered yet (#297). Git transport never runs over the API.
- Anonymous surfaces — HTTPS clone of public repos,
git://where enabled, the read-only web UI — carry no credentials and expose only public data. HTTP push is refused via a pkt-lineERR, never a 401.
Attacker-controlled parsers
Every parser that eats bytes from a pusher, a key registrant, or an anonymous client has a fuzz target and must never panic:
internal/protocol— the SSH command tokenizer (fuzzed against argv round-tripping).internal/gitd— thegit://pkt-line reader.internal/sig— the commit parser, the SSHSIG armor decoder and blob parser, and the OpenPGP armored-key reader.
Run deploy/audit.sh to exercise them plus go vet and govulncheck.
Secret handling
Tokens (web sessions, login links, email verification, API bearer tokens, deploy/CI) are random 256-bit values. Only their SHA-256 hash is stored, and verification is a database index lookup on that hash — the secret itself is never compared in Go, so there is no timing oracle to exploit. Webhook payloads are signed outbound with HMAC-SHA256; the forge verifies no inbound HMAC.
Network-facing request forgery
Webhook delivery, GitHub-history import --api-base and mirror
remotes, which make the server open an outbound connection to a
user-supplied address, pass the same SSRF guard: the scheme
must be http/https and, unless webhooks.allow_local is set, the
resolved address must not be loopback, private, shared
(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks
at connect time, and the mirror worker resolves and checks before each
sync and pins git to the checked addresses, so a DNS answer that
changes after validation still cannot reach private space. Redirects
are never followed. repo import --from is the exception: its clone
checks the scheme but not the address, and follows git's default
redirect rule (#298).
Rendering pushed markup
Rendered markup is attacker-controlled: a README, a wiki page and a profile's about text are all whatever someone pushed or typed. The risk is not only what the output contains but what the parser is willing to go and fetch — the filesystem counterpart of the SSRF guard above.
Org is rendered by go-org, whose default configuration resolves
#+INCLUDE: and #+SETUPFILE: targets with os.ReadFile. Both are
refused outright (orgConfig() in internal/httpd): the file is never
opened and the keyword stays the inert text it already was, so the rest
of the document renders normally. There is no safe subset to allow
instead — an absolute path skips go-org's relative-path join, a relative
one resolves against the daemon's working directory, and the content
came from a git object rather than a checkout, so there is no directory
to scope a read to. Markdown is goldmark, which has no include
mechanism. go-org's parse warnings are discarded rather than logged, so
pushed content cannot write to the server's log.
Org output is then sanitized (bluemonday UGC policy) because go-org passes raw HTML through — export blocks and inline export snippets — while goldmark drops it and needs no pass. The policy admits chroma's short token classes and nothing else.
Web responses
Every response carries Content-Security-Policy (no scripts, no plugins,
no embedding; inline styles allowed for chroma and label chips; images
from any origin so external README images render), X-Frame-Options:
DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer,
and Strict-Transport-Security when TLS is on. The UI needs no
JavaScript, so script-src 'none' costs nothing.
The CI runner
gitbay-runner is the one component that executes repository content.
gitbayd never does: it reads .gitbay/ci.yml and queues a build, and a
runner, polling over SSH, clones the commit and runs its steps.
- What the runner holds. A key of scope
runner, which the dispatcher confines torunner next,runner logandrunner doneand to read-only git, and which claims, logs and finishes builds only for the repositories it is attached to (repo runner add). A step that reads the key off the disk gets exactly that: it cannot administer the instance, push, read a repository the runner's account cannot, or touch another repository's builds. An admin key still works for the runner protocol so an operator can rotate at their own pace; a runner host should not hold one. Untrusted builds are skipped unless the runner asks with-untrusted, so a runner on a user's machine never executes a stranger's branch by default. - What a build sees. The commit, the
GITBAY_*variables and the repository's secrets — unless the head came from another repository. A merge request from a fork is built in the target as untrusted, with no secrets, so a stranger's branch cannot read the target's deploy credentials. The step environment is constructed, not inherited: a build getsPATH,HOME,LANG,CI, its own variables and its secrets, and nothing the operator set on the service.HOMEis a build home under the runner's-workdir, not the runner's own home, so a build cannot read the.netrc,.npmrcor.gitconfigwhere tools keep credentials. A trusted build's home belongs to its repository and persists, so caches survive; an untrusted build's home is new, empty and removed when the build ends, so nothing a fork's build writes is read by a later build (krz/gitbay#255). The claim names a build's trust explicitly, and a runner that finds no trust flag treats the build as untrusted. - Where it runs. Steps run in a rootless podman container, one per
job, with the workspace bind mounted and nothing else. The clone
happens outside it with the runner's key, so the container never sees
GIT_SSH_COMMAND, the key, or the runner's environment.-isolation noneruns steps on the host as before, for an instance where every repository is trusted; there is no automatic fallback to it — a runner configured for podman that cannot find one refuses to start, because dropping isolation silently is worse than a stopped runner. The systemd drop-in still addsProtectSystem=fulland the cgroup protections, and-reposstill limits a runner to named repositories.ProtectKernelTunablesis off: it overmounts/procin the unit's namespace and the kernel then refuses a proc mount in any child user namespace, which every rootless container needs; the container masks the same paths for the build itself.NoNewPrivilegesis off: rootless podman sets up its namespace with the setuidnewuidmap, which that flag blocks, so the choice is between it and containers at all. Containers are the stronger boundary — the flag constrained a process that was already running arbitrary repository code, and under podman that code no longer runs in the runner's process context. Under-isolation nonethere is no container and the flag should be on. - Images are provisioned by the operator, not fetched by a build. The
runner passes
--pull=never, soimage:chooses among what the host already has rather than naming anything on the internet. On an instance with open registration that is the difference between a curated set and arbitrary code from a registry nobody vetted. - What a build can reach. Outbound internet, trusted or not: a fork's
merge request to a Go repository has to fetch its modules. On the
runner's host, the rules below limit it to the forge's public ports
22, 80 and 443. Under pasta a build's container holds the host's own
public address, and pasta translates
169.254.1.2(its--map-guest-addr) to that address. A runner that polls the daemon over loopback starts its containers with--network pasta:--no-map-gw, which is podman's default stated explicitly, and gives themGITBAY_SSHat169.254.1.2, with the forge's port when it is not 22. The runner's source address is127.0.0.1. An nftables table (deploy/gitbay-runner-egress.nft) rejects every connection the runner's user makes to the host's own addresses except127.0.0.1:22, DNS on loopback, and 22, 80 and 443 on the public address: the operator's sshd on 2222 and anything bound to loopback are closed to it. Under rootless podman a build's connections are made by pasta as the runner's user, so the table cannot tell a build from its runner and leaves127.0.0.1:22open; that no build reaches the host's loopback is measured from inside a build by runbook R3. The runner does not start without the table. The SSH auth limiter counts failures per source address and, once an address is over the limit, refuses every key from it until the window passes, the runner's included; with registration open or by invite an unknown key never counts (krz/gitbay#260). Under-isolation nonea build runs on the host and shares its loopback; the table still applies, since it runs as the same user.
Under -isolation none, anything a step can do as the runner's user a
pushed ci.yml can do. Under podman a step is confined to its
container, the bind-mounted workspace and its build home: a trusted
build's cache is read only by later trusted builds of the same
repository, and an untrusted build's home is discarded with it. Treat
the runner host as executing untrusted code all the same: keep it off
the daemon's host where the database lives, or scope it to repositories
whose writers you trust. gitbay.org does the latter — its runner builds
only the repositories the operator names.
What has not been audited
The 2026-09 sweep (krz/gitbay#149) read this repository against the claims above. Its two findings — review verdicts from accounts without write access deciding merge gates (#147), and control commands having no write rate limit while the API had one (#148) — are fixed, as is #173, found afterwards in the same milestone. What it did not reach, and why, is recorded here rather than in a closed issue:
- The host. systemd sandboxing, sshd on 2222, the firewall, unattended-upgrades, fail2ban, disk and file modes, restic append-only credentials. Reading production configuration is a separate exercise from auditing the source, and needs doing against bay1.
- The supply chain beyond
govulncheck. No review of what the dependency set is, who maintains it, or what a compromised release of any of it would reach. - The runner host as an execution environment. Nobody has tried to escape what is there. #144 covers the missing isolation.
- Timing and traffic analysis. Token comparison is a hash index lookup by design, but nothing has been measured.
- Denial of service by resource exhaustion beyond rate: large pushes, pathological diffs, deep histories, zip bombs in LFS.
A sweep is a point in time. This section says what a reader should not assume has been checked.
Residual risks, accepted
- External images in rendered READMEs and profile about text load from their origin (no image proxy), which the author can use as a tracking pixel against a viewer. A profile is the wider surface of the two: it is linked from every commit and issue its owner touches. Documented; proxying is future work.
- Backups are consistent per the DB-snapshot-first ordering but are not a single atomic snapshot; a few orphaned git objects are possible and harmless (see Admin).
- The audit log lives in the database the daemon writes, so anyone with
the daemon user's access can change it. The hash chain makes an edited
or removed row show as a break under
gitbayd admin audit verify, except at the end: removing the newest rows, and writing new rows under their freed ids, leaves a valid chain. Only comparing verify's last id and hash with the daemon's journal copy shows it, and rows written outside the daemon (gitbayd shellunderssh.mode = "system", hostgitbayd admincommands) have no journal copy. - A global signature-verification epoch over-invalidates the cache on any trust-input change. Correct, not a leak; a performance tradeoff.
- A build's secrets are environment variables inside its container, so
they are visible to
podman inspectas the runner's user — the same user that already holds them in memory. They reach podman through a 0600 env file rather than argv, since/procis world-readable. A value with a newline in it — a private key — cannot go in that file; it is named on podman's command line with--env NAMEand valued in the runner-owned podman process's environment, so it never touches argv either.