27f524593a281662874408dd31f270d73c72f48c
- Author
- Kieran Klukas <kieran@dunkirk.sh>
- Committer
- Kieran Klukas <kieran@dunkirk.sh>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/pkg/web/git.go b/pkg/web/git.go
2index 5a15ed2eda6e2f29ff156971ba0204fce3c504cb..16c967ffab9ff0fe83d93e1346f3c8abf8a11072 100644
3--- a/pkg/web/git.go
4+++ b/pkg/web/git.go
5@@ -252,43 +252,15 @@ func withAccess(next http.Handler) http.HandlerFunc {
6 // - git-upload-pack
7 // - git-receive-pack
8 // - git-lfs
9+ //
10+ // The LFS case must stay ahead of the service cases. "file" is derived
11+ // from the request path by withParams, but "service" can come from a
12+ // query parameter the caller controls, and withParams only fills it in
13+ // for paths ending in git-upload-pack or git-receive-pack, so it is
14+ // always caller-supplied on an LFS route. Matching the path first means
15+ // an LFS request is authorized as LFS no matter what service it claims
16+ // to be.
17 switch {
18- case service == git.ReceivePackService:
19- if accessLevel < access.ReadWriteAccess {
20- askCredentials(w, r)
21- renderUnauthorized(w, r)
22- return
23- }
24-
25- // Create the repo if it doesn't exist.
26- if repo == nil {
27- repo, err = be.CreateRepository(ctx, repoName, user, proto.RepositoryOptions{})
28- if err != nil {
29- logger.Error("failed to create repository", "repo", repoName, "err", err)
30- renderInternalServerError(w, r)
31- return
32- }
33-
34- ctx = proto.WithRepositoryContext(ctx, repo)
35- r = r.WithContext(ctx)
36- }
37-
38- fallthrough
39- case service == git.UploadPackService || service == git.UploadArchiveService:
40- if repo == nil {
41- // If the repo doesn't exist, return 404
42- renderNotFound(w, r)
43- return
44- } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
45- // return 403 when bad credentials are provided
46- renderForbidden(w, r)
47- return
48- } else if accessLevel < access.ReadOnlyAccess {
49- askCredentials(w, r)
50- renderUnauthorized(w, r)
51- return
52- }
53-
54 case strings.HasPrefix(file, "info/lfs"):
55 if !cfg.LFS.Enabled {
56 logger.Debug("LFS is not enabled, skipping")
57@@ -346,6 +318,42 @@ func withAccess(next http.Handler) http.HandlerFunc {
58 }
59 return
60 }
61+
62+ case service == git.ReceivePackService:
63+ if accessLevel < access.ReadWriteAccess {
64+ askCredentials(w, r)
65+ renderUnauthorized(w, r)
66+ return
67+ }
68+
69+ // Create the repo if it doesn't exist.
70+ if repo == nil {
71+ repo, err = be.CreateRepository(ctx, repoName, user, proto.RepositoryOptions{})
72+ if err != nil {
73+ logger.Error("failed to create repository", "repo", repoName, "err", err)
74+ renderInternalServerError(w, r)
75+ return
76+ }
77+
78+ ctx = proto.WithRepositoryContext(ctx, repo)
79+ r = r.WithContext(ctx)
80+ }
81+
82+ fallthrough
83+ case service == git.UploadPackService || service == git.UploadArchiveService:
84+ if repo == nil {
85+ // If the repo doesn't exist, return 404
86+ renderNotFound(w, r)
87+ return
88+ } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
89+ // return 403 when bad credentials are provided
90+ renderForbidden(w, r)
91+ return
92+ } else if accessLevel < access.ReadOnlyAccess {
93+ askCredentials(w, r)
94+ renderUnauthorized(w, r)
95+ return
96+ }
97 }
98
99 switch {
100diff --git a/pkg/web/git_lfs.go b/pkg/web/git_lfs.go
101index a7def2157ebc3068aca783a996bc31be8d6be15d..12d3cbd61307fdb9fddfd3650230626edb594e49 100644
102--- a/pkg/web/git_lfs.go
103+++ b/pkg/web/git_lfs.go
104@@ -302,6 +302,17 @@ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
105 }
106
107 ctx := r.Context()
108+
109+ // Checked in withAccess too. Repeated here so a routing or middleware
110+ // mistake cannot turn into an unauthenticated write.
111+ if access.FromContext(ctx) < access.ReadWriteAccess {
112+ askCredentials(w, r)
113+ renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
114+ Message: "write access required",
115+ })
116+ return
117+ }
118+
119 oid := mux.Vars(r)["oid"]
120 cfg := config.FromContext(ctx)
121 be := backend.FromContext(ctx)
122@@ -464,6 +475,16 @@ func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
123 ctx := r.Context()
124 logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
125
126+ // Checked in withAccess too. Repeated here so a routing or middleware
127+ // mistake cannot turn into an unauthorized lock.
128+ if access.FromContext(ctx) < access.ReadWriteAccess {
129+ askCredentials(w, r)
130+ renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
131+ Message: "write access required",
132+ })
133+ return
134+ }
135+
136 var req lfs.LockCreateRequest
137 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
138 logger.Error("error decoding json", "err", err)
139diff --git a/pkg/web/git_lfs_test.go b/pkg/web/git_lfs_test.go
140index 376c70ea1769f5ffecdc69c896323c07813f2e69..bb158935c62b0a62fc11c37abb7a1a237f54d53e 100644
141--- a/pkg/web/git_lfs_test.go
142+++ b/pkg/web/git_lfs_test.go
143@@ -5,9 +5,12 @@ import (
144 "encoding/json"
145 "net/http"
146 "net/http/httptest"
147+ "os"
148+ "path/filepath"
149 "strconv"
150 "strings"
151 "testing"
152+ "time"
153
154 "github.com/charmbracelet/soft-serve/pkg/backend"
155 "github.com/charmbracelet/soft-serve/pkg/config"
156@@ -201,3 +204,194 @@ func TestLFSLocksDeleteDoesNotLeakAcrossRepositories(t *testing.T) {
157 is.NoErr(err)
158 is.Equal(still.Path, "secret/plans.bin")
159 }
160+
161+// forgeableServices are the service names that select a non-LFS branch in
162+// withAccess. None of them may change how an LFS route is authorized.
163+var forgeableServices = []string{"git-upload-pack", "git-receive-pack", "git-upload-archive"}
164+
165+// lfsTestRouter builds the real git router so requests pass through withParams
166+// and withAccess rather than reaching a handler directly. The middleware is
167+// where LFS authorization lives, so a test that calls the handler itself cannot
168+// see a bypass.
169+func lfsTestRouter(ctx context.Context) *mux.Router {
170+ router := mux.NewRouter()
171+ GitController(ctx, router)
172+ return router
173+}
174+
175+// TestLFSUploadRejectsForgedServiceParam covers an authorization bypass in
176+// withAccess: the LFS branch was selected by a `service` value that, on an LFS
177+// route, always came from a caller-supplied query parameter. Because the
178+// git-upload-pack branch sat earlier in the same switch, appending
179+// `?service=git-upload-pack` matched that branch instead and skipped every LFS
180+// write check. Under the shipped defaults (anon-access read-only, LFS enabled)
181+// an unauthenticated caller could write objects into any public repository.
182+func TestLFSUploadRejectsForgedServiceParam(t *testing.T) {
183+ is := is.New(t)
184+ ctx, be, datastore := newLFSTestContext(t)
185+ cfg := config.FromContext(ctx)
186+ dbx := db.FromContext(ctx)
187+
188+ owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
189+ is.NoErr(err)
190+ repo, err := be.CreateRepository(ctx, "victim-repo", owner, proto.RepositoryOptions{})
191+ is.NoErr(err)
192+
193+ router := lfsTestRouter(ctx)
194+ oid := strings.Repeat("a", 64)
195+ path := "/victim-repo.git/info/lfs/objects/basic/" + oid
196+
197+ upload := func(url string) *httptest.ResponseRecorder {
198+ req := httptest.NewRequestWithContext(ctx, http.MethodPut, url, strings.NewReader("payload"))
199+ req.Header.Set("Content-Type", "application/octet-stream")
200+ w := httptest.NewRecorder()
201+ router.ServeHTTP(w, req)
202+ return w
203+ }
204+
205+ // Baseline: anonymous read-only access cannot upload.
206+ is.Equal(upload(path).Code, http.StatusForbidden)
207+
208+ for _, service := range forgeableServices {
209+ w := upload(path + "?service=" + service)
210+ if w.Code != http.StatusForbidden {
211+ t.Errorf("service=%s: got status %d, want 403: %s", service, w.Code, w.Body.String())
212+ }
213+ }
214+
215+ // The object must not exist on disk or in the database. The handler writes
216+ // the body before it parses Content-Length, so an error status alone does
217+ // not prove nothing was stored.
218+ objPath := filepath.Join(cfg.DataPath, "lfs", strconv.FormatInt(repo.ID(), 10),
219+ "objects", oid[0:2], oid[2:4], oid)
220+ if _, err := os.Stat(objPath); !os.IsNotExist(err) {
221+ t.Errorf("object written to disk at %s despite rejection", objPath)
222+ }
223+ if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid); err == nil {
224+ t.Error("object registered in database despite rejection")
225+ }
226+}
227+
228+// TestLFSLockCreateRejectsForgedServiceParam is the lock-create half of the
229+// same bypass: a read-only collaborator could take locks on arbitrary paths.
230+func TestLFSLockCreateRejectsForgedServiceParam(t *testing.T) {
231+ is := is.New(t)
232+ ctx, be, datastore := newLFSTestContext(t)
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+ // An authenticated user with no more than read access to the repository.
241+ attacker, err := be.CreateUser(ctx, "attacker", proto.UserOptions{})
242+ is.NoErr(err)