git_lfs_test.go
15651 bytes
1package web
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/charmbracelet/soft-serve/pkg/backend"
16 "github.com/charmbracelet/soft-serve/pkg/config"
17 "github.com/charmbracelet/soft-serve/pkg/db"
18 "github.com/charmbracelet/soft-serve/pkg/db/migrate"
19 "github.com/charmbracelet/soft-serve/pkg/lfs"
20 "github.com/charmbracelet/soft-serve/pkg/proto"
21 "github.com/charmbracelet/soft-serve/pkg/store"
22 "github.com/charmbracelet/soft-serve/pkg/store/database"
23 "github.com/gorilla/mux"
24 "github.com/matryer/is"
25 _ "modernc.org/sqlite"
26)
27
28// newLFSTestContext returns a context wired with a config and a real migrated
29// SQLite-backed backend, plus the backend and datastore so tests can create
30// users, repositories, and locks.
31func newLFSTestContext(t *testing.T) (context.Context, *backend.Backend, store.Store) {
32 t.Helper()
33 is := is.New(t)
34 ctx := context.Background()
35
36 dp := t.TempDir()
37 cfg := config.DefaultConfig()
38 cfg.DataPath = dp
39 cfg.DB.Driver = "sqlite"
40 cfg.DB.DataSource = dp + "/test.db"
41
42 ctx = config.WithContext(ctx, cfg)
43 dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
44 is.NoErr(err)
45 t.Cleanup(func() { dbx.Close() }) //nolint:errcheck
46
47 is.NoErr(migrate.Migrate(ctx, dbx))
48 ctx = db.WithContext(ctx, dbx)
49 datastore := database.New(ctx, dbx)
50 ctx = store.WithContext(ctx, datastore)
51 be := backend.New(ctx, cfg, dbx, datastore)
52 ctx = backend.WithContext(ctx, be)
53
54 return ctx, be, datastore
55}
56
57// TestLFSLockLookupIsScopedToRepository verifies that a lock cannot be read by
58// global lock ID from a repository it does not belong to.
59//
60// The LFS lock routes authorize the caller against the repository in the URL,
61// but the lock list handler accepts an `id` query parameter and used to fetch
62// the lock by global ID alone. A user with write access to any repository could
63// walk lock IDs and read file paths, owner usernames, and timestamps out of
64// private repositories they have no access to.
65func TestLFSLockLookupIsScopedToRepository(t *testing.T) {
66 is := is.New(t)
67 ctx, be, datastore := newLFSTestContext(t)
68 dbx := db.FromContext(ctx)
69
70 // The victim owns a private repository with a lock on a sensitive path.
71 victim, err := be.CreateUser(ctx, "victim", proto.UserOptions{})
72 is.NoErr(err)
73 victimRepo, err := be.CreateRepository(ctx, "victim-repo", victim, proto.RepositoryOptions{Private: true})
74 is.NoErr(err)
75 is.NoErr(datastore.CreateLFSLockForUser(ctx, dbx, victimRepo.ID(), victim.ID(), "secret/plans.bin", "refs/heads/main"))
76
77 victimLock, err := datastore.GetLFSLockForPath(ctx, dbx, victimRepo.ID(), "secret/plans.bin")
78 is.NoErr(err)
79
80 // The attacker owns their own repository, so they have write access to
81 // it, but no access at all to the victim's repository.
82 attacker, err := be.CreateUser(ctx, "attacker", proto.UserOptions{})
83 is.NoErr(err)
84 attackerRepo, err := be.CreateRepository(ctx, "attacker-repo", attacker, proto.RepositoryOptions{})
85 is.NoErr(err)
86
87 // The store must not return a lock that belongs to another repository.
88 _, err = datastore.GetLFSLockByID(ctx, dbx, attackerRepo.ID(), victimLock.ID)
89 if err == nil {
90 t.Fatal("expected error reading another repository's lock by ID")
91 }
92
93 // The owner can still read their own lock by ID.
94 got, err := datastore.GetLFSLockByID(ctx, dbx, victimRepo.ID(), victimLock.ID)
95 is.NoErr(err)
96 is.Equal(got.Path, "secret/plans.bin")
97}
98
99// TestLFSLocksGetDoesNotLeakAcrossRepositories drives the HTTP lock list
100// handler the way an attacker would: authorized for their own repository,
101// asking for a lock ID that belongs to somebody else's private repository.
102func TestLFSLocksGetDoesNotLeakAcrossRepositories(t *testing.T) {
103 is := is.New(t)
104 ctx, be, datastore := newLFSTestContext(t)
105 dbx := db.FromContext(ctx)
106
107 victim, err := be.CreateUser(ctx, "victim", proto.UserOptions{})
108 is.NoErr(err)
109 victimRepo, err := be.CreateRepository(ctx, "victim-repo", victim, proto.RepositoryOptions{Private: true})
110 is.NoErr(err)
111 is.NoErr(datastore.CreateLFSLockForUser(ctx, dbx, victimRepo.ID(), victim.ID(), "secret/plans.bin", "refs/heads/main"))
112 victimLock, err := datastore.GetLFSLockForPath(ctx, dbx, victimRepo.ID(), "secret/plans.bin")
113 is.NoErr(err)
114
115 attacker, err := be.CreateUser(ctx, "attacker", proto.UserOptions{})
116 is.NoErr(err)
117 attackerRepo, err := be.CreateRepository(ctx, "attacker-repo", attacker, proto.RepositoryOptions{})
118 is.NoErr(err)
119
120 // Request the victim's lock ID while scoped to the attacker's own
121 // repository, which is exactly what the route authorizes.
122 reqCtx := proto.WithUserContext(ctx, attacker)
123 reqCtx = proto.WithRepositoryContext(reqCtx, attackerRepo)
124 req := httptest.NewRequestWithContext(reqCtx, http.MethodGet, "/attacker-repo.git/info/lfs/locks?id="+strconv.FormatInt(victimLock.ID, 10), nil)
125 req.Header.Set("Accept", lfs.MediaType)
126
127 w := httptest.NewRecorder()
128 serviceLfsLocks(w, req)
129
130 body := w.Body.String()
131 if strings.Contains(body, "secret/plans.bin") {
132 t.Errorf("leaked victim lock path in response: %s", body)
133 }
134 if strings.Contains(body, "victim") {
135 t.Errorf("leaked victim username in response: %s", body)
136 }
137
138 // The owner asking for their own lock still gets it, so the fix did not
139 // simply break the endpoint.
140 ownerCtx := proto.WithUserContext(ctx, victim)
141 ownerCtx = proto.WithRepositoryContext(ownerCtx, victimRepo)
142 req = httptest.NewRequestWithContext(ownerCtx, http.MethodGet, "/victim-repo.git/info/lfs/locks?id="+strconv.FormatInt(victimLock.ID, 10), nil)
143 req.Header.Set("Accept", lfs.MediaType)
144
145 w = httptest.NewRecorder()
146 serviceLfsLocks(w, req)
147 is.Equal(w.Code, http.StatusOK)
148
149 var resp lfs.LockListResponse
150 is.NoErr(json.NewDecoder(w.Body).Decode(&resp))
151 is.Equal(len(resp.Locks), 1)
152 is.Equal(resp.Locks[0].Path, "secret/plans.bin")
153}
154
155// TestLFSLocksDeleteDoesNotLeakAcrossRepositories covers the unlock route,
156// which fetched the lock by global ID and echoed its path and owner back in
157// the "lock belongs to another user" rejection. Deletion itself was already
158// repository-scoped, so this path disclosed metadata rather than destroying
159// it, but it is the same unscoped lookup.
160func TestLFSLocksDeleteDoesNotLeakAcrossRepositories(t *testing.T) {
161 is := is.New(t)
162 ctx, be, datastore := newLFSTestContext(t)
163 dbx := db.FromContext(ctx)
164
165 victim, err := be.CreateUser(ctx, "victim", proto.UserOptions{})
166 is.NoErr(err)
167 victimRepo, err := be.CreateRepository(ctx, "victim-repo", victim, proto.RepositoryOptions{Private: true})
168 is.NoErr(err)
169 is.NoErr(datastore.CreateLFSLockForUser(ctx, dbx, victimRepo.ID(), victim.ID(), "secret/plans.bin", "refs/heads/main"))
170 victimLock, err := datastore.GetLFSLockForPath(ctx, dbx, victimRepo.ID(), "secret/plans.bin")
171 is.NoErr(err)
172
173 attacker, err := be.CreateUser(ctx, "attacker", proto.UserOptions{})
174 is.NoErr(err)
175 attackerRepo, err := be.CreateRepository(ctx, "attacker-repo", attacker, proto.RepositoryOptions{})
176 is.NoErr(err)
177
178 reqCtx := proto.WithUserContext(ctx, attacker)
179 reqCtx = proto.WithRepositoryContext(reqCtx, attackerRepo)
180 req := httptest.NewRequestWithContext(reqCtx, http.MethodPost,
181 "/attacker-repo.git/info/lfs/locks/"+strconv.FormatInt(victimLock.ID, 10)+"/unlock",
182 strings.NewReader(`{"force":false}`))
183 req.Header.Set("Accept", lfs.MediaType)
184 req.Header.Set("Content-Type", lfs.MediaType)
185 req = mux.SetURLVars(req, map[string]string{"lock_id": strconv.FormatInt(victimLock.ID, 10)})
186
187 w := httptest.NewRecorder()
188 serviceLfsLocksDelete(w, req)
189
190 // Guard against the request never reaching the lookup: a missing or
191 // unparseable lock_id would make this test vacuously pass.
192 is.Equal(w.Code, http.StatusNotFound)
193
194 body := w.Body.String()
195 if strings.Contains(body, "secret/plans.bin") {
196 t.Errorf("leaked victim lock path in unlock response: %s", body)
197 }
198 if strings.Contains(body, "victim") {
199 t.Errorf("leaked victim username in unlock response: %s", body)
200 }
201
202 // The victim's lock must still exist.
203 still, err := datastore.GetLFSLockByID(ctx, dbx, victimRepo.ID(), victimLock.ID)
204 is.NoErr(err)
205 is.Equal(still.Path, "secret/plans.bin")
206}
207
208// forgeableServices are the service names that select a non-LFS branch in
209// withAccess. None of them may change how an LFS route is authorized.
210var forgeableServices = []string{"git-upload-pack", "git-receive-pack", "git-upload-archive"}
211
212// lfsTestRouter builds the real git router so requests pass through withParams
213// and withAccess rather than reaching a handler directly. The middleware is
214// where LFS authorization lives, so a test that calls the handler itself cannot
215// see a bypass.
216func lfsTestRouter(ctx context.Context) *mux.Router {
217 router := mux.NewRouter()
218 GitController(ctx, router)
219 return router
220}
221
222// TestLFSUploadRejectsForgedServiceParam covers an authorization bypass in
223// withAccess: the LFS branch was selected by a `service` value that, on an LFS
224// route, always came from a caller-supplied query parameter. Because the
225// git-upload-pack branch sat earlier in the same switch, appending
226// `?service=git-upload-pack` matched that branch instead and skipped every LFS
227// write check. Under the shipped defaults (anon-access read-only, LFS enabled)
228// an unauthenticated caller could write objects into any public repository.
229func TestLFSUploadRejectsForgedServiceParam(t *testing.T) {
230 is := is.New(t)
231 ctx, be, datastore := newLFSTestContext(t)
232 cfg := config.FromContext(ctx)
233 dbx := db.FromContext(ctx)
234
235 owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
236 is.NoErr(err)
237 repo, err := be.CreateRepository(ctx, "victim-repo", owner, proto.RepositoryOptions{})
238 is.NoErr(err)
239
240 router := lfsTestRouter(ctx)
241 oid := strings.Repeat("a", 64)
242 path := "/victim-repo.git/info/lfs/objects/basic/" + oid
243
244 upload := func(url string) *httptest.ResponseRecorder {
245 req := httptest.NewRequestWithContext(ctx, http.MethodPut, url, strings.NewReader("payload"))
246 req.Header.Set("Content-Type", "application/octet-stream")
247 w := httptest.NewRecorder()
248 router.ServeHTTP(w, req)
249 return w
250 }
251
252 // Baseline: anonymous read-only access cannot upload.
253 is.Equal(upload(path).Code, http.StatusForbidden)
254
255 for _, service := range forgeableServices {
256 w := upload(path + "?service=" + service)
257 if w.Code != http.StatusForbidden {
258 t.Errorf("service=%s: got status %d, want 403: %s", service, w.Code, w.Body.String())
259 }
260 }
261
262 // The object must not exist on disk or in the database. The handler writes
263 // the body before it parses Content-Length, so an error status alone does
264 // not prove nothing was stored.
265 objPath := filepath.Join(cfg.DataPath, "lfs", strconv.FormatInt(repo.ID(), 10),
266 "objects", oid[0:2], oid[2:4], oid)
267 if _, err := os.Stat(objPath); !os.IsNotExist(err) {
268 t.Errorf("object written to disk at %s despite rejection", objPath)
269 }
270 if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid); err == nil {
271 t.Error("object registered in database despite rejection")
272 }
273}
274
275// TestLFSLockCreateRejectsForgedServiceParam is the lock-create half of the
276// same bypass: a read-only collaborator could take locks on arbitrary paths.
277func TestLFSLockCreateRejectsForgedServiceParam(t *testing.T) {
278 is := is.New(t)
279 ctx, be, datastore := newLFSTestContext(t)
280 dbx := db.FromContext(ctx)
281
282 owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
283 is.NoErr(err)
284 repo, err := be.CreateRepository(ctx, "victim-repo", owner, proto.RepositoryOptions{})
285 is.NoErr(err)
286
287 // An authenticated user with no more than read access to the repository.
288 attacker, err := be.CreateUser(ctx, "attacker", proto.UserOptions{})
289 is.NoErr(err)
290 token, err := be.CreateAccessToken(ctx, attacker, "test", time.Time{})
291 is.NoErr(err)
292
293 router := lfsTestRouter(ctx)
294
295 lock := func(url string) *httptest.ResponseRecorder {
296 req := httptest.NewRequestWithContext(ctx, http.MethodPost, url,
297 strings.NewReader(`{"path":"src/critical.bin","ref":{"name":"refs/heads/main"}}`))
298 req.Header.Set("Content-Type", lfs.MediaType)
299 req.Header.Set("Accept", lfs.MediaType)
300 req.Header.Set("Authorization", "token "+token)
301 w := httptest.NewRecorder()
302 router.ServeHTTP(w, req)
303 return w
304 }
305
306 is.Equal(lock("/victim-repo.git/info/lfs/locks").Code, http.StatusForbidden)
307
308 for _, service := range forgeableServices {
309 w := lock("/victim-repo.git/info/lfs/locks?service=" + service)
310 if w.Code != http.StatusForbidden {
311 t.Errorf("service=%s: got status %d, want 403: %s", service, w.Code, w.Body.String())
312 }
313 }
314
315 if _, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), "src/critical.bin"); err == nil {
316 t.Error("lock created despite rejection")
317 }
318}
319
320// TestLFSDisabledRejectsForgedServiceParam checks the administrative kill
321// switch. The `lfs.enabled = false` check lived inside the branch the bypass
322// skipped, so a forged service value re-enabled LFS on a server where the
323// administrator had turned it off.
324func TestLFSDisabledRejectsForgedServiceParam(t *testing.T) {
325 is := is.New(t)
326 ctx, be, _ := newLFSTestContext(t)
327
328 cfg := config.FromContext(ctx)
329 cfg.LFS.Enabled = false
330
331 owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
332 is.NoErr(err)
333 _, err = be.CreateRepository(ctx, "victim-repo", owner, proto.RepositoryOptions{})
334 is.NoErr(err)
335
336 router := lfsTestRouter(ctx)
337 url := "/victim-repo.git/info/lfs/objects/basic/" + strings.Repeat("a", 64)
338
339 for _, service := range forgeableServices {
340 req := httptest.NewRequestWithContext(ctx, http.MethodPut, url+"?service="+service,
341 strings.NewReader("payload"))
342 req.Header.Set("Content-Type", "application/octet-stream")
343 w := httptest.NewRecorder()
344 router.ServeHTTP(w, req)
345 if w.Code != http.StatusNotFound {
346 t.Errorf("service=%s: got status %d, want 404 with LFS disabled: %s", service, w.Code, w.Body.String())
347 }
348 }
349}
350
351// TestLFSWriteStillWorksForAuthorizedUser guards against the fix simply
352// breaking LFS: a user with write access must still be able to upload an
353// object and take a lock, with or without a service parameter present.
354func TestLFSWriteStillWorksForAuthorizedUser(t *testing.T) {
355 is := is.New(t)
356 ctx, be, datastore := newLFSTestContext(t)
357 dbx := db.FromContext(ctx)
358
359 owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
360 is.NoErr(err)
361 repo, err := be.CreateRepository(ctx, "owner-repo", owner, proto.RepositoryOptions{})
362 is.NoErr(err)
363 token, err := be.CreateAccessToken(ctx, owner, "test", time.Time{})
364 is.NoErr(err)
365
366 router := lfsTestRouter(ctx)
367 oid := strings.Repeat("b", 64)
368 body := "payload"
369
370 req := httptest.NewRequestWithContext(ctx, http.MethodPut,
371 "/owner-repo.git/info/lfs/objects/basic/"+oid, strings.NewReader(body))
372 req.Header.Set("Content-Type", "application/octet-stream")
373 req.Header.Set("Content-Length", strconv.Itoa(len(body)))
374 req.Header.Set("Authorization", "token "+token)
375 w := httptest.NewRecorder()
376 router.ServeHTTP(w, req)
377 is.Equal(w.Code, http.StatusOK)
378
379 obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid)
380 is.NoErr(err)
381 is.Equal(obj.Oid, oid)
382
383 // A service parameter on an LFS route is ignored, not rejected.
384 req = httptest.NewRequestWithContext(ctx, http.MethodPost,
385 "/owner-repo.git/info/lfs/locks?service=git-upload-pack",
386 strings.NewReader(`{"path":"src/file.bin","ref":{"name":"refs/heads/main"}}`))
387 req.Header.Set("Content-Type", lfs.MediaType)
388 req.Header.Set("Accept", lfs.MediaType)
389 req.Header.Set("Authorization", "token "+token)
390 w = httptest.NewRecorder()
391 router.ServeHTTP(w, req)
392 is.Equal(w.Code, http.StatusCreated)
393
394 got, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), "src/file.bin")
395 is.NoErr(err)
396 is.Equal(got.Path, "src/file.bin")
397}