Commit 70d7e17c28

70d7e17c28ac9ca7ab984e1b2fcfcd91a7d98913

parent: 682aca3520

Verified · cmc

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

lfs: refuse a token whose key was removed, expired or disabled

Ref #285

Layout: unified · split

internal/httpd/lfs.go +15 −2
@@ -37,8 +37,10 @@ func (s *Server) lfsSecret() ([]byte, error) {
3737
3838// lfsAuth resolves what the request may do to the repo: "upload",
3939// "download", or "" for no access, and the key the grant rests on (0
40// for none). Tokens are repo-scoped; without one, public repos allow
41// anonymous download only.
40// 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// account (#285). Without one, public repos allow anonymous download
43// only.
4244func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
4345 auth := r.Header.Get("Authorization")
4446 if tok, ok := strings.CutPrefix(auth, "Bearer "); ok {
@@ -50,6 +52,17 @@ func (s *Server) lfsAuth(r *http.Request, repo store.Repo) (string, int64) {
5052 if !ok || g.RepoID != repo.ID {
5153 return "", 0
5254 }
55 if g.KeyID == 0 {
56 // Minted by an anonymous batch: worth what anonymous is.
57 if g.Op == "download" && repo.Visibility == "public" {
58 return "download", 0
59 }
60 return "", 0
61 }
62 live, err := s.st.LiveSSHKeys([]int64{g.KeyID})
63 if err != nil || !live[g.KeyID] {
64 return "", 0
65 }
5366 return g.Op, g.KeyID
5467 }
5568 if repo.Visibility == "public" {
internal/httpd/lfsauth_test.go added +108
@@ -0,0 +1,108 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "testing"
7 "time"
8
9 "gitbay.org/gitbay/internal/lfs"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func lfsRequest(tok string) *http.Request {
14 r := httptest.NewRequest("GET", "/alice/app.git/info/lfs/objects/x", nil)
15 if tok != "" {
16 r.Header.Set("Authorization", "Bearer "+tok)
17 }
18 return r
19}
20
21func lfsTestRepo(t *testing.T, st *store.Store, uid int64, name, visibility string) store.Repo {
22 t.Helper()
23 id, err := st.CreateRepo("user", uid, name, visibility)
24 if err != nil {
25 t.Fatal(err)
26 }
27 repo, err := st.RepoByID(id)
28 if err != nil {
29 t.Fatal(err)
30 }
31 return repo
32}
33
34// A token works only while its key does: removed, expired or on a
35// disabled account, the key takes its tokens with it (#285).
36func TestLFSTokenNeedsALiveKey(t *testing.T) {
37 s, st, u := newTokenTestServer(t)
38 repo := lfsTestRepo(t, st, u.ID, "app", "private")
39 secret, err := s.lfsSecret()
40 if err != nil {
41 t.Fatal(err)
42 }
43 addKey := func(fp string, exp *time.Time) int64 {
44 t.Helper()
45 if err := st.AddSSHKeyFrom(u.ID, fp, "ssh-ed25519", []byte(fp), "full", "", store.KeyOrigin{ExpiresAt: exp}); err != nil {
46 t.Fatal(err)
47 }
48 k, err := st.SSHKeyByFingerprint(fp)
49 if err != nil {
50 t.Fatal(err)
51 }
52 return k.ID
53 }
54
55 live := addKey("SHA256:live", nil)
56 tok := lfs.Sign(secret, repo.ID, live, "upload", time.Now())
57 if op, key := s.lfsAuth(lfsRequest(tok), repo); op != "upload" || key != live {
58 t.Fatalf("live key: %q, %d", op, key)
59 }
60
61 past := time.Now().Add(-time.Minute)
62 expired := addKey("SHA256:expired", &past)
63 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, expired, "upload", time.Now())), repo); op != "" {
64 t.Errorf("expired key: %q", op)
65 }
66
67 if err := st.RemoveSSHKey(u.ID, "SHA256:live"); err != nil {
68 t.Fatal(err)
69 }
70 if op, _ := s.lfsAuth(lfsRequest(tok), repo); op != "" {
71 t.Errorf("removed key: %q", op)
72 }
73
74 other := addKey("SHA256:other", nil)
75 otherTok := lfs.Sign(secret, repo.ID, other, "download", time.Now())
76 if op, _ := s.lfsAuth(lfsRequest(otherTok), repo); op != "download" {
77 t.Fatalf("second key before disable: %q", op)
78 }
79 if err := st.SetUserDisabled(u.ID, true); err != nil {
80 t.Fatal(err)
81 }
82 if op, _ := s.lfsAuth(lfsRequest(otherTok), repo); op != "" {
83 t.Errorf("disabled account: %q", op)
84 }
85}
86
87// A token with no key comes from an anonymous batch on a public
88// repository and is worth exactly what anonymous is: a download, while
89// the repository is public.
90func TestLFSAnonymousTokenOnlyDownloadsPublic(t *testing.T) {
91 s, st, u := newTokenTestServer(t)
92 pub := lfsTestRepo(t, st, u.ID, "big", "public")
93 priv := lfsTestRepo(t, st, u.ID, "vault", "private")
94 secret, err := s.lfsSecret()
95 if err != nil {
96 t.Fatal(err)
97 }
98 now := time.Now()
99 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "download", now)), pub); op != "download" {
100 t.Errorf("public download: %q", op)
101 }
102 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, pub.ID, 0, "upload", now)), pub); op != "" {
103 t.Errorf("anonymous upload: %q", op)
104 }
105 if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, priv.ID, 0, "download", now)), priv); op != "" {
106 t.Errorf("private download: %q", op)
107 }
108}