Commit 314c9ca55c

314c9ca55c3b7866b5bac4eb806a7e200e42edd4

parent: ba9cd01976

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-11 16:16 UTC

control, sshd, hookd: a cross-repo close is checked against the pushing key

The hook told the daemon which account pushed, not which key. A deploy
key's scope grants nothing outside its binding, so a close naming another
repository has to see the scope as well as the account's access: the
session's scope now travels with the push through GITBAY_KEY_SCOPE and
hookd.Request, and closeTarget requires ScopeAllowsGit on the target
beside CanWrite. An archived target is refused, and targets resolve once
per push rather than once per commit.

Ref #203
cmd/gitbayd/hook.go +1
@@ -171,6 +171,7 @@ func hookCmd() *cobra.Command {
171171 Hook: args[0],
172172 RepoID: repoID,
173173 UserID: userID,
174 Scope: os.Getenv(hookd.EnvScope),
174175 Updates: updates,
175176 }, func(emit func(hookd.RawCommit) error) error {
176177 return streamIncomingCommits(updates, emit)
internal/control/commitrefs.go +36 −11
@@ -34,14 +34,32 @@ const maxMessageCommits = 100
3434// landed on the default branch (old..new): closing keywords close the
3535// issue, bare #N leaves a reference comment. Each (issue, sha) pair acts
3636// at most once, ever. actorID — the pusher or merger — authorizes and
37// signs the resulting comments; failures are logged, never fatal, because
38// this runs after the push or merge already succeeded.
39func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID int64, old, new string) {
37// signs the resulting comments, and scope is the key they used, which a
38// cross-repo close is checked against too; failures are logged, never
39// fatal, because this runs after the push or merge already succeeded.
40func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID int64, scope, old, new string) {
4041 msgs, err := gitutil.RevListMessages(dir, old, new, maxMessageCommits)
4142 if err != nil {
4243 slog.Error("commit refs: listing messages", "repo", repo.Path(), "err", err)
4344 return
4445 }
46 // Commits in one push name the same repositories over and over, and
47 // each resolution is three queries; keep the answers, refusals too.
48 resolved := map[string]struct {
49 repo store.Repo
50 ok bool
51 }{}
52 target := func(path string) (store.Repo, bool) {
53 if r, seen := resolved[path]; seen {
54 return r.repo, r.ok
55 }
56 t, ok := closeTarget(st, repo, actorID, scope, path)
57 resolved[path] = struct {
58 repo store.Repo
59 ok bool
60 }{t, ok}
61 return t, ok
62 }
4563 for _, m := range msgs {
4664 closes := closingRefs(m.Message)
4765 local := map[int64]bool{}
@@ -59,11 +77,11 @@ func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID
5977 subject, _, _ := strings.Cut(m.Message, "\n")
6078 author := authorLink(st, m.AuthorName, m.AuthorEmail)
6179 for _, ref := range closes {
62 target, ok := closeTarget(st, repo, actorID, ref.Path)
80 t, ok := target(ref.Path)
6381 if !ok {
6482 continue
6583 }
66 actOnIssue(st, repo, target, actorID, m.SHA, ref.N, true, subject, author)
84 actOnIssue(st, repo, t, actorID, m.SHA, ref.N, true, subject, author)
6785 }
6886 for n := range refs {
6987 actOnIssue(st, repo, repo, actorID, m.SHA, n, false, subject, author)
@@ -81,9 +99,9 @@ func ProcessCommitMessages(st *store.Store, dir string, repo store.Repo, actorID
8199// key is per merge request rather than the merged sha, because sharing
82100// the sha let a bare "#N" in a commit message claim it first and silently
83101// suppress the close.
84func ProcessMRDescription(st *store.Store, repo store.Repo, mr store.MR, actorID int64) {
102func ProcessMRDescription(st *store.Store, repo store.Repo, mr store.MR, actorID int64, scope string) {
85103 for _, ref := range closingRefs(mr.Title + "\n" + mr.Body) {
86 target, ok := closeTarget(st, repo, actorID, ref.Path)
104 target, ok := closeTarget(st, repo, actorID, scope, ref.Path)
87105 if !ok {
88106 continue
89107 }
@@ -134,9 +152,10 @@ func closingRefs(text string) []closeRef {
134152
135153// closeTarget resolves where a closing reference acts: the source
136154// repository for a bare #N, or the named repository when the actor holds
137// write there. false means the reference stays text; nothing is logged
138// above debug, since a refusal must not confirm the target exists.
139func closeTarget(st *store.Store, source store.Repo, actorID int64, path string) (store.Repo, bool) {
155// write there with a key whose scope reaches it. false means the
156// reference stays text; nothing is logged above debug, since a refusal
157// must not confirm the target exists.
158func closeTarget(st *store.Store, source store.Repo, actorID int64, scope, path string) (store.Repo, bool) {
140159 if path == "" {
141160 return source, true
142161 }
@@ -152,10 +171,16 @@ func closeTarget(st *store.Store, source store.Repo, actorID int64, path string)
152171 if err != nil {
153172 return store.Repo{}, false
154173 }
155 if !policy.CanWrite(actor, target, grant) {
174 // The account's access and the key's reach both have to hold: a deploy
175 // key is bound to one repository and inherits nothing from whoever
176 // registered it, so its scope allows no write anywhere else.
177 if !policy.CanWrite(actor, target, grant) || !policy.ScopeAllowsGit(scope, target.Path(), true) {
156178 slog.Debug("commit refs: cross-repo close refused", "source", source.Path(), "target", path)
157179 return store.Repo{}, false
158180 }
181 if target.Settings.Archived {
182 return store.Repo{}, false
183 }
159184 return target, true
160185}
161186
internal/control/commitrefs_test.go +23 −4
@@ -1,6 +1,7 @@
11package control
22
33import (
4 "fmt"
45 "slices"
56 "strings"
67 "testing"
@@ -55,22 +56,40 @@ func TestMRDescriptionClosesAcrossRepos(t *testing.T) {
5556 }
5657 // carol cannot write acme/priv: the issue stays open and no comment
5758 // lands.
58 ProcessMRDescription(f.st, f.app, mr(1, "Closes acme/priv#1"), f.carol)
59 ProcessMRDescription(f.st, f.app, mr(1, "Closes acme/priv#1"), f.carol, "full")
5960 if iss, _ := f.st.IssueByNumber(f.priv.ID, 1); iss.State != "open" {
6061 t.Fatal("outsider closed a private repo's issue")
6162 }
63 // A deploy key on alice/app is bound to alice/app: alice's own access
64 // to acme/priv is not the key's to use.
65 deploy := fmt.Sprintf("deploy:%d:rw", f.app.ID)
66 ProcessMRDescription(f.st, f.app, mr(2, "Closes acme/priv#1"), f.alice, deploy)
67 if iss, _ := f.st.IssueByNumber(f.priv.ID, 1); iss.State != "open" {
68 t.Fatal("a deploy key closed an issue outside its binding")
69 }
70 // An archived target is read-only, cross-repo closes included.
71 if _, err := f.st.UpdateRepoSettings(f.priv.ID, func(rs *store.RepoSettings) { rs.Archived = true }); err != nil {
72 t.Fatal(err)
73 }
74 ProcessMRDescription(f.st, f.app, mr(3, "Closes acme/priv#1"), f.alice, "full")
75 if iss, _ := f.st.IssueByNumber(f.priv.ID, 1); iss.State != "open" {
76 t.Fatal("an archived repository's issue was closed")
77 }
78 if _, err := f.st.UpdateRepoSettings(f.priv.ID, func(rs *store.RepoSettings) { rs.Archived = false }); err != nil {
79 t.Fatal(err)
80 }
6281 // alice can: it closes with a comment naming the source repository.
63 ProcessMRDescription(f.st, f.app, mr(2, "Closes acme/priv#1"), f.alice)
82 ProcessMRDescription(f.st, f.app, mr(4, "Closes acme/priv#1"), f.alice, "full")
6483 iss, _ := f.st.IssueByNumber(f.priv.ID, 1)
6584 if iss.State != "closed" {
6685 t.Fatal("writer did not close across repos")
6786 }
6887 comments, _ := f.st.ListIssueComments(iss.ID)
69 if len(comments) != 1 || !strings.Contains(comments[0].Body, "(/alice/app/mrs/2)") {
88 if len(comments) != 1 || !strings.Contains(comments[0].Body, "(/alice/app/mrs/4)") {
7089 t.Fatalf("close comment = %+v", comments)
7190 }
7291 // An unknown path is text; a bare #N still acts in the source repo.
73 ProcessMRDescription(f.st, f.app, mr(3, "Closes nobody/nothing#1 and closes #1"), f.alice)
92 ProcessMRDescription(f.st, f.app, mr(5, "Closes nobody/nothing#1 and closes #1"), f.alice, "full")
7493 if iss, _ := f.st.IssueByNumber(f.app.ID, 1); iss.State != "closed" {
7594 t.Fatal("bare #N stopped working")
7695 }
internal/control/mr.go +2 −2
@@ -1208,8 +1208,8 @@ func runMRMerge(c *Ctx, args []string) int {
12081208 // description is scanned after them, so a commit wins the attribution
12091209 // when both name the same issue.
12101210 if mr.TargetRef == repo.DefaultBranch {
1211 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA)
1212 ProcessMRDescription(c.Store, repo, mr, c.User.ID)
1211 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, c.Scope, targetSHA, newSHA)
1212 ProcessMRDescription(c.Store, repo, mr, c.User.ID, c.Scope)
12131213 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
12141214 }
12151215 // A merge moves the ref directly, so it never reaches post-receive and
internal/hookd/hookd.go +9 −4
@@ -35,12 +35,17 @@ const (
3535 EnvSocket = "GITBAY_HOOK_SOCKET"
3636 EnvRepoID = "GITBAY_REPO_ID"
3737 EnvUserID = "GITBAY_USER_ID"
38 EnvScope = "GITBAY_KEY_SCOPE"
3839)
3940
4041type Request struct {
41 Hook string `json:"hook"` // pre-receive | post-receive
42 RepoID int64 `json:"repo_id"`
43 UserID int64 `json:"user_id"`
42 Hook string `json:"hook"` // pre-receive | post-receive
43 RepoID int64 `json:"repo_id"`
44 UserID int64 `json:"user_id"`
45 // Scope is the pushing key's scope. The user id alone is the account
46 // the key belongs to, and a deploy key grants nothing outside its
47 // binding, so anything acting on another repository needs this too.
48 Scope string `json:"scope"`
4449 Updates []policy.RefUpdate `json:"updates"`
4550}
4651
@@ -237,7 +242,7 @@ func (s *Server) postReceive(req Request) {
237242 // in their messages (closes #N, plain #N).
238243 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
239244 dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
240 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New)
245 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, req.Scope, u.Old, u.New)
241246 control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
242247 }
243248 // A branch push with a .gitbay/ci.yml queues one build per job.
internal/sshd/sshd.go +1
@@ -404,6 +404,7 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
404404 hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root),
405405 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
406406 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
407 hookd.EnvScope + "=" + scope,
407408 }
408409 // A storage quota on the owner rides the same mechanism as the pack
409410 // cap: the pack may be no larger than what the owner has left.