.gitbay/wiki/Threat-Model.org

00033f022fed6fe0b36e1d7a1e2b3f1df6139ccd
gitbay/.gitbay/wiki/Threat-Model.org rendered · source · history · blame · raw

151 lines · 7724 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.

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.
  • 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.
  • 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 authenticated channel (SSH), fully usable from stock OpenSSH. The JSON API fronts the same command registry with bearer tokens minted only over SSH; git transport never runs over it.
  • 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 added with keys add --scope runner, which the dispatcher confines to runner next, runner log and runner done and to read-only git. A step that reads the key off the disk gets exactly that: it cannot administer the instance, push, or read a repository the runner's account cannot. 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.
  • 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.
  • Where it runs. Steps run as the runner's own user on the runner host, with no container; the systemd drop-in adds NoNewPrivileges, ProtectSystem=full and the kernel and cgroup protections. -repos limits a runner to named repositories, which is the control that matters on an open instance: without it a runner builds whatever anyone pushes.

Anything a step can do as the runner's user, a pushed ci.yml can do. Treat the runner host as executing untrusted code: keep it off the daemon's host where the database lives, or scope it to repositories whose writers you trust.

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.
  • Build steps run as the runner's user with no container. Isolation is the key scope, the sandboxing drop-in and -repos; containers are future work.