.gitbay/wiki/Threat-Model.org

32a5f76e5b270097b63a5bba9a43557cf50ad63d
gitbay/.gitbay/wiki/Threat-Model.org rendered · source · history · blame · raw

241 lines · 13549 bytes

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 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. 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. 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

Anything that makes the server open an outbound connection to a user-supplied address — webhook delivery, GitHub-history import --api-base, mirror remotes — passes 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, or link-local. The webhook dialer re-checks at connect time so a DNS answer that changes after validation still cannot reach private space. Redirects are never followed.

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. That home is shared by every build on the runner — one build can poison a cache another reads, which is no more than anything a step can already do as this user, and is what isolation (krz/gitbay#144) is for.
  • 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.

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 the repository's own build home, so what a build leaves in a cache is read only by later builds of the same repository. 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).
  • 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.