dc351aa0d824c8bef528c6924a7acdfb9f20c852

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

Message

fix: answer the same for private and missing repositories over HTTP

Git and LFS requests told a caller whether a repository they could not
read existed. An anonymous request to a private repository got 401
while a missing one got 404, and a signed-in user without access got
401 for a private repository but 404 for a missing one. Either way a
guessed name could be confirmed.

Now any repository the caller cannot read gets one answer: 401 asking
for credentials when anonymous, 404 when signed in, and 403 when the
credentials are wrong. This matches how GitHub responds. Anonymous
clones of a mistyped name now prompt for credentials instead of saying
the repository is missing. Pushes that create repositories still work
as before.

💘 Generated with Crush

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

Diff

This diff is truncated to protect this page.

  1diff --git a/pkg/web/auth.go b/pkg/web/auth.go
  2index 86e15f1ef1a818b442756f80d2e48f0984f18e54..48a8e4e1fa352160a840bc424f3bd6a40b32f999 100644
  3--- a/pkg/web/auth.go
  4+++ b/pkg/web/auth.go
  5@@ -141,9 +141,11 @@ func parseJWT(ctx context.Context, bearer string) (*jwt.RegisteredClaims, error)
  6 		return nil, err
  7 	}
  8 
  9+	// No token can be valid for a missing repository. Reject it like any
 10+	// other bad token so the answer does not reveal whether it exists.
 11 	repo := proto.RepositoryFromContext(ctx)
 12 	if repo == nil {
 13-		return nil, errors.New("missing repository")
 14+		return nil, ErrInvalidToken
 15 	}
 16 
 17 	token, err := jwt.ParseWithClaims(bearer, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) {
 18diff --git a/pkg/web/git.go b/pkg/web/git.go
 19index 5807fefa993aac2f7edd6b1f83044dbec88f86cc..5083d83c02d61abea32aeb2cdfc3d1e1460468c9 100644
 20--- a/pkg/web/git.go
 21+++ b/pkg/web/git.go
 22@@ -230,6 +230,41 @@ func badCredentials(err error) bool {
 23 	return errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword)
 24 }
 25 
 26+// denyRead answers a caller who may not read the repository. Missing and
 27+// unreadable repositories get the same answer so neither is revealed.
 28+func denyRead(w http.ResponseWriter, r *http.Request, err error, lfsJSON bool) {
 29+	switch {
 30+	case badCredentials(err) && lfsJSON:
 31+		renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{Message: "bad credentials"})
 32+	case badCredentials(err):
 33+		renderForbidden(w, r)
 34+	case proto.UserFromContext(r.Context()) == nil && lfsJSON:
 35+		askCredentials(w, r)
 36+		renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{Message: "credentials needed"})
 37+	case proto.UserFromContext(r.Context()) == nil:
 38+		askCredentials(w, r)
 39+		renderUnauthorized(w, r)
 40+	case lfsJSON:
 41+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{Message: "repository not found"})
 42+	default:
 43+		renderNotFound(w, r)
 44+	}
 45+}
 46+
 47+// lfsNeedsWrite reports whether an LFS request needs write access.
 48+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
 49+func lfsNeedsWrite(file, method string) bool {
 50+	switch {
 51+	case strings.HasPrefix(file, "info/lfs/locks"):
 52+		return strings.HasSuffix(file, "lfs/locks") ||
 53+			strings.HasSuffix(file, "lfs/locks/verify") ||
 54+			strings.HasSuffix(file, "/unlock") && method == http.MethodPost
 55+	case strings.HasPrefix(file, "info/lfs/objects/basic"):
 56+		return method == http.MethodPut
 57+	}
 58+	return false
 59+}
 60+
 61 // requireCaller rejects anonymous requests when keyless access is disabled.
 62 func requireCaller(w http.ResponseWriter, r *http.Request) bool {
 63 	ctx := r.Context()
 64@@ -287,18 +322,10 @@ func withAccess(next http.Handler) http.HandlerFunc {
 65 
 66 		file := mux.Vars(r)["file"]
 67 
 68-		// We only allow these services to proceed any other services should return 403
 69-		// - git-upload-pack
 70-		// - git-receive-pack
 71-		// - git-lfs
 72-		//
 73 		// The LFS case must stay ahead of the service cases. "file" is derived
 74-		// from the request path by withParams, but "service" can come from a
 75-		// query parameter the caller controls, and withParams only fills it in
 76-		// for paths ending in git-upload-pack or git-receive-pack, so it is
 77-		// always caller-supplied on an LFS route. Matching the path first means
 78-		// an LFS request is authorized as LFS no matter what service it claims
 79-		// to be.
 80+		// from the request path, but "service" can come from a query parameter
 81+		// the caller controls, so an LFS request is authorized as LFS no
 82+		// matter what service it claims to be.
 83 		switch {
 84 		case strings.HasPrefix(file, "info/lfs"):
 85 			if !cfg.LFS.Enabled {
 86@@ -307,54 +334,15 @@ func withAccess(next http.Handler) http.HandlerFunc {
 87 				return
 88 			}
 89 
 90-			switch {
 91-			case strings.HasPrefix(file, "info/lfs/locks"):
 92-				switch {
 93-				case strings.HasSuffix(file, "lfs/locks"), strings.HasSuffix(file, "/unlock") && r.Method == http.MethodPost:
 94-					// Create lock, list locks, and delete lock require write access
 95-					fallthrough
 96-				case strings.HasSuffix(file, "lfs/locks/verify"):
 97-					// Locks verify requires write access
 98-					// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md#unauthorized-response-2
 99-					if accessLevel < access.ReadWriteAccess {
100-						renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
101-							Message: "write access required",
102-						})
103-						return
104-					}
105-				}
106-			case strings.HasPrefix(file, "info/lfs/objects/basic"):
107-				switch r.Method {
108-				case http.MethodPut:
109-					// Basic upload
110-					if accessLevel < access.ReadWriteAccess {
111-						renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
112-							Message: "write access required",
113-						})
114-						return
115-					}
116-				case http.MethodGet:
117-					// Basic download
118-				case http.MethodPost:
119-					// Basic verify
120-				}
121+			if repo == nil || accessLevel < access.ReadOnlyAccess {
122diff --git a/testscript/testdata/http.txtar b/testscript/testdata/http.txtar
123index 9dfe6a4d941192af201f2fbc494e4865f3deb81b..41bb207f6561e80399f9260481aa5073a057a783 100644
124--- a/testscript/testdata/http.txtar
125+++ b/testscript/testdata/http.txtar
126@@ -105,13 +105,54 @@ cmp stdout http3.txt
127 curl http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
128 stdout '.*credentials needed.*'
129 curl http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
130-stdout '.*credentials needed.*'
131+stdout '.*repository not found.*'
132 curl http://0$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
133 cmp stdout http3.txt
134 
135 # deny dumb http git
136 curl -XGET http://localhost:$HTTP_PORT/repo2.git/info/refs
137+stdout '401.*'
138+curl -XGET http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/refs
139+stdout '404.*'
140+
141+# private and missing repos get the same answer
142+curl http://localhost:$HTTP_PORT/repo2.git/info/refs?service=git-upload-pack
143+stdout '401.*'
144+cp stdout private.txt
145+curl http://localhost:$HTTP_PORT/nope.git/info/refs?service=git-upload-pack
146+cmp stdout private.txt
147+curl http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/refs?service=git-upload-pack
148 stdout '404.*'
149+cp stdout private.txt
150+curl http://$UTOKEN@localhost:$HTTP_PORT/nope.git/info/refs?service=git-upload-pack
151+cmp stdout private.txt
152+curl http://someuser:somepassword@localhost:$HTTP_PORT/repo2.git/info/refs?service=git-upload-pack
153+stdout '403.*'
154+cp stdout private.txt
155+curl http://someuser:somepassword@localhost:$HTTP_PORT/nope.git/info/refs?service=git-upload-pack
156+cmp stdout private.txt
157+curl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
158+cp stdout private.txt
159+curl -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://localhost:$HTTP_PORT/nope.git/info/lfs/objects/batch
160+cmp stdout private.txt
161+curl -H 'Authorization: Bearer garbage' http://localhost:$HTTP_PORT/repo2.git/info/refs?service=git-upload-pack
162+stdout '403.*'
163+cp stdout private.txt
164+curl -H 'Authorization: Bearer garbage' http://localhost:$HTTP_PORT/nope.git/info/refs?service=git-upload-pack
165+cmp stdout private.txt
166+curl -H 'Authorization: Bearer garbage' http://localhost:$HTTP_PORT/repo2.git/HEAD
167+cp stdout private.txt
168+curl -H 'Authorization: Bearer garbage' http://localhost:$HTTP_PORT/nope.git/HEAD
169+cmp stdout private.txt
170+curl -XPOST -H 'Authorization: Bearer garbage' http://localhost:$HTTP_PORT/repo2.git/info/lfs/locks
171+cp stdout private.txt
172+curl -XPOST -H 'Authorization: Bearer garbage' http://localhost:$HTTP_PORT/nope.git/info/lfs/locks
173+cmp stdout private.txt
174+curl -XPOST http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/locks
175+stdout '.*repository not found.*'
176+cp stdout private.txt
177+curl -XPOST http://$UTOKEN@localhost:$HTTP_PORT/nope.git/info/lfs/locks
178+cmp stdout private.txt
179 
180 # deny access ask for credentials
181 # this means the server responded with a 401 and prompted for credentials
182@@ -157,9 +198,9 @@ stdout '404.*'
183 
184 # go-get does not bypass repository checks on other routes
185 curl http://localhost:$HTTP_PORT/nope.git/info/lfs/objects/basic/0000000000000000000000000000000000000000000000000000000000000000?go-get=1
186-stdout '404.*'
187+stdout '.*credentials needed.*'
188 curl http://localhost:$HTTP_PORT/nope.git/objects/info/packs?go-get=1
189-stdout '404.*'
190+stdout '401.*'
191 
192 # stop the server
193 [windows] stopserver