CVE-2023-2253
Description
A flaw was found in the /v2/_catalog endpoint in distribution/distribution, which accepts a parameter to control the maximum number of records returned (query string: n). This vulnerability allows a malicious user to submit an unreasonably large value for n, causing the allocation of a massive string array, possibly causing a denial of service through excessive use of memory.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A denial-of-service vulnerability in the /v2/_catalog endpoint of distribution/distribution allows memory exhaustion via an unreasonably large 'n' parameter.
Vulnerability
Details
The /v2/_catalog endpoint in the distribution/distribution project (the reference implementation of the OCI Distribution Specification) accepts a query parameter n to control the maximum number of catalog entries returned. Due to insufficient input validation, an attacker can supply an unreasonably large value for n, causing the server to allocate a massive string array. This memory allocation can exhaust available memory, leading to a denial of service (DoS) condition. [1][2]
Exploitation
The vulnerability is exploitable over the network without requiring authentication, as the /v2/_catalog endpoint is typically accessible to any client. An attacker sends a crafted HTTP GET request to /v2/_catalog?n=. The server attempts to allocate memory for that many entries, potentially consuming all available memory and crashing the registry process. [1][2]
Impact
Successful exploitation results in a denial of service, making the registry unavailable for legitimate container image operations. This can disrupt container image pulls and pushes, affecting dependent systems and workflows. The vulnerability has been assigned a CVSS score (currently pending from NVD). [2]
Mitigation
The issue has been fixed in a commit that introduces a Catalog.MaxEntries configuration option, with a default maximum of 1000 entries, preventing unbounded allocation. [3] Red Hat has released an advisory for Red Hat OpenShift Container Platform 4.13. [1] Users should update to the patched version or configure the maximum entries limit to mitigate the vulnerability. [3]
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/docker/distributionGo | < 2.8.2-beta.1 | 2.8.2-beta.1 |
Affected products
48- distribution/distributiondescription
- osv-coords47 versionspkg:apk/chainguard/aactlpkg:apk/chainguard/argocd-image-updaterpkg:apk/chainguard/argocd-image-updater-compatpkg:apk/chainguard/argocd-image-updater-fipspkg:apk/chainguard/bompkg:apk/chainguard/flux-0pkg:apk/chainguard/flux-compatpkg:apk/chainguard/flux-helm-controller-0.37pkg:apk/chainguard/flux-image-reflector-controller-0pkg:apk/chainguard/goreleaser-1.18pkg:apk/chainguard/kptpkg:apk/chainguard/kubeadm-fips-1.27pkg:apk/chainguard/kubeadm-fips-1.27-defaultpkg:apk/chainguard/kube-apiserver-fips-1.27pkg:apk/chainguard/kube-apiserver-fips-1.27-defaultpkg:apk/chainguard/kube-controller-manager-fips-1.27pkg:apk/chainguard/kube-controller-manager-fips-1.27-defaultpkg:apk/chainguard/kubectl-bash-completion-fips-1.27pkg:apk/chainguard/kubectl-fips-1.27pkg:apk/chainguard/kubectl-fips-1.27-defaultpkg:apk/chainguard/kubelet-fips-1.27pkg:apk/chainguard/kubelet-fips-1.27-defaultpkg:apk/chainguard/kube-proxy-fips-1.27pkg:apk/chainguard/kube-proxy-fips-1.27-defaultpkg:apk/chainguard/kubernetes-dashboardpkg:apk/chainguard/kubernetes-fips-1.27pkg:apk/chainguard/kubernetes-fips-1.27-defaultpkg:apk/chainguard/kube-scheduler-fips-1.27pkg:apk/chainguard/kube-scheduler-fips-1.27-defaultpkg:apk/chainguard/prometheus-2.38pkg:apk/chainguard/prometheus-2.38-bitnami-compatpkg:apk/chainguard/prometheus-fips-2.38pkg:apk/chainguard/traefikpkg:apk/wolfi/aactlpkg:apk/wolfi/argocd-image-updaterpkg:apk/wolfi/argocd-image-updater-compatpkg:apk/wolfi/bompkg:apk/wolfi/flux-compatpkg:apk/wolfi/goreleaser-1.18pkg:apk/wolfi/kptpkg:apk/wolfi/kubernetes-dashboardpkg:apk/wolfi/traefikpkg:golang/github.com/docker/distributionpkg:rpm/opensuse/distribution&distro=openSUSE%20Leap%2015.4pkg:rpm/opensuse/distribution-registry&distro=openSUSE%20Tumbleweedpkg:rpm/suse/distribution&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Containers%2015%20SP4pkg:rpm/suse/docker-distribution&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Containers%2012
< 0.4.12-r7+ 46 more
- (no CPE)range: < 0.4.12-r7
- (no CPE)range: < 0.17.0-r1
- (no CPE)range: < 0.17.0-r1
- (no CPE)range: < 0.17.0-r2
- (no CPE)range: < 0.6.0-r0
- (no CPE)range: < 0
- (no CPE)range: < 0
- (no CPE)range: < 0.27.0-r7
- (no CPE)range: < 0.26.1-r3
- (no CPE)range: < 1.18.2-r12
- (no CPE)range: < 1.0.0_beta31-r5
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 2.7.0-r2
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 1.27.12-r1
- (no CPE)range: < 2.38.0-r7
- (no CPE)range: < 2.38.0-r7
- (no CPE)range: < 0
- (no CPE)range: < 2.10.1-r2
- (no CPE)range: < 0.4.12-r7
- (no CPE)range: < 0.17.0-r1
- (no CPE)range: < 0.17.0-r1
- (no CPE)range: < 0.6.0-r0
- (no CPE)range: < 0
- (no CPE)range: < 1.18.2-r12
- (no CPE)range: < 1.0.0_beta31-r5
- (no CPE)range: < 2.7.0-r2
- (no CPE)range: < 2.10.1-r2
- (no CPE)range: < 2.8.2-beta.1
- (no CPE)range: < 2.8.1-150400.9.18.1
- (no CPE)range: < 2.8.2-1.1
- (no CPE)range: < 2.8.1-150400.9.18.1
- (no CPE)range: < 2.6.2-13.9.1
Patches
1f55a6552b006Merge pull request from GHSA-hqxw-f8mx-cpmw
6 files changed · +369 −55
configuration/configuration.go+17 −1 modified@@ -197,7 +197,8 @@ type Configuration struct { } `yaml:"pool,omitempty"` } `yaml:"redis,omitempty"` - Health Health `yaml:"health,omitempty"` + Health Health `yaml:"health,omitempty"` + Catalog Catalog `yaml:"catalog,omitempty"` Proxy Proxy `yaml:"proxy,omitempty"` @@ -260,6 +261,16 @@ type Configuration struct { } `yaml:"policy,omitempty"` } +// Catalog is composed of MaxEntries. +// Catalog endpoint (/v2/_catalog) configuration, it provides the configuration +// options to control the maximum number of entries returned by the catalog endpoint. +type Catalog struct { + // Max number of entries returned by the catalog endpoint. Requesting n entries + // to the catalog endpoint will return at most MaxEntries entries. + // An empty or a negative value will set a default of 1000 maximum entries by default. + MaxEntries int `yaml:"maxentries,omitempty"` +} + // LogHook is composed of hook Level and Type. // After hooks configuration, it can execute the next handling automatically, // when defined levels of log message emitted. @@ -686,6 +697,11 @@ func Parse(rd io.Reader) (*Configuration, error) { if v0_1.Loglevel != Loglevel("") { v0_1.Loglevel = Loglevel("") } + + if v0_1.Catalog.MaxEntries <= 0 { + v0_1.Catalog.MaxEntries = 1000 + } + if v0_1.Storage.Type() == "" { return nil, errors.New("no storage configuration provided") }
configuration/configuration_test.go+4 −0 modified@@ -70,6 +70,9 @@ var configStruct = Configuration{ }, }, }, + Catalog: Catalog{ + MaxEntries: 1000, + }, HTTP: struct { Addr string `yaml:"addr,omitempty"` Net string `yaml:"net,omitempty"` @@ -521,6 +524,7 @@ func copyConfig(config Configuration) *Configuration { configCopy.Version = MajorMinorVersion(config.Version.Major(), config.Version.Minor()) configCopy.Loglevel = config.Loglevel configCopy.Log = config.Log + configCopy.Catalog = config.Catalog configCopy.Log.Fields = make(map[string]interface{}, len(config.Log.Fields)) for k, v := range config.Log.Fields { configCopy.Log.Fields[k] = v
registry/api/v2/descriptors.go+17 −12 modified@@ -134,6 +134,19 @@ var ( }, } + invalidPaginationResponseDescriptor = ResponseDescriptor{ + Name: "Invalid pagination number", + Description: "The received parameter n was invalid in some way, as described by the error code. The client should resolve the issue and retry the request.", + StatusCode: http.StatusBadRequest, + Body: BodyDescriptor{ + ContentType: "application/json", + Format: errorsBody, + }, + ErrorCodes: []errcode.ErrorCode{ + ErrorCodePaginationNumberInvalid, + }, + } + repositoryNotFoundResponseDescriptor = ResponseDescriptor{ Name: "No Such Repository Error", StatusCode: http.StatusNotFound, @@ -489,18 +502,7 @@ var routeDescriptors = []RouteDescriptor{ }, }, Failures: []ResponseDescriptor{ - { - Name: "Invalid pagination number", - Description: "The received parameter n was invalid in some way, as described by the error code. The client should resolve the issue and retry the request.", - StatusCode: http.StatusBadRequest, - Body: BodyDescriptor{ - ContentType: "application/json", - Format: errorsBody, - }, - ErrorCodes: []errcode.ErrorCode{ - ErrorCodePaginationNumberInvalid, - }, - }, + invalidPaginationResponseDescriptor, unauthorizedResponseDescriptor, repositoryNotFoundResponseDescriptor, deniedResponseDescriptor, @@ -1589,6 +1591,9 @@ var routeDescriptors = []RouteDescriptor{ }, }, }, + Failures: []ResponseDescriptor{ + invalidPaginationResponseDescriptor, + }, }, }, },
registry/api/v2/errors.go+2 −1 modified@@ -151,7 +151,8 @@ var ( Value: "PAGINATION_NUMBER_INVALID", Message: "invalid number of results requested", Description: `Returned when the "n" parameter (number of results - to return) is not an integer, or "n" is negative.`, + to return) is not an integer, "n" is negative or "n" is bigger than + the maximum allowed.`, HTTPStatusCode: http.StatusBadRequest, }) )
registry/handlers/api_test.go+286 −28 modified@@ -80,22 +80,23 @@ func TestCheckAPI(t *testing.T) { // TestCatalogAPI tests the /v2/_catalog endpoint func TestCatalogAPI(t *testing.T) { - chunkLen := 2 env := newTestEnv(t, false) defer env.Shutdown() - values := url.Values{ - "last": []string{""}, - "n": []string{strconv.Itoa(chunkLen)}, + maxEntries := env.config.Catalog.MaxEntries + allCatalog := []string{ + "foo/aaaa", "foo/bbbb", "foo/cccc", "foo/dddd", "foo/eeee", "foo/ffff", } - catalogURL, err := env.builder.BuildCatalogURL(values) + chunkLen := maxEntries - 1 + + catalogURL, err := env.builder.BuildCatalogURL() if err != nil { t.Fatalf("unexpected error building catalog url: %v", err) } // ----------------------------------- - // try to get an empty catalog + // Case No. 1: Empty catalog resp, err := http.Get(catalogURL) if err != nil { t.Fatalf("unexpected error issuing request: %v", err) @@ -113,23 +114,23 @@ func TestCatalogAPI(t *testing.T) { t.Fatalf("error decoding fetched manifest: %v", err) } - // we haven't pushed anything to the registry yet + // No images pushed = no image returned if len(ctlg.Repositories) != 0 { - t.Fatalf("repositories has unexpected values") + t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", 0, len(ctlg.Repositories)) } + // No pagination should be returned if resp.Header.Get("Link") != "" { t.Fatalf("repositories has more data when none expected") } - // ----------------------------------- - // push something to the registry and try again - images := []string{"foo/aaaa", "foo/bbbb", "foo/cccc"} - - for _, image := range images { + for _, image := range allCatalog { createRepository(env, t, image, "sometag") } + // ----------------------------------- + // Case No. 2: Catalog populated & n is not provided nil (n internally will be min(100, maxEntries)) + resp, err = http.Get(catalogURL) if err != nil { t.Fatalf("unexpected error issuing request: %v", err) @@ -143,27 +144,95 @@ func TestCatalogAPI(t *testing.T) { t.Fatalf("error decoding fetched manifest: %v", err) } - if len(ctlg.Repositories) != chunkLen { - t.Fatalf("repositories has unexpected values") + // it must match max entries + if len(ctlg.Repositories) != maxEntries { + t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", maxEntries, len(ctlg.Repositories)) } - for _, image := range images[:chunkLen] { + // it must return the first maxEntries entries from the catalog + for _, image := range allCatalog[:maxEntries] { if !contains(ctlg.Repositories, image) { t.Fatalf("didn't find our repository '%s' in the catalog", image) } } + // fail if there's no pagination link := resp.Header.Get("Link") if link == "" { t.Fatalf("repositories has less data than expected") } + // ----------------------------------- + // Case No. 2.1: Second page (n internally will be min(100, maxEntries)) + + // build pagination link + values := checkLink(t, link, maxEntries, ctlg.Repositories[len(ctlg.Repositories)-1]) + + catalogURL, err = env.builder.BuildCatalogURL(values) + if err != nil { + t.Fatalf("unexpected error building catalog url: %v", err) + } + + resp, err = http.Get(catalogURL) + if err != nil { + t.Fatalf("unexpected error issuing request: %v", err) + } + defer resp.Body.Close() + + checkResponse(t, "issuing catalog api check", resp, http.StatusOK) + + dec = json.NewDecoder(resp.Body) + if err = dec.Decode(&ctlg); err != nil { + t.Fatalf("error decoding fetched manifest: %v", err) + } + + expectedRemainder := len(allCatalog) - maxEntries + + if len(ctlg.Repositories) != expectedRemainder { + t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories)) + } + + // ----------------------------------- + // Case No. 3: request n = maxentries + values = url.Values{ + "last": []string{""}, + "n": []string{strconv.Itoa(maxEntries)}, + } + + catalogURL, err = env.builder.BuildCatalogURL(values) + if err != nil { + t.Fatalf("unexpected error building catalog url: %v", err) + } + + resp, err = http.Get(catalogURL) + if err != nil { + t.Fatalf("unexpected error issuing request: %v", err) + } + defer resp.Body.Close() + + checkResponse(t, "issuing catalog api check", resp, http.StatusOK) + + dec = json.NewDecoder(resp.Body) + if err = dec.Decode(&ctlg); err != nil { + t.Fatalf("error decoding fetched manifest: %v", err) + } - newValues := checkLink(t, link, chunkLen, ctlg.Repositories[len(ctlg.Repositories)-1]) + if len(ctlg.Repositories) != maxEntries { + t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", maxEntries, len(ctlg.Repositories)) + } + + // fail if there's no pagination + link = resp.Header.Get("Link") + if link == "" { + t.Fatalf("repositories has less data than expected") + } // ----------------------------------- - // get the last chunk of data + // Case No. 3.1: Second (last) page + + // build pagination link + values = checkLink(t, link, maxEntries, ctlg.Repositories[len(ctlg.Repositories)-1]) - catalogURL, err = env.builder.BuildCatalogURL(newValues) + catalogURL, err = env.builder.BuildCatalogURL(values) if err != nil { t.Fatalf("unexpected error building catalog url: %v", err) } @@ -181,18 +250,202 @@ func TestCatalogAPI(t *testing.T) { t.Fatalf("error decoding fetched manifest: %v", err) } - if len(ctlg.Repositories) != 1 { - t.Fatalf("repositories has unexpected values") + expectedRemainder = len(allCatalog) - maxEntries + + if len(ctlg.Repositories) != expectedRemainder { + t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories)) + } + + // ----------------------------------- + // Case No. 4: request n < maxentries + + values = url.Values{ + "n": []string{strconv.Itoa(chunkLen)}, + } + + catalogURL, err = env.builder.BuildCatalogURL(values) + if err != nil { + t.Fatalf("unexpected error building catalog url: %v", err) + } + + resp, err = http.Get(catalogURL) + if err != nil { + t.Fatalf("unexpected error issuing request: %v", err) + } + defer resp.Body.Close() + + checkResponse(t, "issuing catalog api check", resp, http.StatusOK) + + dec = json.NewDecoder(resp.Body) + if err = dec.Decode(&ctlg); err != nil { + t.Fatalf("error decoding fetched manifest: %v", err) } - lastImage := images[len(images)-1] - if !contains(ctlg.Repositories, lastImage) { - t.Fatalf("didn't find our repository '%s' in the catalog", lastImage) + // returns the requested amount + if len(ctlg.Repositories) != chunkLen { + t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories)) } + // fail if there's no pagination link = resp.Header.Get("Link") - if link != "" { - t.Fatalf("catalog has unexpected data") + if link == "" { + t.Fatalf("repositories has less data than expected") + } + + // ----------------------------------- + // Case No. 4.1: request n < maxentries (second page) + + // build pagination link + values = checkLink(t, link, chunkLen, ctlg.Repositories[len(ctlg.Repositories)-1]) + + catalogURL, err = env.builder.BuildCatalogURL(values) + if err != nil { + t.Fatalf("unexpected error building catalog url: %v", err) + } + + resp, err = http.Get(catalogURL) + if err != nil { + t.Fatalf("unexpected error issuing request: %v", err) + } + defer resp.Body.Close() + + checkResponse(t, "issuing catalog api check", resp, http.StatusOK) + + dec = json.NewDecoder(resp.Body) + if err = dec.Decode(&ctlg); err != nil { + t.Fatalf("error decoding fetched manifest: %v", err) + } + + expectedRemainder = len(allCatalog) - chunkLen + + if len(ctlg.Repositories) != expectedRemainder { + t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories)) + } + + // ----------------------------------- + // Case No. 5: request n > maxentries + + values = url.Values{ + "n": []string{strconv.Itoa(maxEntries + 10)}, + } + + catalogURL, err = env.builder.BuildCatalogURL(values) + if err != nil { + t.Fatalf("unexpected error building catalog url: %v", err) + } + + resp, err = http.Get(catalogURL) + if err != nil { + t.Fatalf("unexpected error issuing request: %v", err) + } + defer resp.Body.Close() + + checkResponse(t, "issuing catalog api check", resp, http.StatusBadRequest) + checkBodyHasErrorCodes(t, "invalid number of results requested", resp, v2.ErrorCodePaginationNumberInvalid) + + // ----------------------------------- + // Case No. 6: request n > maxentries but <= total catalog + + values = url.Values{ + "n": []string{strconv.Itoa(len(allCatalog))}, + } + + catalogURL, err = env.builder.BuildCatalogURL(values) + if err != nil { + t.Fatalf("unexpected error building catalog url: %v", err) + } + + resp, err = http.Get(catalogURL) + if err != nil { + t.Fatalf("unexpected error issuing request: %v", err) + } + defer resp.Body.Close() + + checkResponse(t, "issuing catalog api check", resp, http.StatusBadRequest) + checkBodyHasErrorCodes(t, "invalid number of results requested", resp, v2.ErrorCodePaginationNumberInvalid) + + // ----------------------------------- + // Case No. 7: n = 0 + values = url.Values{ + "n": []string{"0"}, + } + + catalogURL, err = env.builder.BuildCatalogURL(values) + if err != nil { + t.Fatalf("unexpected error building catalog url: %v", err) + } + + resp, err = http.Get(catalogURL) + if err != nil { + t.Fatalf("unexpected error issuing request: %v", err) + } + defer resp.Body.Close() + + checkResponse(t, "issuing catalog api check", resp, http.StatusOK) + + dec = json.NewDecoder(resp.Body) + if err = dec.Decode(&ctlg); err != nil { + t.Fatalf("error decoding fetched manifest: %v", err) + } + + // it must match max entries + if len(ctlg.Repositories) != 0 { + t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", 0, len(ctlg.Repositories)) + } + + // ----------------------------------- + // Case No. 8: n = -1 + values = url.Values{ + "n": []string{"-1"}, + } + + catalogURL, err = env.builder.BuildCatalogURL(values) + if err != nil { + t.Fatalf("unexpected error building catalog url: %v", err) + } + + resp, err = http.Get(catalogURL) + if err != nil { + t.Fatalf("unexpected error issuing request: %v", err) + } + defer resp.Body.Close() + + checkResponse(t, "issuing catalog api check", resp, http.StatusBadRequest) + checkBodyHasErrorCodes(t, "invalid number of results requested", resp, v2.ErrorCodePaginationNumberInvalid) + + // ----------------------------------- + // Case No. 9: n = 5, max = 5, total catalog = 4 + values = url.Values{ + "n": []string{strconv.Itoa(maxEntries)}, + } + + envWithLessImages := newTestEnv(t, false) + for _, image := range allCatalog[0:(maxEntries - 1)] { + createRepository(envWithLessImages, t, image, "sometag") + } + + catalogURL, err = envWithLessImages.builder.BuildCatalogURL(values) + + if err != nil { + t.Fatalf("unexpected error building catalog url: %v", err) + } + + resp, err = http.Get(catalogURL) + if err != nil { + t.Fatalf("unexpected error issuing request: %v", err) + } + defer resp.Body.Close() + + checkResponse(t, "issuing catalog api check", resp, http.StatusOK) + + dec = json.NewDecoder(resp.Body) + if err = dec.Decode(&ctlg); err != nil { + t.Fatalf("error decoding fetched manifest: %v", err) + } + + // it must match max entries + if len(ctlg.Repositories) != maxEntries-1 { + t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", maxEntries-1, len(ctlg.Repositories)) } } @@ -363,7 +616,7 @@ func checkLink(t *testing.T, urlStr string, numEntries int, last string) url.Val urlValues := linkURL.Query() if urlValues.Get("n") != strconv.Itoa(numEntries) { - t.Fatalf("Catalog link entry size is incorrect") + t.Fatalf("Catalog link entry size is incorrect (expected: %v, returned: %v)", urlValues.Get("n"), strconv.Itoa(numEntries)) } if urlValues.Get("last") != last { @@ -2299,6 +2552,9 @@ func newTestEnvMirror(t *testing.T, deleteEnabled bool) *testEnv { Proxy: configuration.Proxy{ RemoteURL: upstreamEnv.server.URL, }, + Catalog: configuration.Catalog{ + MaxEntries: 5, + }, } config.Compatibility.Schema1.Enabled = true //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility. @@ -2314,6 +2570,9 @@ func newTestEnv(t *testing.T, deleteEnabled bool) *testEnv { "enabled": false, }}, }, + Catalog: configuration.Catalog{ + MaxEntries: 5, + }, } config.Compatibility.Schema1.Enabled = true //nolint:staticcheck // Ignore SA1019: "github.com/distribution/distribution/v3/manifest/schema1" is deprecated, as it's used for backward compatibility. @@ -2594,7 +2853,6 @@ func checkResponse(t *testing.T, msg string, resp *http.Response, expectedStatus if resp.StatusCode != expectedStatus { t.Logf("unexpected status %s: %v != %v", msg, resp.StatusCode, expectedStatus) maybeDumpResponse(t, resp) - t.FailNow() }
registry/handlers/catalog.go+43 −13 modified@@ -9,11 +9,12 @@ import ( "strconv" "github.com/distribution/distribution/v3/registry/api/errcode" + v2 "github.com/distribution/distribution/v3/registry/api/v2" "github.com/distribution/distribution/v3/registry/storage/driver" "github.com/gorilla/handlers" ) -const maximumReturnedEntries = 100 +const defaultReturnedEntries = 100 func catalogDispatcher(ctx *Context, r *http.Request) http.Handler { catalogHandler := &catalogHandler{ @@ -38,29 +39,58 @@ func (ch *catalogHandler) GetCatalog(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() lastEntry := q.Get("last") - maxEntries, err := strconv.Atoi(q.Get("n")) - if err != nil || maxEntries < 0 { - maxEntries = maximumReturnedEntries + + entries := defaultReturnedEntries + maximumConfiguredEntries := ch.App.Config.Catalog.MaxEntries + + // parse n, if n is negative abort with an error + if n := q.Get("n"); n != "" { + parsedMax, err := strconv.Atoi(n) + if err != nil || parsedMax < 0 { + ch.Errors = append(ch.Errors, v2.ErrorCodePaginationNumberInvalid.WithDetail(map[string]string{"n": n})) + return + } + + // if a client requests more than it's allowed to receive + if parsedMax > maximumConfiguredEntries { + ch.Errors = append(ch.Errors, v2.ErrorCodePaginationNumberInvalid.WithDetail(map[string]int{"n": parsedMax})) + return + } + entries = parsedMax } - repos := make([]string, maxEntries) + // then enforce entries to be between 0 & maximumConfiguredEntries + // max(0, min(entries, maximumConfiguredEntries)) + if entries < 0 || entries > maximumConfiguredEntries { + entries = maximumConfiguredEntries + } - filled, err := ch.App.registry.Repositories(ch.Context, repos, lastEntry) - _, pathNotFound := err.(driver.PathNotFoundError) + repos := make([]string, entries) + filled := 0 - if err == io.EOF || pathNotFound { + // entries is guaranteed to be >= 0 and < maximumConfiguredEntries + if entries == 0 { moreEntries = false - } else if err != nil { - ch.Errors = append(ch.Errors, errcode.ErrorCodeUnknown.WithDetail(err)) - return + } else { + returnedRepositories, err := ch.App.registry.Repositories(ch.Context, repos, lastEntry) + if err != nil { + _, pathNotFound := err.(driver.PathNotFoundError) + if err != io.EOF && !pathNotFound { + ch.Errors = append(ch.Errors, errcode.ErrorCodeUnknown.WithDetail(err)) + return + } + // err is either io.EOF or not PathNotFoundError + moreEntries = false + } + filled = returnedRepositories } w.Header().Set("Content-Type", "application/json") // Add a link header if there are more entries to retrieve if moreEntries { - lastEntry = repos[len(repos)-1] - urlStr, err := createLinkEntry(r.URL.String(), maxEntries, lastEntry) + lastEntry = repos[filled-1] + urlStr, err := createLinkEntry(r.URL.String(), entries, lastEntry) if err != nil { ch.Errors = append(ch.Errors, errcode.ErrorCodeUnknown.WithDetail(err)) return
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-hqxw-f8mx-cpmwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-2253ghsaADVISORY
- bugzilla.redhat.com/show_bug.cgighsaWEB
- github.com/distribution/distribution/commit/f55a6552b006a381d9167e328808565dd2bf77dcghsaWEB
- github.com/distribution/distribution/security/advisories/GHSA-hqxw-f8mx-cpmwghsaWEB
- lists.debian.org/debian-lts-announce/2023/06/msg00035.htmlghsamailing-listWEB
News mentions
0No linked articles in our index yet.