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.
16| 80/tcp | HTTP | gitbayd | on with ACME| none; ACME HTTP-01 and redirect only | =cmd/gitbayd/main.go= | 16| 80/tcp | HTTP | gitbayd | on with ACME| none; ACME HTTP-01 and redirect only | =cmd/gitbayd/main.go= |
17| 9418/tcp | git:// | gitbayd | off | none; public repositories only | =internal/gitd= | 17| 9418/tcp | git:// | gitbayd | off | none; public repositories only | =internal/gitd= |
18| 2222/tcp | SSH (operator) | host sshd | on | public key, no passwords, fail2ban | =deploy/cloud-init.yaml= | 18| 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= |
20 20
21With =ssh.mode = system= the host's sshd serves port 22 instead and 21With =ssh.mode = system= the host's sshd serves port 22 instead and
22invokes =gitbayd authorized-keys= and =gitbayd shell= 22invokes =gitbayd authorized-keys= and =gitbayd shell=
.gitbay/wiki/Architecture/04-Trust-Boundaries.org +2 −2
@@ -22,7 +22,7 @@
22| 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=) | 22| 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=) |
23| 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]]) | 23| 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]]) |
24| 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=) | 24| 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=) |
26| 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=) | 26| 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=) |
27| 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) | 27| 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) |
28| TB8 | Z1 → Z0 outbound | webhooks, mirrors, mail, push | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-Deployment.org][3]]) | 28| 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.
55 checks, archive and pull-mirror refusals and the owner's storage 55 checks, archive and pull-mirror refusals and the owner's storage
56 quota (=sshd.go=). TB3. 56 quota (=sshd.go=). TB3.
572. =git receive-pack= runs with the hook socket path, repository id, 572. =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.
593. git runs =pre-receive=, which is =gitbayd hook pre-receive=. It 593. git runs =pre-receive=, which is =gitbayd hook pre-receive=. It
60 reads the ref updates, computes ancestry in git's quarantine 60 reads the ref updates, computes ancestry in git's quarantine
61 environment and asks the daemon over the socket 61 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.
20| #274 | Backups | The local backup archive is not encrypted | medium | 20| #274 | Backups | The local backup archive is not encrypted | medium |
21| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium | 21| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium |
22| #298 | SSRF | =repo import --from= fetches without an address check | medium | 22| #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 |
24| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | 23| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
25 24
26* Questions an auditor will ask that have no answer yet 25* 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.
48 syncs, and each records why (#279). 48 syncs, and each records why (#279).
49- Webhook and mirror targets in 100.64.0.0/10 or on a multicast 49- Webhook and mirror targets in 100.64.0.0/10 or on a multicast
50 address are refused, as private addresses are (#279). 50 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).
51 57
52* v1.36.0 — 2026-09-23 58* v1.36.0 — 2026-09-23
53 59
cmd/gitbayd/hook.go +1
@@ -172,6 +172,7 @@ func hookCmd() *cobra.Command {
172 RepoID: repoID, 172 RepoID: repoID,
173 UserID: userID, 173 UserID: userID,
174 Scope: os.Getenv(hookd.EnvScope), 174 Scope: os.Getenv(hookd.EnvScope),
175 Token: os.Getenv(hookd.EnvToken),
175 Updates: updates, 176 Updates: updates,
176 }, func(emit func(hookd.RawCommit) error) error { 177 }, func(emit func(hookd.RawCommit) error) error {
177 return streamIncomingCommits(updates, emit) 178 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
575 } 575 }
576 } 576 }
577 } 577 }
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 }
578 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil { 588 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil {
579 return protocol.ExitFailure 589 return protocol.ExitFailure
580 } 590 }