hookd: authenticate the hook socket !494

merged merged by cmc on 2026-09-28 21:50 UTC · krz/gitbay:hook-socket-auth into main

15 files changed, +321 −4

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/hookd/hookd.go +36
@@ -36,6 +36,10 @@ const (
36 EnvRepoID = "GITBAY_REPO_ID" 36 EnvRepoID = "GITBAY_REPO_ID"
37 EnvUserID = "GITBAY_USER_ID" 37 EnvUserID = "GITBAY_USER_ID"
38 EnvScope = "GITBAY_KEY_SCOPE" 38 EnvScope = "GITBAY_KEY_SCOPE"
39 // EnvToken names the receive-pack this hook runs under. sshd mints
40 // it per push; hookd answers only a request carrying a live one
41 // whose repository, account and scope match the request's.
42 EnvToken = "GITBAY_PUSH_TOKEN"
39) 43)
40 44
41type Request struct { 45type Request struct {
@@ -46,6 +50,7 @@ type Request struct {
46 // the key belongs to, and a deploy key grants nothing outside its 50 // the key belongs to, and a deploy key grants nothing outside its
47 // binding, so anything acting on another repository needs this too. 51 // binding, so anything acting on another repository needs this too.
48 Scope string `json:"scope"` 52 Scope string `json:"scope"`
53 Token string `json:"token"`
49 Updates []policy.RefUpdate `json:"updates"` 54 Updates []policy.RefUpdate `json:"updates"`
50} 55}
51 56
@@ -94,6 +99,12 @@ func Serve(cfg config.Config, st *store.Store) (func() error, error) {
94 if err != nil { 99 if err != nil {
95 return nil, err 100 return nil, err
96 } 101 }
102 // Listen creates the socket under the process umask. Hooks run as
103 // the daemon's own user; nobody else has a reason to connect.
104 if err := os.Chmod(path, 0o600); err != nil {
105 ln.Close()
106 return nil, err
107 }
97 s := &Server{cfg: cfg, st: st} 108 s := &Server{cfg: cfg, st: st}
98 go func() { 109 go func() {
99 for { 110 for {
@@ -111,11 +122,20 @@ func (s *Server) handle(conn net.Conn) {
111 defer conn.Close() 122 defer conn.Close()
112 dec := json.NewDecoder(conn) 123 dec := json.NewDecoder(conn)
113 enc := json.NewEncoder(conn) 124 enc := json.NewEncoder(conn)
125 if err := checkPeer(conn); err != nil {
126 slog.Warn("hook socket: refused connection", "err", err)
127 enc.Encode(Response{Allow: false, Message: "hook socket: " + err.Error()})
128 return
129 }
114 var req Request 130 var req Request
115 if err := dec.Decode(&req); err != nil { 131 if err := dec.Decode(&req); err != nil {
116 enc.Encode(Response{Allow: false, Message: "bad hook request"}) 132 enc.Encode(Response{Allow: false, Message: "bad hook request"})
117 return 133 return
118 } 134 }
135 if msg := s.authorize(req); msg != "" {
136 enc.Encode(Response{Allow: false, Message: msg})
137 return
138 }
119 switch req.Hook { 139 switch req.Hook {
120 case "pre-receive": 140 case "pre-receive":
121 s.preReceive(req, dec, enc) 141 s.preReceive(req, dec, enc)
@@ -127,6 +147,22 @@ func (s *Server) handle(conn net.Conn) {
127 } 147 }
128} 148}
129 149
150// authorize ties a request to a receive-pack sshd started: its token
151// must be live and name the same repository, account and key scope.
152func (s *Server) authorize(req Request) string {
153 if req.Token == "" {
154 return "push not started by this server"
155 }
156 tok, err := s.st.PushTokenByHash(store.HashToken(req.Token))
157 if err != nil {
158 return "push not started by this server"
159 }
160 if tok.RepoID != req.RepoID || tok.UserID != req.UserID || tok.Scope != req.Scope {
161 return "push token does not match this request"
162 }
163 return ""
164}
165
130func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) { 166func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
131 repo, err := s.st.RepoByID(req.RepoID) 167 repo, err := s.st.RepoByID(req.RepoID)
132 if err != nil { 168 if err != nil {
internal/hookd/peercred_linux.go added +38
@@ -0,0 +1,38 @@
1//go:build linux
2
3package hookd
4
5import (
6 "errors"
7 "fmt"
8 "net"
9 "os"
10 "syscall"
11)
12
13// checkPeer refuses a connection from any uid but the daemon's: git,
14// and so every hook, runs as the daemon's user.
15func checkPeer(conn net.Conn) error {
16 uc, ok := conn.(*net.UnixConn)
17 if !ok {
18 return fmt.Errorf("not a unix socket connection")
19 }
20 raw, err := uc.SyscallConn()
21 if err != nil {
22 return err
23 }
24 var cred *syscall.Ucred
25 var credErr error
26 if err := raw.Control(func(fd uintptr) {
27 cred, credErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
28 }); err != nil {
29 return err
30 }
31 if credErr != nil {
32 return credErr
33 }
34 if int(cred.Uid) != os.Getuid() {
35 return errors.New("peer uid not permitted")
36 }
37 return nil
38}
internal/hookd/peercred_other.go added +9
@@ -0,0 +1,9 @@
1//go:build !linux
2
3package hookd
4
5import "net"
6
7// checkPeer reads peer credentials on Linux only; elsewhere the
8// socket's 0600 mode is the boundary.
9func checkPeer(net.Conn) error { return nil }
internal/hookd/socket_test.go added +105
@@ -0,0 +1,105 @@
1package hookd
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func serveSocket(t *testing.T) (sock string, st *store.Store, repoID, uid int64) {
14 t.Helper()
15 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
16 if err != nil {
17 t.Fatal(err)
18 }
19 t.Cleanup(func() { st.Close() })
20 if err := st.MigrateUp(); err != nil {
21 t.Fatal(err)
22 }
23 if uid, err = st.CreateUser("alice", false); err != nil {
24 t.Fatal(err)
25 }
26 if repoID, err = st.CreateRepo("user", uid, "app", "public"); err != nil {
27 t.Fatal(err)
28 }
29 var cfg config.Config
30 cfg.Server.Root = t.TempDir()
31 stop, err := Serve(cfg, st)
32 if err != nil {
33 t.Fatal(err)
34 }
35 t.Cleanup(func() { stop() })
36 return SocketPath(cfg.Server.Root), st, repoID, uid
37}
38
39func TestSocketIsOwnerOnly(t *testing.T) {
40 sock, _, _, _ := serveSocket(t)
41 fi, err := os.Stat(sock)
42 if err != nil {
43 t.Fatal(err)
44 }
45 if fi.Mode().Perm() != 0o600 {
46 t.Fatalf("mode %v, want 0600", fi.Mode().Perm())
47 }
48}
49
50// A request speaks for a receive-pack sshd started, and only for the
51// repository, account and scope that push was started with (#282).
52func TestHookRequestNeedsItsPushToken(t *testing.T) {
53 sock, st, repoID, uid := serveSocket(t)
54 req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full"}
55
56 resp, err := Ask(sock, req, nil)
57 if err != nil {
58 t.Fatal(err)
59 }
60 if resp.Allow || !strings.Contains(resp.Message, "not started by this server") {
61 t.Fatalf("no token: %+v", resp)
62 }
63
64 token, err := st.CreatePushToken(repoID, uid, "full")
65 if err != nil {
66 t.Fatal(err)
67 }
68 req.Token = token
69 if resp, err = Ask(sock, req, nil); err != nil || !resp.Allow {
70 t.Fatalf("with token: %+v, %v", resp, err)
71 }
72
73 other, err := st.CreateUser("mallory", false)
74 if err != nil {
75 t.Fatal(err)
76 }
77 forged := req
78 forged.UserID = other
79 if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow {
80 t.Fatalf("token for another account: %+v, %v", resp, err)
81 }
82
83 otherRepo, err := st.CreateRepo("user", uid, "lib", "public")
84 if err != nil {
85 t.Fatal(err)
86 }
87 forged = req
88 forged.RepoID = otherRepo
89 if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow {
90 t.Fatalf("token for another repository: %+v, %v", resp, err)
91 }
92
93 forged = req
94 forged.Scope = "read"
95 if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow {
96 t.Fatalf("token for another scope: %+v, %v", resp, err)
97 }
98
99 if err := st.DeletePushToken(token); err != nil {
100 t.Fatal(err)
101 }
102 if resp, err = Ask(sock, req, nil); err != nil || resp.Allow {
103 t.Fatalf("finished push: %+v, %v", resp, err)
104 }
105}
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 }
internal/store/migrations/0063_push_tokens.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE push_tokens;
internal/store/migrations/0063_push_tokens.up.sql added +10
@@ -0,0 +1,10 @@
1-- One row per receive-pack in flight. The hook names its push by the
2-- token; hookd answers only a live one. Only the SHA-256 is stored.
3CREATE TABLE push_tokens (
4 token_hash TEXT PRIMARY KEY,
5 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
6 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
7 scope TEXT NOT NULL,
8 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
9 expires_at TEXT NOT NULL
10);
internal/store/pushtokens.go added +52
@@ -0,0 +1,52 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "time"
7)
8
9// PushToken is the receive-pack a hook request speaks for.
10type PushToken struct {
11 RepoID int64
12 UserID int64
13 Scope string
14}
15
16// pushTokenTTL bounds a row whose receive-pack died before deleting it.
17const pushTokenTTL = 24 * time.Hour
18
19// CreatePushToken records a token for one receive-pack and returns it.
20func (s *Store) CreatePushToken(repoID, userID int64, scope string) (string, error) {
21 token, hash, err := NewToken()
22 if err != nil {
23 return "", err
24 }
25 _, err = s.DB.Exec(
26 "INSERT INTO push_tokens (token_hash, repo_id, user_id, scope, expires_at) VALUES (?, ?, ?, ?, ?)",
27 hash, repoID, userID, scope, fmtTime(time.Now().Add(pushTokenTTL)))
28 if err != nil {
29 return "", err
30 }
31 return token, nil
32}
33
34// PushTokenByHash looks up a live token by its stored hash. ErrNotFound
35// covers both an absent row and one that has expired.
36func (s *Store) PushTokenByHash(hash string) (PushToken, error) {
37 var t PushToken
38 err := s.DB.QueryRow(
39 "SELECT repo_id, user_id, scope FROM push_tokens WHERE token_hash = ? AND expires_at > ?",
40 hash, fmtTime(time.Now())).Scan(&t.RepoID, &t.UserID, &t.Scope)
41 if errors.Is(err, sql.ErrNoRows) {
42 return PushToken{}, ErrNotFound
43 }
44 return t, err
45}
46
47// DeletePushToken removes a token by its raw value, once its receive-pack
48// is done with it.
49func (s *Store) DeletePushToken(token string) error {
50 _, err := s.DB.Exec("DELETE FROM push_tokens WHERE token_hash = ?", HashToken(token))
51 return err
52}
internal/store/pushtokens_test.go added +49
@@ -0,0 +1,49 @@
1package store
2
3import (
4 "errors"
5 "testing"
6 "time"
7)
8
9func TestPushTokens(t *testing.T) {
10 s := open(t)
11 if err := s.MigrateUp(); err != nil {
12 t.Fatal(err)
13 }
14 uid, err := s.CreateUser("alice", false)
15 if err != nil {
16 t.Fatal(err)
17 }
18 repoID, err := s.CreateRepo("user", uid, "app", "public")
19 if err != nil {
20 t.Fatal(err)
21 }
22 token, err := s.CreatePushToken(repoID, uid, "full")
23 if err != nil {
24 t.Fatal(err)
25 }
26 got, err := s.PushTokenByHash(HashToken(token))
27 if err != nil || got != (PushToken{RepoID: repoID, UserID: uid, Scope: "full"}) {
28 t.Fatalf("lookup = %+v, %v", got, err)
29 }
30 if err := s.DeletePushToken(token); err != nil {
31 t.Fatal(err)
32 }
33 if _, err := s.PushTokenByHash(HashToken(token)); !errors.Is(err, ErrNotFound) {
34 t.Fatalf("after delete: %v", err)
35 }
36
37 // A token whose receive-pack never cleaned up is swept after a day.
38 stale, err := s.CreatePushToken(repoID, uid, "full")
39 if err != nil {
40 t.Fatal(err)
41 }
42 swept, err := s.Sweep(Retention{}, time.Now().Add(25*time.Hour))
43 if err != nil || swept["push_tokens"] != 1 {
44 t.Fatalf("sweep = %v, %v", swept, err)
45 }
46 if _, err := s.PushTokenByHash(HashToken(stale)); !errors.Is(err, ErrNotFound) {
47 t.Fatalf("after sweep: %v", err)
48 }
49}
internal/store/retention.go +1
@@ -51,6 +51,7 @@ func (s *Store) Sweep(r Retention, now time.Time) (Swept, error) {
51 {"web_sessions", "expires_at <= ?"}, 51 {"web_sessions", "expires_at <= ?"},
52 {"login_tokens", "expires_at <= ?"}, 52 {"login_tokens", "expires_at <= ?"},
53 {"email_tokens", "expires_at <= ?"}, 53 {"email_tokens", "expires_at <= ?"},
54 {"push_tokens", "expires_at <= ?"},
54 } 55 }
55 for _, e := range expired { 56 for _, e := range expired {
56 n, err := s.deleteBy("DELETE FROM "+e.table+" WHERE "+e.where, fmtTime(now)) 57 n, err := s.deleteBy("DELETE FROM "+e.table+" WHERE "+e.where, fmtTime(now))