Commit 911c19309b

911c19309b0a6862ad2f444fe77d42f8fae4ee45

parent: 70d7e17c28

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 00:22 UTC

lfs: a token needs its key's current access to the repository

Ref #285

Layout: unified · split

internal/httpd/lfs.go +30 −3
@@ -9,6 +9,7 @@ import (
9 "time" 9 "time"
10 10
11 "gitbay.org/gitbay/internal/lfs" 11 "gitbay.org/gitbay/internal/lfs"
12 "gitbay.org/gitbay/internal/policy"
12 "gitbay.org/gitbay/internal/store" 13 "gitbay.org/gitbay/internal/store"
13) 14)
14 15
@@ -39,8 +40,9 @@ func (s *Server) lfsSecret() ([]byte, error) {
39// "download", or "" for no access, and the key the grant rests on (0 40// "download", or "" for no access, and the key the grant rests on (0
40// for none). A token is bound to the SSH key that obtained it and 41// for none). A token is bound to the SSH key that obtained it and
41// works only while that key is registered, unexpired and on an enabled 42// works only while that key is registered, unexpired and on an enabled
42// account (#285). Without one, public repos allow anonymous download 43// account, and while the key still has the access its operation needs
43// only. 44// on the repo (#285). Without one, public repos allow anonymous
45// download only.
44func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) { 46func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
45 auth := r.Header.Get("Authorization") 47 auth := r.Header.Get("Authorization")
46 if tok, ok := strings.CutPrefix(auth, "Bearer "); ok { 48 if tok, ok := strings.CutPrefix(auth, "Bearer "); ok {
@@ -60,7 +62,7 @@ func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
60 return "", 0 62 return "", 0
61 } 63 }
62 live, err := s.st.LiveSSHKeys([]int64{g.KeyID}) 64 live, err := s.st.LiveSSHKeys([]int64{g.KeyID})
63 if err != nil || !live[g.KeyID] { 65 if err != nil || !live[g.KeyID] || !s.lfsKeyAllows(g.KeyID, repo, g.Op == "upload") {
64 return "", 0 66 return "", 0
65 } 67 }
66 return g.Op, g.KeyID 68 return g.Op, g.KeyID
@@ -71,6 +73,31 @@ func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
71 return "", 0 73 return "", 0
72} 74}
73 75
76// lfsKeyAllows repeats git-lfs-authenticate's access check for the key
77// now: a deploy key by its binding, any other key by its account's
78// access narrowed by the key's scope.
79func (s *Server) lfsKeyAllows(keyID int64, repo store.Repo, write bool) bool {
80 key, err := s.st.SSHKeyByID(keyID)
81 if err != nil {
82 return false
83 }
84 if policy.IsDeployScope(key.Scope) {
85 return policy.DeployScopeAllows(key.Scope, repo.ID, write)
86 }
87 user, err := s.st.UserByID(key.UserID)
88 if err != nil {
89 return false
90 }
91 grant, err := s.st.AccessRole(repo.ID, user.ID)
92 if err != nil {
93 return false
94 }
95 if !policy.CanRead(user, repo, grant) || !policy.ScopeAllowsGit(key.Scope, repo.Path(), write) {
96 return false
97 }
98 return !write || policy.CanWrite(user, repo, grant)
99}
100
74func lfsError(w http.ResponseWriter, code int, msg string) { 101func lfsError(w http.ResponseWriter, code int, msg string) {
75 w.Header().Set("Content-Type", lfsMediaType) 102 w.Header().Set("Content-Type", lfsMediaType)
76 w.WriteHeader(code) 103 w.WriteHeader(code)
internal/httpd/lfsauth_test.go +71
@@ -106,3 +106,74 @@ func TestLFSAnonymousTokenOnlyDownloadsPublic(t *testing.T) {
106 t.Errorf("private download: %q", op) 106 t.Errorf("private download: %q", op)
107 } 107 }
108} 108}
109
110func lfsTestKey(t *testing.T, st *store.Store, uid int64, fp, scope string) int64 {
111 t.Helper()
112 if err := st.AddSSHKey(uid, fp, "ssh-ed25519", []byte(fp), scope, ""); err != nil {
113 t.Fatal(err)
114 }
115 k, err := st.SSHKeyByFingerprint(fp)
116 if err != nil {
117 t.Fatal(err)
118 }
119 return k.ID
120}
121
122// A token carries only the access its key's account still has: a
123// collaborator removed from the repository, or a reader of a public
124// repository made private, loses the token with the access (#285).
125func TestLFSTokenNeedsCurrentAccess(t *testing.T) {
126 s, st, u := newTokenTestServer(t)
127 repo := lfsTestRepo(t, st, u.ID, "app", "private")
128 secret, err := s.lfsSecret()
129 if err != nil {
130 t.Fatal(err)
131 }
132 now := time.Now()
133
134 bob, err := st.CreateUser("bob", false)
135 if err != nil {
136 t.Fatal(err)
137 }
138 if err := st.GrantAccess(repo.ID, bob, "write"); err != nil {
139 t.Fatal(err)
140 }
141 bobKey := lfsTestKey(t, st, bob, "SHA256:bob", "full")
142 up := lfs.Sign(secret, repo.ID, bobKey, "upload", now)
143 if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "upload" {
144 t.Fatalf("collaborator upload: %q", op)
145 }
146 if err := st.GrantAccess(repo.ID, bob, "read"); err != nil {
147 t.Fatal(err)
148 }
149 if op, _ := s.lfsAuth(lfsRequest(up), repo); op != "" {
150 t.Errorf("upload after write was taken away: %q", op)
151 }
152 if err := st.RevokeAccess(repo.ID, bob); err != nil {
153 t.Fatal(err)
154 }
155 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, bobKey, "download", now)), repo); op != "" {
156 t.Errorf("download after access was revoked: %q", op)
157 }
158
159 pub := lfsTestRepo(t, st, u.ID, "big", "public")
160 carol, err := st.CreateUser("carol", false)
161 if err != nil {
162 t.Fatal(err)
163 }
164 carolKey := lfsTestKey(t, st, carol, "SHA256:carol", "full")
165 down := lfs.Sign(secret, pub.ID, carolKey, "download", now)
166 if op, _ := s.lfsAuth(lfsRequest(down), pub); op != "download" {
167 t.Fatalf("public download: %q", op)
168 }
169 if err := st.SetRepoVisibility(pub.ID, "private"); err != nil {
170 t.Fatal(err)
171 }
172 pub, err = st.RepoByID(pub.ID)
173 if err != nil {
174 t.Fatal(err)
175 }
176 if op, _ := s.lfsAuth(lfsRequest(down), pub); op != "" {
177 t.Errorf("download after the repository went private: %q", op)
178 }
179}