#+title: gitbay threat model 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 [[file:Architecture/00-Overview.org][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/plain= with =nosniff=. 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 carries =Cache-Control: no-store= so 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 admin= on the host, another process, is found within 15 seconds. In =ssh.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-line =ERR=, 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= — the =git://= 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 to =runner next=, =runner log= and =runner done= and 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 gets =PATH=, =HOME=, =LANG=, =CI=, its own variables and its secrets, and nothing the operator set on the service. =HOME= is a build home under the runner's =-workdir=, not the runner's own home, so a build cannot read the =.netrc=, =.npmrc= or =.gitconfig= where 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 none= runs 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 adds =ProtectSystem=full= and the cgroup protections, and =-repos= still limits a runner to named repositories. =ProtectKernelTunables= is *off*: it overmounts =/proc= in 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. =NoNewPrivileges= is *off*: rootless podman sets up its namespace with the setuid =newuidmap=, 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 none= there 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=, so =image:= 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 them =GITBAY_SSH= at =169.254.1.2=, with the forge's port when it is not 22. The runner's source address is =127.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 except =127.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 leaves =127.0.0.1:22= open; 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 none= a 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 snapshot the database first, and repository deletes and moves wait out a full backup. Each repository's refs are archived before its objects, so every archived ref finds the objects it reaches, unless git's own automatic gc after a push repacks during the walk: the archive can then miss objects, and =--verify= reports it. A push during a backup may be missing from the archive, or present as objects no archived ref names, and a repository's refs may be newer than the database snapshot (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 shell= under =ssh.mode = "system"=, host =gitbayd admin= commands) 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 inspect= as 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 =/proc= is 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 NAME= and valued in the runner-owned podman process's environment, so it never touches argv either.