74e41543304d1e12fd54fc59e410bd428373dd34

Author
Kieran Klukas <kieran@dunkirk.sh>
Committer
Kieran Klukas <kieran@dunkirk.sh>
Date

Message

fix: stop go-get requests from revealing private repository names

A go-get request for a path inside a private repository, such as
/secret/pkg?go-get=1, answered with the private repository's name and
clone URL, even to anonymous callers. The go-get route now only
authenticates the caller, and the lookup only resolves repositories the
caller can read, so for go-get, unreadable and nonexistent repositories
look the same. Access checks now look up the repository by name instead
of trusting the one attached to the request, which during the lookup is
not the repository being checked.

The go-get exception in the HTTP access check also covered the LFS and
plain HTTP git routes, where it let requests for missing repositories
crash a handler. It is removed, so those routes always check access.
Bad credentials on a go-get request now get 403, as on git routes.
(GHSA-fgxm-5hvv-x4vh)

💘 Generated with Crush

Assisted-by: Crush:claude-opus-5-5

Diff

This diff is truncated to protect this page.

  1diff --git a/pkg/backend/user.go b/pkg/backend/user.go
  2index aa93aeb10177eb4500c6f8a9bbe3dd3e3ef7814a..11e24058eb61c2597189bd3757ec0d284ce03536 100644
  3--- a/pkg/backend/user.go
  4+++ b/pkg/backend/user.go
  5@@ -56,10 +56,7 @@ func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user prot
  6 	}
  7 
  8 	// If the repository exists, check if the user is a collaborator.
  9-	r := proto.RepositoryFromContext(ctx)
 10-	if r == nil {
 11-		r, _ = d.Repository(ctx, repo)
 12-	}
 13+	r, _ := d.Repository(ctx, repo)
 14 
 15 	if r != nil {
 16 		if user != nil {
 17@@ -70,9 +67,7 @@ func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user prot
 18 		}
 19 
 20 		// If the user is a collaborator, they have return their access level.
 21-		// On a private repository the collaborator's level is authoritative;
 22-		// the anonymous floor only applies to public repositories, otherwise
 23-		// a no-access collaborator would be granted read access.
 24+		// The anonymous floor only applies to public repositories.
 25 		collabAccess, isCollab, _ := d.IsCollaborator(ctx, repo, username)
 26 		if isCollab {
 27 			if !r.IsPrivate() && anon > collabAccess {
 28diff --git a/pkg/web/git.go b/pkg/web/git.go
 29index 16c967ffab9ff0fe83d93e1346f3c8abf8a11072..5807fefa993aac2f7edd6b1f83044dbec88f86cc 100644
 30--- a/pkg/web/git.go
 31+++ b/pkg/web/git.go
 32@@ -113,7 +113,7 @@ func GitController(_ context.Context, r *mux.Router) {
 33 	}
 34 
 35 	// Handle go-get
 36-	r.Handle(basePrefix, withParams(withAccess(http.HandlerFunc(GoGetHandler)))).Methods(http.MethodGet)
 37+	r.Handle(basePrefix, withParams(withCaller(http.HandlerFunc(GoGetHandler)))).Methods(http.MethodGet)
 38 }
 39 
 40 var gitRoutes = []GitRoute{
 41@@ -196,46 +196,85 @@ func askCredentials(w http.ResponseWriter, _ *http.Request) {
 42 	w.Header().Set("LFS-Authenticate", `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`)
 43 }
 44 
 45-// withAccess handles auth.
 46-func withAccess(next http.Handler) http.HandlerFunc {
 47-	return func(w http.ResponseWriter, r *http.Request) {
 48-		ctx := r.Context()
 49-		cfg := config.FromContext(ctx)
 50-		logger := log.FromContext(ctx)
 51-		be := backend.FromContext(ctx)
 52+// identify stores the requested repository and the authenticated caller in
 53+// the request context, and returns the authentication error, if any.
 54+func identify(r *http.Request) (*http.Request, proto.Repository, error) {
 55+	ctx := r.Context()
 56+	logger := log.FromContext(ctx)
 57+	be := backend.FromContext(ctx)
 58 
 59-		// Store repository in context
 60-		// We're not checking for errors here because we want to allow
 61-		// repo creation on the fly.
 62-		repoName := mux.Vars(r)["repo"]
 63-		repo, _ := be.Repository(ctx, repoName)
 64-		ctx = proto.WithRepositoryContext(ctx, repo)
 65-		r = r.WithContext(ctx)
 66+	// A missing repository is not an error here; pushes create it.
 67+	repo, _ := be.Repository(ctx, mux.Vars(r)["repo"])
 68+	ctx = proto.WithRepositoryContext(ctx, repo)
 69+	r = r.WithContext(ctx)
 70 
 71-		user, err := authenticate(r)
 72-		if err != nil {
 73-			switch {
 74-			case errors.Is(err, ErrInvalidToken):
 75-			case errors.Is(err, proto.ErrUserNotFound):
 76-			default:
 77-				logger.Error("failed to authenticate", "err", err)
 78-			}
 79+	user, err := authenticate(r)
 80+	if err != nil {
 81+		switch {
 82+		case errors.Is(err, ErrInvalidToken):
 83+		case errors.Is(err, proto.ErrUserNotFound):
 84+		default:
 85+			logger.Error("failed to authenticate", "err", err)
 86 		}
 87+	}
 88+
 89+	if user != nil {
 90+		logger.Debug("authenticated", "username", user.Username())
 91+	}
 92 
 93-		if user == nil && !be.AllowKeyless(ctx) {
 94-			askCredentials(w, r)
 95-			renderUnauthorized(w, r)
 96+	return r.WithContext(proto.WithUserContext(ctx, user)), repo, err
 97+}
 98+
 99+// badCredentials reports whether the caller sent credentials that failed.
100+func badCredentials(err error) bool {
101+	return errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword)
102+}
103+
104+// requireCaller rejects anonymous requests when keyless access is disabled.
105+func requireCaller(w http.ResponseWriter, r *http.Request) bool {
106+	ctx := r.Context()
107+	if proto.UserFromContext(ctx) == nil && !backend.FromContext(ctx).AllowKeyless(ctx) {
108+		askCredentials(w, r)
109+		renderUnauthorized(w, r)
110+		return false
111+	}
112+	return true
113+}
114+
115+// withCaller authenticates the caller without authorizing a repository.
116+// Handlers behind it must check access to any repository they reveal.
117+func withCaller(next http.Handler) http.HandlerFunc {
118+	return func(w http.ResponseWriter, r *http.Request) {
119+		r, _, err := identify(r)
120+		if !requireCaller(w, r) {
121 			return
122 		}
123 
124-		// Store user in context
125-		ctx = proto.WithUserContext(ctx, user)
126-		r = r.WithContext(ctx)
127+		if badCredentials(err) {
128+			renderForbidden(w, r)
129+			return
130+		}
131 
132diff --git a/pkg/web/git_lfs.go b/pkg/web/git_lfs.go
133index 6d7dfe863d255e001d533d188076de4799e0d131..c9b8ba1779f115e51070fe611c1eb5eaa81d8f0c 100644
134--- a/pkg/web/git_lfs.go
135+++ b/pkg/web/git_lfs.go
136@@ -259,6 +259,13 @@ func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
137 	ctx := r.Context()
138 	oid := mux.Vars(r)["oid"]
139 	repo := proto.RepositoryFromContext(ctx)
140+	if repo == nil {
141+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
142+			Message: "repository not found",
143+		})
144+		return
145+	}
146+
147 	cfg := config.FromContext(ctx)
148 	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
149 	datastore := store.FromContext(ctx)
150@@ -325,6 +332,13 @@ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
151 	datastore := store.FromContext(ctx)
152 	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
153 	repo := proto.RepositoryFromContext(ctx)
154+	if repo == nil {
155+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
156+			Message: "repository not found",
157+		})
158+		return
159+	}
160+
161 	repoID := strconv.FormatInt(repo.ID(), 10)
162 	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
163 	name := mux.Vars(r)["repo"]
164diff --git a/pkg/web/goget.go b/pkg/web/goget.go
165index fa486d5b3d8919fd934093e71f2c1a59ff555b3a..219c54f8cca664e70c3af79df46e4b698aa1bf15 100644
166--- a/pkg/web/goget.go
167+++ b/pkg/web/goget.go
168@@ -7,8 +7,10 @@ import (
169 	"text/template"
170 
171 	"charm.land/log/v2"
172+	"github.com/charmbracelet/soft-serve/pkg/access"
173 	"github.com/charmbracelet/soft-serve/pkg/backend"
174 	"github.com/charmbracelet/soft-serve/pkg/config"
175+	"github.com/charmbracelet/soft-serve/pkg/proto"
176 	"github.com/charmbracelet/soft-serve/pkg/utils"
177 	"github.com/gorilla/mux"
178 	"github.com/prometheus/client_golang/prometheus"
179@@ -45,9 +47,8 @@ func GoGetHandler(w http.ResponseWriter, r *http.Request) {
180 
181 	// Handle go get requests.
182 	//
183-	// Always return a 200 status code, even if the repo path doesn't exist.
184-	// It will try to find the repo by walking up the path until it finds one.
185-	// If it can't find one, it will return a 404.
186+	// Walk up the path to the closest repository the caller can read, or
187+	// return a 404 if there is none.
188 	//
189 	// https://golang.org/cmd/go/#hdr-Remote_import_paths
190 	// https://go.dev/ref/mod#vcs-branch
191@@ -59,9 +60,10 @@ func GoGetHandler(w http.ResponseWriter, r *http.Request) {
192 			return
193 		}
194 
195-		// find the repo
196+		// Find the closest repository the caller can read; skip the rest.
197+		user := proto.UserFromContext(ctx)
198 		for {
199-			if _, err := be.Repository(ctx, repo); err == nil {
200+			if _, err := be.Repository(ctx, repo); err == nil && be.AccessLevelForUser(ctx, repo, user) >= access.ReadOnlyAccess {
201 				break
202 			}
203 
204diff --git a/testscript/testdata/http.txtar b/testscript/testdata/http.txtar
205index 796c6421a7a73eb8e1c3c0a8e11eb93fa31a60e9..9dfe6a4d941192af201f2fbc494e4865f3deb81b 100644
206--- a/testscript/testdata/http.txtar
207+++ b/testscript/testdata/http.txtar
208@@ -137,6 +137,30 @@ stdout '404.*'
209 curl http://$TOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1
210 cmpenv stdout goget.txt
211 
212+# go-get subpackage of a private repo does not reveal it (GHSA-fgxm-5hvv-x4vh)
213+curl http://localhost:$HTTP_PORT/repo2/subpackage?go-get=1
214+stdout '404.*'
215+! stdout 'go-import'
216+curl http://$UTOKEN@localhost:$HTTP_PORT/repo2/sub/package?go-get=1
217+stdout '404.*'
218+! stdout 'go-import'
219+curl http://$TOKEN@localhost:$HTTP_PORT/repo2/subpackage?go-get=1
220+cmpenv stdout goget.txt
221+
222+# go-get resolves a readable repo nested under an unreadable one
223+soft repo create outer -p
224+soft repo create outer/inner
225+curl http://localhost:$HTTP_PORT/outer/inner/pkg?go-get=1
226+stdout 'go-import.*/outer/inner git'
227+curl http://localhost:$HTTP_PORT/outer/pkg?go-get=1
228+stdout '404.*'
229+
230+# go-get does not bypass repository checks on other routes
231+curl http://localhost:$HTTP_PORT/nope.git/info/lfs/objects/basic/0000000000000000000000000000000000000000000000000000000000000000?go-get=1
232+stdout '404.*'
233+curl http://localhost:$HTTP_PORT/nope.git/objects/info/packs?go-get=1
234+stdout '404.*'
235+
236 # stop the server
237 [windows] stopserver
238 [windows] ! stderr .