Skip to main content
VulnerabilityPublished

github.com/charmbracelet/soft-serve · Soft serve: charmbracelet/soft-serve HTTP responses disclose the existence and names of private repositories

Affects github.com/charmbracelet/soft-serveSource GCVE Database

0

CVSS 5.3 Medium : upgrade to 0.12.3 when convenient.

Data as of 30 Oct 2026, 15:33 UTC

CVSS 3.1 base score
5.3Medium
How severe the flaw is on the 0–10 CVSS scale.
Patch
Available – fixed in 0.12.3
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

What to do

Upgrade github.com/charmbracelet/soft-serve to 0.12.3.

Affected software

ProductAffectedFixed in
Gogithub.com/charmbracelet/soft-serveAffected
  • >=0.5.0 <0.12.3
Fixed in
  • 0.12.3

Check your own hosts for these packages with threat-finder (opens in a new tab), our free open-source scanner.

Description

## Summary Soft Serve answers Go's `?go-get=1` requests by walking up the requested path until it finds a repository, then rendering that repository's name and clone URL into a `go-import` meta tag. The walk checked that each candidate repository existed, but never that the caller could read it. A request for a path inside a private repository, such as `GET /secret/anything?go-get=1`, therefore returned `200` naming `secret` and its clone URL, even though `GET /secret?go-get=1` correctly returned `404`. The same `?go-get=1` exception in the HTTP access middleware also let requests reach the LFS and dumb-HTTP routes with the repository existence check skipped, where two LFS handlers dereferenced the missing repository and panicked. The same disclosure appears without `?go-get=1`. The Git and LFS routes answered a repository the caller could not read differently depending on whether it existed: an anonymous clone of a private repository got `401` while a missing one got `404`, and a signed-in user without access got the reverse pattern. Either way, one request confirms whether a guessed name is a private repository. This was found while fixing the go-get issue and is included here because it has the same impact and is fixed in the same release. ## Impact A remote client can learn, with one request per candidate name, which private repositories exist, their exact names, and their clone URLs. Under the shipped default of `anon-access: read-only` no account is needed, and an authenticated user without access to the repository gets the same answer. Project names in private deployments are often sensitive (internal codenames, unreleased products, customer names), and this turns them into an enumerable namespace. The Git and LFS routes give the same confirmation for any guessed name, to anonymous and authenticated callers alike, and an invalid `Authorization: Bearer` header does the same even where credentials are otherwise required. Repository contents, refs, commits, and collaborators are not exposed, and no write path is reachable. The LFS panic is caught by the recovery middleware and returns a `500`; the server stays up. Hidden repositories are not affected beyond what direct access already allows, since hiding a repository removes it from listings but does not restrict reading it by name. Reachable over the HTTP interface, which is enabled by default (AV:N), with one ordinary GET request per name (AC:L). No privileges are required under the default anonymous read access (PR:N), and no victim interaction is needed (UI:N). Only repository names and clone URLs are disclosed, which is a low confidentiality impact (C:L), with no integrity or availability impact (I:N, A:N), since the panic is recovered per request. ## Root Cause Two pieces of code each assumed the other had checked access. `withAccess` in `pkg/web/git.go` computed the access level for the requested path and then let go-get requests through ahead of the guard meant to hide repositories: ```go case r.URL.Query().Get("go-get") == "1" && accessLevel >= access.ReadOnlyAccess: // Allow go-get requests to passthrough. break case repo == nil, accessLevel < access.ReadOnlyAccess: // Don't hint that the repo exists if the user doesn't have access renderNotFound(w, r) return ``` For a path that is not itself a repository, `AccessLevelForUser` returns the anonymous access level to anonymous callers and read-write to any authenticated user, since that is what allows a push to create a new repository. The go-get case therefore always matched for a sub-path, and the "don't hint that the repo exists" guard below it never ran. `GoGetHandler` in `pkg/web/goget.go` then trimmed path segments with `path.Dir` until `be.Repository` succeeded, and rendered the first repository it found. It never asked whether the caller could read that repository. The access level the middleware had computed was for a different path. Because the exception lived in the middleware shared by every Git route rather than on the go-get route, it also applied to the dumb-HTTP and LFS routes. There it skipped the `repo == nil` guard, and `serviceLfsBasicDownload` and `serviceLfsBasicUpload` dereferenced a nil repository from the request context. The sibling LFS handlers already checked for nil, which is why only those two panicked. The Git and LFS routes behind `withAccess` had the opposite problem: each branch chose its own response for a caller who could not read the repository, and the choice depended on which condition failed first. On the smart HTTP routes a missing repository was checked before credentials, so it returned `404`, while a private one fell through to the credential prompt and returned `401`. On the LFS write routes the write-access check ran before the read check, so a signed-in user got `403` for a private repository and `404` for a missing one. And `parseJWT` in `pkg/web/auth.go` rejected a token for a missing repository with a "missing repository" erro

References

Community reviews

Loading reviews…