Commit 5c2215b845

5c2215b8455f840087844b1237205f1b010ff842

parent: 135af80d08

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-28 09:07 UTC

sshd: mint a push token per receive-pack; hook sends it

Closes #282

Layout: unified · split

.gitbay/wiki/Architecture/03-Deployment.org +1 −1
@@ -16,7 +16,7 @@ statements in this document about the host rest on those files.
1616| 80/tcp | HTTP | gitbayd | on with ACME| none; ACME HTTP-01 and redirect only | =cmd/gitbayd/main.go= |
1717| 9418/tcp | git:// | gitbayd | off | none; public repositories only | =internal/gitd= |
1818| 2222/tcp | SSH (operator) | host sshd | on | public key, no passwords, fail2ban | =deploy/cloud-init.yaml= |
19| =<root>/hook.sock= | Unix socket | gitbayd | on | filesystem permissions only | =internal/hookd/hookd.go= |
19| =<root>/hook.sock= | Unix socket | gitbayd | on | mode 0600; peer uid must be the daemon's (Linux); per-push token | =internal/hookd/hookd.go= |
2020
2121With =ssh.mode = system= the host's sshd serves port 22 instead and
2222invokes =gitbayd authorized-keys= and =gitbayd shell=
.gitbay/wiki/Architecture/04-Trust-Boundaries.org +2 −2
@@ -22,7 +22,7 @@
2222| TB2 | Z0 → Z1 HTTPS | page requests, form posts, API calls, fetches, LFS | TLS; session cookie or bearer token; =checkOrigin= on posts; CSP and security headers (=internal/httpd/routes.go=); smart HTTP is fetch-only (=smart.go=) |
2323| TB3 | identity → data | every command | =Dispatch= gates, then =resolveRepo= with =policy= predicates; unreadable repositories are indistinguishable from missing ones ([[file:05-Identity-and-Access.org][5]]) |
2424| TB4 | Z1 → Z3 git | argv, repository path, stdin packs | argv built by code, never a shell; repository path from the database, not the request (=internal/gitutil=) |
25| TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, commit objects | the daemon decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=). The socket trusts the ids in the request, so access to the socket is equivalent to acting as any user; it is reachable only through the =gitbay= user's filesystem |
25| TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, push token, commit objects | the socket is mode 0600 and, on Linux, refuses a peer whose uid is not the daemon's; a request must carry the token sshd minted for its receive-pack (stored hashed in =push_tokens=) and name the same repository, account and scope. The daemon then decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=) |
2626| TB6 | Z4 ↔ Z1 runner channel | build claims (with secrets for trusted builds), logs, results | runner-scoped SSH key; claims limited to attached repositories; secrets only when the build is trusted (=internal/control/build.go=) |
2727| TB7 | Z5 → Z4 container | build steps, workspace, build home | rootless podman, operator-provisioned image, cgroup limits; the build home is shared per repository and the network is open (#255, #260) |
2828| TB8 | Z1 → Z0 outbound | webhooks, mirrors, mail, push | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-Deployment.org][3]]) |
@@ -55,7 +55,7 @@ above.
5555 checks, archive and pull-mirror refusals and the owner's storage
5656 quota (=sshd.go=). TB3.
57572. =git receive-pack= runs with the hook socket path, repository id,
58 user id and key scope in its environment (=sshd.go=). TB4.
58 user id and key scope in its environment, and a push token (=sshd.go=). TB4.
59593. git runs =pre-receive=, which is =gitbayd hook pre-receive=. It
6060 reads the ref updates, computes ancestry in git's quarantine
6161 environment and asks the daemon over the socket
.gitbay/wiki/Architecture/10-Known-Gaps.org −1
@@ -20,7 +20,6 @@ what the 2026-09-27 review found; remove a row when its issue closes.
2020| #274 | Backups | The local backup archive is not encrypted | medium |
2121| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium |
2222| #298 | SSRF | =repo import --from= fetches without an address check | medium |
23| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium |
2423| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
2524
2625* Questions an auditor will ask that have no answer yet
CHANGELOG.org +6
@@ -48,6 +48,12 @@ must add =--scope full=. Existing tokens keep their scope.
4848 syncs, and each records why (#279).
4949- Webhook and mirror targets in 100.64.0.0/10 or on a multicast
5050 address are refused, as private addresses are (#279).
51- The hook socket is mode 0600 and, on Linux, refuses a peer with
52 another uid; each receive-pack gets its own token from sshd, stored
53 hashed (migration 0063), and the hook must present it before the
54 daemon acts. *Operators:* deploy with no push in flight, since a
55 receive-pack started by the old daemon has no token and its
56 post-receive will be refused by the new one (#282).
5157
5258* v1.36.0 — 2026-09-23
5359
cmd/gitbayd/hook.go +1
@@ -172,6 +172,7 @@ func hookCmd() *cobra.Command {
172172 RepoID: repoID,
173173 UserID: userID,
174174 Scope: os.Getenv(hookd.EnvScope),
175 Token: os.Getenv(hookd.EnvToken),
175176 Updates: updates,
176177 }, func(emit func(hookd.RawCommit) error) error {
177178 return streamIncomingCommits(updates, emit)
internal/sshd/sshd.go +10
@@ -575,6 +575,16 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
575575 }
576576 }
577577 }
578 if write {
579 // hookd answers only a hook that names this receive-pack.
580 token, err := st.CreatePushToken(repo.ID, user.ID, scope)
581 if err != nil {
582 fmt.Fprintln(stderr, "internal error")
583 return protocol.ExitFailure
584 }
585 defer st.DeletePushToken(token)
586 env = append(env, hookd.EnvToken+"="+token)
587 }
578588 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil {
579589 return protocol.ExitFailure
580590 }