#+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. An LFS transfer token names the key that obtained it and is refused from the moment that key is. 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. A browser session can create one, or grant access, only within 15 minutes of signing in (=control.ReauthWindow=, #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. - =internal/mailin= — an inbound reply's headers, reply token, MIME body and quote stripping, where anyone who can send mail to the reply mailbox chooses the bytes. - =internal/imapc= — the IMAP response reader, literals included. 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. * Reply by mail When =[mail.inbound]= is on (#295), anyone can send mail to the reply mailbox, and a posted reply is a comment written as an account. Two things are required together, because neither is enough alone: - *The reply token.* Each Reply-To is =reply+@=; the token names the recipient, the repository, the issue or merge request, and an expiry thirty days out, under an HMAC-SHA256 truncated to 96 bits. Its key is derived from the secret key file (=seal.Keyring.Derive=), which lives outside =server.root= and out of backups; no row is stored per message. Verification is =hmac.Equal=, against every key in the file so a rotation does not break mail already sent, and only the canonical encoding is accepted. A token is per recipient: it is not a credential for anyone else's account or any other thread. - *The sender address.* =From= must be one of the token's account's verified addresses. A leaked token (a forwarded notification, a mailing-list archive, a shared inbox) is not enough to post without also sending as that person. =From= is only what the sender wrote unless the mail host vouches for it. With =[mail.inbound] trusted_authserv_id= set, gitbay reads the topmost =Authentication-Results= header carrying that id and requires DMARC pass for the From domain or a DKIM pass whose =header.d= has the same organizational domain (public suffix list); a forged header lower down, claiming the same id, is ignored. Quoted strings and comments are tokenized as RFC 8601 defines them, so sender-controlled text the mail host echoes into its header (a quoted MAIL FROM local part, a reason) cannot read as a result; =FuzzAuthResults= checks that. That rests on the mail host removing incoming headers that claim its id (RFC 8601 §5); Gmail, Fastmail and Migadu do. With =require_dkim= set, gitbayd verifies the message's DKIM signatures itself, on the bytes as fetched, and requires one whose =d= has the same organizational domain as From; this depends on the sender's domain signing, not on the mail host, and is what an instance whose host adds no =Authentication-Results= for some senders (gitbay.org, at Migadu) relies on. The signature's =h= must cover From, the To or Cc the reply address is read from (a signed message cannot be redirected to another token by adding an unsigned Cc or by Bcc), Content-Type, and Message-ID when present. An unsigned Content-Transfer-Encoding is accepted only as 7bit, 8bit or binary, identity encodings, so adding one cannot change what the signed body decodes to; the encodings in MIME parts are inside the body and covered by =bh=. Header field names are checked on the raw header before anything reads it: a name outside RFC 5322 =ftext= such as =From : x= is one net/mail and the DKIM verifier would file under different names, so a forged From could be read while the signature covers another; such a message is refused, as is one without exactly one From or with a repeated To, Cc, Message-ID, Content-Type or Content-Transfer-Encoding. Signatures with a body length tag are refused, since content appended after the signed length would verify, as are rsa-sha1, keys under 1024 bits and expired signatures. Each passing signature's =b= is recorded with the Message-ID, so a replayed copy does not post twice. Keys are cached for fifteen minutes, so a revoked key is still honoured for up to that long. A DNS failure that may pass delays the reply rather than refusing it; the key lookup is bounded by a five-second timeout and only the first five signatures are checked, so a message cannot make gitbayd wait on many lookups. The key comes from the system resolver and gitbayd does not validate DNSSEC itself; whoever can forge the resolver's answers can forge the key. With both set, either passing is enough; with only =trusted_authserv_id=, the reply address may come from any recipient field, as the mail host vouches for the sender and not for the fields. With neither, the daemon warns at start, and a leaked token plus a forged From posts. The Admin page recommends =require_dkim= for any exposed instance. - *Reused ids.* Account and repository ids are reused after a hard delete. A reply is refused when the account or repository was created after its token was minted, so a token cannot post into a later repository, or as a later account, that took the id. Access is judged when the reply is read, not when the mail was sent: the reply is posted by dispatching =issue comment= or =mr comment= as the account, so a revoked grant, a private repository, an archived repository, a disabled or pending account, or reply by mail turned off all refuse it. A =Message-ID= that already posted to a thread as an account is not posted there again. Automatic replies (=Auto-Submitted=, =Precedence: bulk=) are refused so an out-of-office responder cannot post. Refusals send nothing back: no bounce, no error mail, so the mailbox cannot be used to make the instance mail a forged sender (backscatter). Each refusal is an audit row, =refused mail reply=, with the reason and the =Message-ID= and none of the message's content, bounded at sixty rows a minute. The IMAP connection is TLS or STARTTLS with certificate verification; there is no plaintext setting. The mailbox password is read from a 0600 file and never logged. The client bounds what the server can make it hold: 10 MiB a message (refused by size before fetching), about 11 MiB and a thousand responses a command, after which the connection is closed; literals other than the message body are read and discarded. The Reply-To address is blanked from the mail queue once the mail is sent or dead-lettered. * Network-facing request forgery Webhook delivery, GitHub-history import =--api-base=, mirror remotes and =repo import --from=, 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 mirror sync, =repo import= and =repo import-issues= resolve and check immediately before running git and pin it to the checked addresses (=internal/gitpin=), so a DNS answer that changes after validation still cannot reach private space; =import-issues= holds its API client to the API host's checked addresses the same way, with no proxy taken from the environment. Redirects are never followed. =repo import= refuses =git://=, which cannot be pinned. Mirror sync and =repo import= refuse a host written as a bare number or in hex/octal (=0x7f.1=, =2130706433=, =127.1=) rather than dotted decimal, since that form resolves differently across parsers; =repo mirror add= refuses it when the mirror is saved, and =repo import-issues= refuses it in =--api-base=. * 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.* A trusted build has the internet; an untrusted one — a fork's merge request head — has TCP 80 and 443 and DNS, enough to fetch modules and packages. Neither reaches private address ranges (RFC 1918, CGNAT, link-local, ULA). On the runner's host a trusted build reaches the forge's public ports 22, 80 and 443 and the resolver on loopback; an untrusted build reaches only the resolver. 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=; a trusted build's is the host's public address. Two nftables tables enforce this. The first (=deploy/gitbay-runner-egress.nft=) matches the runner's user and rejects every connection it 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. Under rootless podman a build's connections are made by pasta as that same user, so this table cannot tell a build from its runner. The second (=deploy/gitbay-runner-builds.nft=) can: the runner starts every podman process for a build, pasta included, inside =builds/trusted/build-= or =builds/untrusted/build-= under its service cgroup, and the table matches sockets by those cgroups (=socket cgroupv2=). It closes the host's loopback, =127.0.0.1:22= included, to every build except for DNS, and applies the per-trust rules above. The runner does not start without either 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; an unknown key counts only with registration closed, an expired key always (krz/gitbay#260). No build shares =127.0.0.1= with the runner, and an untrusted build cannot reach sshd at all. Trusted builds share the public address with one another, so one that fails logins can throttle another's push for a minute. Under =-isolation none= a build runs on the host in the runner's cgroup and shares its loopback; only the first table applies, and such a runner must not take =-untrusted=. 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. Concurrent clones, fetches and web archives are bounded by the pack limit (#262); pushes are not, beyond =max_pack_bytes= on each one. Nor are pathological diffs, deep histories, or 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 is unkeyed: whoever can write the database can edit a row and recompute every later hash. =gitbayd admin audit verify= catches an edited or removed row only when the later hashes were not recomputed, and never catches removing the newest rows or writing new rows under their freed ids. Comparing verify's last id and hash with the daemon's journal copy is the check for any change; 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.