Parent directory

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}