e34914699128c937afad167d43ccb5310e48b319
- Author
- Kieran Klukas <kieran@dunkirk.sh>
- Committer
- GitHub <noreply@github.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/pkg/store/database/lfs.go b/pkg/store/database/lfs.go
2index 8179d18031ae352fa1400d4cdc6d592c9a7c7c7a..15031016b41d1e0b7e28754aec321539c832808a 100644
3--- a/pkg/store/database/lfs.go
4+++ b/pkg/store/database/lfs.go
5@@ -112,14 +112,14 @@ func (*lfsStore) GetLFSLockForUserPath(ctx context.Context, tx db.Handler, repoI
6 }
7
8 // GetLFSLockByID implements store.LFSStore.
9-func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id int64) (models.LFSLock, error) {
10+func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, repoID int64, id int64) (models.LFSLock, error) {
11 var lock models.LFSLock
12 query := tx.Rebind(`
13 SELECT *
14 FROM lfs_locks
15- WHERE lfs_locks.id = ?;
16+ WHERE id = ? AND repo_id = ?;
17 `)
18- err := tx.GetContext(ctx, &lock, query, id)
19+ err := tx.GetContext(ctx, &lock, query, id, repoID)
20 return lock, db.WrapError(err)
21 }
22
23diff --git a/pkg/store/lfs.go b/pkg/store/lfs.go
24index 31611e9b5a607d376b5f2dadf7c0d3476ce6e035..f96f82c9a0aeae4965514c37ad0c911ec884f0d4 100644
25--- a/pkg/store/lfs.go
26+++ b/pkg/store/lfs.go
27@@ -21,7 +21,7 @@ type LFSStore interface {
28 GetLFSLocksForUser(ctx context.Context, h db.Handler, repoID int64, userID int64) ([]models.LFSLock, error)
29 GetLFSLockForPath(ctx context.Context, h db.Handler, repoID int64, path string) (models.LFSLock, error)
30 GetLFSLockForUserPath(ctx context.Context, h db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error)
31- GetLFSLockByID(ctx context.Context, h db.Handler, id int64) (models.LFSLock, error)
32+ GetLFSLockByID(ctx context.Context, h db.Handler, repoID int64, id int64) (models.LFSLock, error)
33 GetLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error)
34 DeleteLFSLock(ctx context.Context, h db.Handler, repoID int64, id int64) error
35 DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) error
36diff --git a/pkg/web/git_lfs.go b/pkg/web/git_lfs.go
37index 647e932ea392108c2b8090e16bbba5f7d42266ec..a7def2157ebc3068aca783a996bc31be8d6be15d 100644
38--- a/pkg/web/git_lfs.go
39+++ b/pkg/web/git_lfs.go
40@@ -608,7 +608,7 @@ func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
41 }
42
43 if id > 0 {
44- lock, err := datastore.GetLFSLockByID(ctx, dbx, id)
45+ lock, err := datastore.GetLFSLockByID(ctx, dbx, repo.ID(), id)
46 if err != nil {
47 if errors.Is(err, db.ErrRecordNotFound) {
48 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
49@@ -874,8 +874,9 @@ func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
50 return
51 }
52
53- // The lock being deleted
54- lock, err := datastore.GetLFSLockByID(ctx, dbx, lockID)
55+ // The lock being deleted. Looked up scoped to this repository so that a
56+ // lock ID belonging to another repository cannot be read through it.
57+ lock, err := datastore.GetLFSLockByID(ctx, dbx, repo.ID(), lockID)
58 if err != nil {
59 logger.Error("error getting lock", "err", err)
60 renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
61diff --git a/pkg/web/git_lfs_test.go b/pkg/web/git_lfs_test.go
62new file mode 100644
63index 0000000000000000000000000000000000000000..376c70ea1769f5ffecdc69c896323c07813f2e69
64--- /dev/null
65+++ b/pkg/web/git_lfs_test.go
66@@ -0,0 +1,203 @@
67+package web
68+
69+import (
70+ "context"
71+ "encoding/json"
72+ "net/http"
73+ "net/http/httptest"
74+ "strconv"
75+ "strings"
76+ "testing"
77+
78+ "github.com/charmbracelet/soft-serve/pkg/backend"
79+ "github.com/charmbracelet/soft-serve/pkg/config"
80+ "github.com/charmbracelet/soft-serve/pkg/db"
81+ "github.com/charmbracelet/soft-serve/pkg/db/migrate"
82+ "github.com/charmbracelet/soft-serve/pkg/lfs"
83+ "github.com/charmbracelet/soft-serve/pkg/proto"
84+ "github.com/charmbracelet/soft-serve/pkg/store"
85+ "github.com/charmbracelet/soft-serve/pkg/store/database"
86+ "github.com/gorilla/mux"
87+ "github.com/matryer/is"
88+ _ "modernc.org/sqlite"
89+)
90+
91+// newLFSTestContext returns a context wired with a config and a real migrated
92+// SQLite-backed backend, plus the backend and datastore so tests can create
93+// users, repositories, and locks.
94+func newLFSTestContext(t *testing.T) (context.Context, *backend.Backend, store.Store) {
95+ t.Helper()
96+ is := is.New(t)
97+ ctx := context.Background()
98+
99+ dp := t.TempDir()
100+ cfg := config.DefaultConfig()
101+ cfg.DataPath = dp
102+ cfg.DB.Driver = "sqlite"
103+ cfg.DB.DataSource = dp + "/test.db"
104+
105+ ctx = config.WithContext(ctx, cfg)
106+ dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
107+ is.NoErr(err)
108+ t.Cleanup(func() { dbx.Close() }) //nolint:errcheck
109+
110+ is.NoErr(migrate.Migrate(ctx, dbx))
111+ ctx = db.WithContext(ctx, dbx)
112+ datastore := database.New(ctx, dbx)
113+ ctx = store.WithContext(ctx, datastore)
114+ be := backend.New(ctx, cfg, dbx, datastore)
115+ ctx = backend.WithContext(ctx, be)
116+
117+ return ctx, be, datastore
118+}
119+
120+// TestLFSLockLookupIsScopedToRepository verifies that a lock cannot be read by
121+// global lock ID from a repository it does not belong to.
122+//
123+// The LFS lock routes authorize the caller against the repository in the URL,
124+// but the lock list handler accepts an `id` query parameter and used to fetch
125+// the lock by global ID alone. A user with write access to any repository could
126+// walk lock IDs and read file paths, owner usernames, and timestamps out of
127+// private repositories they have no access to.
128+func TestLFSLockLookupIsScopedToRepository(t *testing.T) {
129+ is := is.New(t)
130+ ctx, be, datastore := newLFSTestContext(t)
131+ dbx := db.FromContext(ctx)
132+
133+ // The victim owns a private repository with a lock on a sensitive path.
134+ victim, err := be.CreateUser(ctx, "victim", proto.UserOptions{})
135+ is.NoErr(err)
136+ victimRepo, err := be.CreateRepository(ctx, "victim-repo", victim, proto.RepositoryOptions{Private: true})
137+ is.NoErr(err)
138+ is.NoErr(datastore.CreateLFSLockForUser(ctx, dbx, victimRepo.ID(), victim.ID(), "secret/plans.bin", "refs/heads/main"))
139+
140+ victimLock, err := datastore.GetLFSLockForPath(ctx, dbx, victimRepo.ID(), "secret/plans.bin")
141+ is.NoErr(err)
142+
143+ // The attacker owns their own repository, so they have write access to
144+ // it, but no access at all to the victim's repository.
145+ attacker, err := be.CreateUser(ctx, "attacker", proto.UserOptions{})
146+ is.NoErr(err)
147+ attackerRepo, err := be.CreateRepository(ctx, "attacker-repo", attacker, proto.RepositoryOptions{})
148+ is.NoErr(err)
149+
150+ // The store must not return a lock that belongs to another repository.
151+ _, err = datastore.GetLFSLockByID(ctx, dbx, attackerRepo.ID(), victimLock.ID)
152+ if err == nil {
153+ t.Fatal("expected error reading another repository's lock by ID")
154+ }
155+
156+ // The owner can still read their own lock by ID.
157+ got, err := datastore.GetLFSLockByID(ctx, dbx, victimRepo.ID(), victimLock.ID)
158+ is.NoErr(err)
159+ is.Equal(got.Path, "secret/plans.bin")
160+}
161+
162+// TestLFSLocksGetDoesNotLeakAcrossRepositories drives the HTTP lock list
163+// handler the way an attacker would: authorized for their own repository,
164+// asking for a lock ID that belongs to somebody else's private repository.
165+func TestLFSLocksGetDoesNotLeakAcrossRepositories(t *testing.T) {