summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNarpat Mali <narpat.mali@windriver.com>2023-07-25 11:53:19 +0000
committerBruce Ashfield <bruce.ashfield@gmail.com>2023-07-25 14:53:37 -0400
commitaf02908efda1580e77b3fdeed25b124a2b8d9482 (patch)
tree24fdc54ec2990bbf82da577f1c3431f47c25a750
parentb3b3dbc67504e8cd498d6db202ddcf5a9dd26a9d (diff)
downloadmeta-virtualization-af02908efda1580e77b3fdeed25b124a2b8d9482.tar.gz
docker-distribution: fix for CVE-2023-2253
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. References: https://github.com/distribution/distribution/security/advisories/GHSA-hqxw-f8mx-cpmw https://github.com/distribution/distribution/commit/521ea3d973cb0c7089ebbcdd4ccadc34be941f54 Signed-off-by: Narpat Mali <narpat.mali@windriver.com> Signed-off-by: Bruce Ashfield <bruce.ashfield@gmail.com>
-rw-r--r--recipes-containers/docker-distribution/docker-distribution_git.bb1
-rw-r--r--recipes-containers/docker-distribution/files/0001-Fix-runaway-allocation-on-v2-_catalog.patch669
2 files changed, 670 insertions, 0 deletions
diff --git a/recipes-containers/docker-distribution/docker-distribution_git.bb b/recipes-containers/docker-distribution/docker-distribution_git.bb
index 93c2067e..f8981a88 100644
--- a/recipes-containers/docker-distribution/docker-distribution_git.bb
+++ b/recipes-containers/docker-distribution/docker-distribution_git.bb
@@ -7,6 +7,7 @@ SRCREV_distribution= "b5ca020cfbe998e5af3457fda087444cf5116496"
7SRC_URI = "git://github.com/docker/distribution.git;branch=release/2.8;name=distribution;destsuffix=git/src/github.com/docker/distribution;protocol=https \ 7SRC_URI = "git://github.com/docker/distribution.git;branch=release/2.8;name=distribution;destsuffix=git/src/github.com/docker/distribution;protocol=https \
8 file://docker-registry.service \ 8 file://docker-registry.service \
9 file://0001-build-use-to-use-cross-go-compiler.patch \ 9 file://0001-build-use-to-use-cross-go-compiler.patch \
10 file://0001-Fix-runaway-allocation-on-v2-_catalog.patch \
10 " 11 "
11 12
12PACKAGES =+ "docker-registry" 13PACKAGES =+ "docker-registry"
diff --git a/recipes-containers/docker-distribution/files/0001-Fix-runaway-allocation-on-v2-_catalog.patch b/recipes-containers/docker-distribution/files/0001-Fix-runaway-allocation-on-v2-_catalog.patch
new file mode 100644
index 00000000..69da7054
--- /dev/null
+++ b/recipes-containers/docker-distribution/files/0001-Fix-runaway-allocation-on-v2-_catalog.patch
@@ -0,0 +1,669 @@
1From 521ea3d973cb0c7089ebbcdd4ccadc34be941f54 Mon Sep 17 00:00:00 2001
2From: "Jose D. Gomez R" <jose.gomez@suse.com>
3Date: Mon, 24 Apr 2023 18:52:27 +0200
4Subject: [PATCH] Fix runaway allocation on /v2/_catalog
5MIME-Version: 1.0
6Content-Type: text/plain; charset=UTF-8
7Content-Transfer-Encoding: 8bit
8
9Introduced a Catalog entry in the configuration struct. With it,
10it's possible to control the maximum amount of entries returned
11by /v2/catalog (`GetCatalog` in registry/handlers/catalog.go).
12
13It's set to a default value of 1000.
14
15`GetCatalog` returns 100 entries by default if no `n` is
16provided. When provided it will be validated to be between `0`
17and `MaxEntries` defined in Configuration. When `n` is outside
18the aforementioned boundary, ErrorCodePaginationNumberInvalid is
19returned.
20
21`GetCatalog` now handles `n=0` gracefully with an empty response
22as well.
23
24Signed-off-by: José D. Gómez R. <1josegomezr@gmail.com>
25Co-authored-by: Cory Snider <corhere@gmail.com>
26
27CVE: CVE-2023-2253
28
29Upstream-Status: Backport [https://github.com/distribution/distribution/commit/521ea3d973cb0c7089ebbcdd4ccadc34be941f54]
30
31Signed-off-by: Narpat Mali <narpat.mali@windriver.com>
32---
33 configuration/configuration.go | 18 +-
34 configuration/configuration_test.go | 4 +
35 registry/api/v2/descriptors.go | 17 ++
36 registry/api/v2/errors.go | 9 +
37 registry/handlers/api_test.go | 316 +++++++++++++++++++++++++---
38 registry/handlers/catalog.go | 54 +++--
39 6 files changed, 376 insertions(+), 42 deletions(-)
40
41diff --git a/configuration/configuration.go b/configuration/configuration.go
42index dd315485..1e696613 100644
43--- a/configuration/configuration.go
44+++ b/configuration/configuration.go
45@@ -193,7 +193,8 @@ type Configuration struct {
46 } `yaml:"pool,omitempty"`
47 } `yaml:"redis,omitempty"`
48
49- Health Health `yaml:"health,omitempty"`
50+ Health Health `yaml:"health,omitempty"`
51+ Catalog Catalog `yaml:"catalog,omitempty"`
52
53 Proxy Proxy `yaml:"proxy,omitempty"`
54
55@@ -244,6 +245,16 @@ type Configuration struct {
56 } `yaml:"policy,omitempty"`
57 }
58
59+// Catalog is composed of MaxEntries.
60+// Catalog endpoint (/v2/_catalog) configuration, it provides the configuration
61+// options to control the maximum number of entries returned by the catalog endpoint.
62+type Catalog struct {
63+ // Max number of entries returned by the catalog endpoint. Requesting n entries
64+ // to the catalog endpoint will return at most MaxEntries entries.
65+ // An empty or a negative value will set a default of 1000 maximum entries by default.
66+ MaxEntries int `yaml:"maxentries,omitempty"`
67+}
68+
69 // LogHook is composed of hook Level and Type.
70 // After hooks configuration, it can execute the next handling automatically,
71 // when defined levels of log message emitted.
72@@ -670,6 +681,11 @@ func Parse(rd io.Reader) (*Configuration, error) {
73 if v0_1.Loglevel != Loglevel("") {
74 v0_1.Loglevel = Loglevel("")
75 }
76+
77+ if v0_1.Catalog.MaxEntries <= 0 {
78+ v0_1.Catalog.MaxEntries = 1000
79+ }
80+
81 if v0_1.Storage.Type() == "" {
82 return nil, errors.New("no storage configuration provided")
83 }
84diff --git a/configuration/configuration_test.go b/configuration/configuration_test.go
85index 0d6136e1..48cc9980 100644
86--- a/configuration/configuration_test.go
87+++ b/configuration/configuration_test.go
88@@ -71,6 +71,9 @@ var configStruct = Configuration{
89 },
90 },
91 },
92+ Catalog: Catalog{
93+ MaxEntries: 1000,
94+ },
95 HTTP: struct {
96 Addr string `yaml:"addr,omitempty"`
97 Net string `yaml:"net,omitempty"`
98@@ -524,6 +527,7 @@ func copyConfig(config Configuration) *Configuration {
99 configCopy.Version = MajorMinorVersion(config.Version.Major(), config.Version.Minor())
100 configCopy.Loglevel = config.Loglevel
101 configCopy.Log = config.Log
102+ configCopy.Catalog = config.Catalog
103 configCopy.Log.Fields = make(map[string]interface{}, len(config.Log.Fields))
104 for k, v := range config.Log.Fields {
105 configCopy.Log.Fields[k] = v
106diff --git a/registry/api/v2/descriptors.go b/registry/api/v2/descriptors.go
107index a9616c58..c3bf90f7 100644
108--- a/registry/api/v2/descriptors.go
109+++ b/registry/api/v2/descriptors.go
110@@ -134,6 +134,19 @@ var (
111 },
112 }
113
114+ invalidPaginationResponseDescriptor = ResponseDescriptor{
115+ Name: "Invalid pagination number",
116+ 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.",
117+ StatusCode: http.StatusBadRequest,
118+ Body: BodyDescriptor{
119+ ContentType: "application/json",
120+ Format: errorsBody,
121+ },
122+ ErrorCodes: []errcode.ErrorCode{
123+ ErrorCodePaginationNumberInvalid,
124+ },
125+ }
126+
127 repositoryNotFoundResponseDescriptor = ResponseDescriptor{
128 Name: "No Such Repository Error",
129 StatusCode: http.StatusNotFound,
130@@ -490,6 +503,7 @@ var routeDescriptors = []RouteDescriptor{
131 },
132 },
133 Failures: []ResponseDescriptor{
134+ invalidPaginationResponseDescriptor,
135 unauthorizedResponseDescriptor,
136 repositoryNotFoundResponseDescriptor,
137 deniedResponseDescriptor,
138@@ -1578,6 +1592,9 @@ var routeDescriptors = []RouteDescriptor{
139 },
140 },
141 },
142+ Failures: []ResponseDescriptor{
143+ invalidPaginationResponseDescriptor,
144+ },
145 },
146 },
147 },
148diff --git a/registry/api/v2/errors.go b/registry/api/v2/errors.go
149index 97d6923a..87e9f3c1 100644
150--- a/registry/api/v2/errors.go
151+++ b/registry/api/v2/errors.go
152@@ -133,4 +133,13 @@ var (
153 longer proceed.`,
154 HTTPStatusCode: http.StatusNotFound,
155 })
156+
157+ ErrorCodePaginationNumberInvalid = errcode.Register(errGroup, errcode.ErrorDescriptor{
158+ Value: "PAGINATION_NUMBER_INVALID",
159+ Message: "invalid number of results requested",
160+ Description: `Returned when the "n" parameter (number of results
161+ to return) is not an integer, "n" is negative or "n" is bigger than
162+ the maximum allowed.`,
163+ HTTPStatusCode: http.StatusBadRequest,
164+ })
165 )
166diff --git a/registry/handlers/api_test.go b/registry/handlers/api_test.go
167index 2d3edc74..bf037d45 100644
168--- a/registry/handlers/api_test.go
169+++ b/registry/handlers/api_test.go
170@@ -81,21 +81,23 @@ func TestCheckAPI(t *testing.T) {
171
172 // TestCatalogAPI tests the /v2/_catalog endpoint
173 func TestCatalogAPI(t *testing.T) {
174- chunkLen := 2
175 env := newTestEnv(t, false)
176 defer env.Shutdown()
177
178- values := url.Values{
179- "last": []string{""},
180- "n": []string{strconv.Itoa(chunkLen)}}
181+ maxEntries := env.config.Catalog.MaxEntries
182+ allCatalog := []string{
183+ "foo/aaaa", "foo/bbbb", "foo/cccc", "foo/dddd", "foo/eeee", "foo/ffff",
184+ }
185
186- catalogURL, err := env.builder.BuildCatalogURL(values)
187+ chunkLen := maxEntries - 1
188+
189+ catalogURL, err := env.builder.BuildCatalogURL()
190 if err != nil {
191 t.Fatalf("unexpected error building catalog url: %v", err)
192 }
193
194 // -----------------------------------
195- // try to get an empty catalog
196+ // Case No. 1: Empty catalog
197 resp, err := http.Get(catalogURL)
198 if err != nil {
199 t.Fatalf("unexpected error issuing request: %v", err)
200@@ -113,23 +115,22 @@ func TestCatalogAPI(t *testing.T) {
201 t.Fatalf("error decoding fetched manifest: %v", err)
202 }
203
204- // we haven't pushed anything to the registry yet
205+ // No images pushed = no image returned
206 if len(ctlg.Repositories) != 0 {
207- t.Fatalf("repositories has unexpected values")
208+ t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", 0, len(ctlg.Repositories))
209 }
210
211+ // No pagination should be returned
212 if resp.Header.Get("Link") != "" {
213 t.Fatalf("repositories has more data when none expected")
214 }
215
216- // -----------------------------------
217- // push something to the registry and try again
218- images := []string{"foo/aaaa", "foo/bbbb", "foo/cccc"}
219-
220- for _, image := range images {
221+ for _, image := range allCatalog {
222 createRepository(env, t, image, "sometag")
223 }
224
225+ // -----------------------------------
226+ // Case No. 2: Catalog populated & n is not provided nil (n internally will be min(100, maxEntries))
227 resp, err = http.Get(catalogURL)
228 if err != nil {
229 t.Fatalf("unexpected error issuing request: %v", err)
230@@ -143,27 +144,60 @@ func TestCatalogAPI(t *testing.T) {
231 t.Fatalf("error decoding fetched manifest: %v", err)
232 }
233
234- if len(ctlg.Repositories) != chunkLen {
235- t.Fatalf("repositories has unexpected values")
236+ // it must match max entries
237+ if len(ctlg.Repositories) != maxEntries {
238+ t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", maxEntries, len(ctlg.Repositories))
239 }
240
241- for _, image := range images[:chunkLen] {
242+ // it must return the first maxEntries entries from the catalog
243+ for _, image := range allCatalog[:maxEntries] {
244 if !contains(ctlg.Repositories, image) {
245 t.Fatalf("didn't find our repository '%s' in the catalog", image)
246 }
247 }
248
249+ // fail if there's no pagination
250 link := resp.Header.Get("Link")
251 if link == "" {
252 t.Fatalf("repositories has less data than expected")
253 }
254+ // -----------------------------------
255+ // Case No. 2.1: Second page (n internally will be min(100, maxEntries))
256+
257+ // build pagination link
258+ values := checkLink(t, link, maxEntries, ctlg.Repositories[len(ctlg.Repositories)-1])
259+
260+ catalogURL, err = env.builder.BuildCatalogURL(values)
261+ if err != nil {
262+ t.Fatalf("unexpected error building catalog url: %v", err)
263+ }
264
265- newValues := checkLink(t, link, chunkLen, ctlg.Repositories[len(ctlg.Repositories)-1])
266+ resp, err = http.Get(catalogURL)
267+ if err != nil {
268+ t.Fatalf("unexpected error issuing request: %v", err)
269+ }
270+ defer resp.Body.Close()
271+
272+ checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
273+
274+ dec = json.NewDecoder(resp.Body)
275+ if err = dec.Decode(&ctlg); err != nil {
276+ t.Fatalf("error decoding fetched manifest: %v", err)
277+ }
278+
279+ expectedRemainder := len(allCatalog) - maxEntries
280+ if len(ctlg.Repositories) != expectedRemainder {
281+ t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories))
282+ }
283
284 // -----------------------------------
285- // get the last chunk of data
286+ // Case No. 3: request n = maxentries
287+ values = url.Values{
288+ "last": []string{""},
289+ "n": []string{strconv.Itoa(maxEntries)},
290+ }
291
292- catalogURL, err = env.builder.BuildCatalogURL(newValues)
293+ catalogURL, err = env.builder.BuildCatalogURL(values)
294 if err != nil {
295 t.Fatalf("unexpected error building catalog url: %v", err)
296 }
297@@ -181,18 +215,239 @@ func TestCatalogAPI(t *testing.T) {
298 t.Fatalf("error decoding fetched manifest: %v", err)
299 }
300
301- if len(ctlg.Repositories) != 1 {
302- t.Fatalf("repositories has unexpected values")
303+ if len(ctlg.Repositories) != maxEntries {
304+ t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", maxEntries, len(ctlg.Repositories))
305 }
306
307- lastImage := images[len(images)-1]
308- if !contains(ctlg.Repositories, lastImage) {
309- t.Fatalf("didn't find our repository '%s' in the catalog", lastImage)
310+ // fail if there's no pagination
311+ link = resp.Header.Get("Link")
312+ if link == "" {
313+ t.Fatalf("repositories has less data than expected")
314+ }
315+
316+ // -----------------------------------
317+ // Case No. 3.1: Second (last) page
318+
319+ // build pagination link
320+ values = checkLink(t, link, maxEntries, ctlg.Repositories[len(ctlg.Repositories)-1])
321+
322+ catalogURL, err = env.builder.BuildCatalogURL(values)
323+ if err != nil {
324+ t.Fatalf("unexpected error building catalog url: %v", err)
325 }
326
327+ resp, err = http.Get(catalogURL)
328+ if err != nil {
329+ t.Fatalf("unexpected error issuing request: %v", err)
330+ }
331+ defer resp.Body.Close()
332+
333+ checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
334+
335+ dec = json.NewDecoder(resp.Body)
336+ if err = dec.Decode(&ctlg); err != nil {
337+ t.Fatalf("error decoding fetched manifest: %v", err)
338+ }
339+
340+ expectedRemainder = len(allCatalog) - maxEntries
341+ if len(ctlg.Repositories) != expectedRemainder {
342+ t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories))
343+ }
344+
345+ // -----------------------------------
346+ // Case No. 4: request n < maxentries
347+ values = url.Values{
348+ "n": []string{strconv.Itoa(chunkLen)},
349+ }
350+
351+ catalogURL, err = env.builder.BuildCatalogURL(values)
352+ if err != nil {
353+ t.Fatalf("unexpected error building catalog url: %v", err)
354+ }
355+
356+ resp, err = http.Get(catalogURL)
357+ if err != nil {
358+ t.Fatalf("unexpected error issuing request: %v", err)
359+ }
360+ defer resp.Body.Close()
361+
362+ checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
363+
364+ dec = json.NewDecoder(resp.Body)
365+ if err = dec.Decode(&ctlg); err != nil {
366+ t.Fatalf("error decoding fetched manifest: %v", err)
367+ }
368+
369+ // returns the requested amount
370+ if len(ctlg.Repositories) != chunkLen {
371+ t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories))
372+ }
373+
374+ // fail if there's no pagination
375 link = resp.Header.Get("Link")
376- if link != "" {
377- t.Fatalf("catalog has unexpected data")
378+ if link == "" {
379+ t.Fatalf("repositories has less data than expected")
380+ }
381+
382+ // -----------------------------------
383+ // Case No. 4.1: request n < maxentries (second page)
384+
385+ // build pagination link
386+ values = checkLink(t, link, chunkLen, ctlg.Repositories[len(ctlg.Repositories)-1])
387+
388+ catalogURL, err = env.builder.BuildCatalogURL(values)
389+ if err != nil {
390+ t.Fatalf("unexpected error building catalog url: %v", err)
391+ }
392+
393+ resp, err = http.Get(catalogURL)
394+ if err != nil {
395+ t.Fatalf("unexpected error issuing request: %v", err)
396+ }
397+ defer resp.Body.Close()
398+
399+ checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
400+
401+ dec = json.NewDecoder(resp.Body)
402+ if err = dec.Decode(&ctlg); err != nil {
403+ t.Fatalf("error decoding fetched manifest: %v", err)
404+ }
405+
406+ expectedRemainder = len(allCatalog) - chunkLen
407+ if len(ctlg.Repositories) != expectedRemainder {
408+ t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories))
409+ }
410+
411+ // -----------------------------------
412+ // Case No. 5: request n > maxentries | return err: ErrorCodePaginationNumberInvalid
413+ values = url.Values{
414+ "n": []string{strconv.Itoa(maxEntries + 10)},
415+ }
416+
417+ catalogURL, err = env.builder.BuildCatalogURL(values)
418+ if err != nil {
419+ t.Fatalf("unexpected error building catalog url: %v", err)
420+ }
421+
422+ resp, err = http.Get(catalogURL)
423+ if err != nil {
424+ t.Fatalf("unexpected error issuing request: %v", err)
425+ }
426+ defer resp.Body.Close()
427+
428+ checkResponse(t, "issuing catalog api check", resp, http.StatusBadRequest)
429+ checkBodyHasErrorCodes(t, "invalid number of results requested", resp, v2.ErrorCodePaginationNumberInvalid)
430+
431+ // -----------------------------------
432+ // Case No. 6: request n > maxentries but <= total catalog | return err: ErrorCodePaginationNumberInvalid
433+ values = url.Values{
434+ "n": []string{strconv.Itoa(len(allCatalog))},
435+ }
436+
437+ catalogURL, err = env.builder.BuildCatalogURL(values)
438+ if err != nil {
439+ t.Fatalf("unexpected error building catalog url: %v", err)
440+ }
441+
442+ resp, err = http.Get(catalogURL)
443+ if err != nil {
444+ t.Fatalf("unexpected error issuing request: %v", err)
445+ }
446+ defer resp.Body.Close()
447+
448+ checkResponse(t, "issuing catalog api check", resp, http.StatusBadRequest)
449+ checkBodyHasErrorCodes(t, "invalid number of results requested", resp, v2.ErrorCodePaginationNumberInvalid)
450+
451+ // -----------------------------------
452+ // Case No. 7: n = 0 | n is set to max(0, min(defaultEntries, maxEntries))
453+ values = url.Values{
454+ "n": []string{"0"},
455+ }
456+
457+ catalogURL, err = env.builder.BuildCatalogURL(values)
458+ if err != nil {
459+ t.Fatalf("unexpected error building catalog url: %v", err)
460+ }
461+
462+ resp, err = http.Get(catalogURL)
463+ if err != nil {
464+ t.Fatalf("unexpected error issuing request: %v", err)
465+ }
466+ defer resp.Body.Close()
467+
468+ checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
469+
470+ dec = json.NewDecoder(resp.Body)
471+ if err = dec.Decode(&ctlg); err != nil {
472+ t.Fatalf("error decoding fetched manifest: %v", err)
473+ }
474+
475+ // it must be empty
476+ if len(ctlg.Repositories) != 0 {
477+ t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", 0, len(ctlg.Repositories))
478+ }
479+
480+ // -----------------------------------
481+ // Case No. 8: n = -1 | n is set to max(0, min(defaultEntries, maxEntries))
482+ values = url.Values{
483+ "n": []string{"-1"},
484+ }
485+
486+ catalogURL, err = env.builder.BuildCatalogURL(values)
487+ if err != nil {
488+ t.Fatalf("unexpected error building catalog url: %v", err)
489+ }
490+
491+ resp, err = http.Get(catalogURL)
492+ if err != nil {
493+ t.Fatalf("unexpected error issuing request: %v", err)
494+ }
495+ defer resp.Body.Close()
496+
497+ checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
498+
499+ dec = json.NewDecoder(resp.Body)
500+ if err = dec.Decode(&ctlg); err != nil {
501+ t.Fatalf("error decoding fetched manifest: %v", err)
502+ }
503+
504+ // it must match max entries
505+ if len(ctlg.Repositories) != maxEntries {
506+ t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", expectedRemainder, len(ctlg.Repositories))
507+ }
508+
509+ // -----------------------------------
510+ // Case No. 9: n = 5, max = 5, total catalog = 4
511+ values = url.Values{
512+ "n": []string{strconv.Itoa(maxEntries)},
513+ }
514+
515+ envWithLessImages := newTestEnv(t, false)
516+ for _, image := range allCatalog[0:(maxEntries - 1)] {
517+ createRepository(envWithLessImages, t, image, "sometag")
518+ }
519+
520+ catalogURL, err = envWithLessImages.builder.BuildCatalogURL(values)
521+ if err != nil {
522+ t.Fatalf("unexpected error building catalog url: %v", err)
523+ }
524+
525+ resp, err = http.Get(catalogURL)
526+ if err != nil {
527+ t.Fatalf("unexpected error issuing request: %v", err)
528+ }
529+ defer resp.Body.Close()
530+
531+ checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
532+
533+ dec = json.NewDecoder(resp.Body)
534+ if err = dec.Decode(&ctlg); err != nil {
535+ t.Fatalf("error decoding fetched manifest: %v", err)
536+ }
537+
538+ // it must match max entries
539+ if len(ctlg.Repositories) != maxEntries-1 {
540+ t.Fatalf("repositories returned unexpected entries (expected: %d, returned: %d)", maxEntries-1, len(ctlg.Repositories))
541 }
542 }
543
544@@ -207,7 +462,7 @@ func checkLink(t *testing.T, urlStr string, numEntries int, last string) url.Val
545 urlValues := linkURL.Query()
546
547 if urlValues.Get("n") != strconv.Itoa(numEntries) {
548- t.Fatalf("Catalog link entry size is incorrect")
549+ t.Fatalf("Catalog link entry size is incorrect (expected: %v, returned: %v)", urlValues.Get("n"), strconv.Itoa(numEntries))
550 }
551
552 if urlValues.Get("last") != last {
553@@ -2023,6 +2278,9 @@ func newTestEnvMirror(t *testing.T, deleteEnabled bool) *testEnv {
554 Proxy: configuration.Proxy{
555 RemoteURL: "http://example.com",
556 },
557+ Catalog: configuration.Catalog{
558+ MaxEntries: 5,
559+ },
560 }
561 config.Compatibility.Schema1.Enabled = true
562
563@@ -2039,6 +2297,9 @@ func newTestEnv(t *testing.T, deleteEnabled bool) *testEnv {
564 "enabled": false,
565 }},
566 },
567+ Catalog: configuration.Catalog{
568+ MaxEntries: 5,
569+ },
570 }
571
572 config.Compatibility.Schema1.Enabled = true
573@@ -2291,7 +2552,6 @@ func checkResponse(t *testing.T, msg string, resp *http.Response, expectedStatus
574 if resp.StatusCode != expectedStatus {
575 t.Logf("unexpected status %s: %v != %v", msg, resp.StatusCode, expectedStatus)
576 maybeDumpResponse(t, resp)
577-
578 t.FailNow()
579 }
580
581diff --git a/registry/handlers/catalog.go b/registry/handlers/catalog.go
582index eca98468..83ec0a9c 100644
583--- a/registry/handlers/catalog.go
584+++ b/registry/handlers/catalog.go
585@@ -9,11 +9,13 @@ import (
586 "strconv"
587
588 "github.com/docker/distribution/registry/api/errcode"
589+ v2 "github.com/docker/distribution/registry/api/v2"
590 "github.com/docker/distribution/registry/storage/driver"
591+
592 "github.com/gorilla/handlers"
593 )
594
595-const maximumReturnedEntries = 100
596+const defaultReturnedEntries = 100
597
598 func catalogDispatcher(ctx *Context, r *http.Request) http.Handler {
599 catalogHandler := &catalogHandler{
600@@ -38,29 +40,55 @@ func (ch *catalogHandler) GetCatalog(w http.ResponseWriter, r *http.Request) {
601
602 q := r.URL.Query()
603 lastEntry := q.Get("last")
604- maxEntries, err := strconv.Atoi(q.Get("n"))
605- if err != nil || maxEntries < 0 {
606- maxEntries = maximumReturnedEntries
607+
608+ entries := defaultReturnedEntries
609+ maximumConfiguredEntries := ch.App.Config.Catalog.MaxEntries
610+
611+ // parse n, if n unparseable, or negative assign it to defaultReturnedEntries
612+ if n := q.Get("n"); n != "" {
613+ parsedMax, err := strconv.Atoi(n)
614+ if err == nil {
615+ if parsedMax > maximumConfiguredEntries {
616+ ch.Errors = append(ch.Errors, v2.ErrorCodePaginationNumberInvalid.WithDetail(map[string]int{"n": parsedMax}))
617+ return
618+ } else if parsedMax >= 0 {
619+ entries = parsedMax
620+ }
621+ }
622 }
623
624- repos := make([]string, maxEntries)
625+ // then enforce entries to be between 0 & maximumConfiguredEntries
626+ // max(0, min(entries, maximumConfiguredEntries))
627+ if entries < 0 || entries > maximumConfiguredEntries {
628+ entries = maximumConfiguredEntries
629+ }
630
631- filled, err := ch.App.registry.Repositories(ch.Context, repos, lastEntry)
632- _, pathNotFound := err.(driver.PathNotFoundError)
633+ repos := make([]string, entries)
634+ filled := 0
635
636- if err == io.EOF || pathNotFound {
637+ // entries is guaranteed to be >= 0 and < maximumConfiguredEntries
638+ if entries == 0 {
639 moreEntries = false
640- } else if err != nil {
641- ch.Errors = append(ch.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
642- return
643+ } else {
644+ returnedRepositories, err := ch.App.registry.Repositories(ch.Context, repos, lastEntry)
645+ if err != nil {
646+ _, pathNotFound := err.(driver.PathNotFoundError)
647+ if err != io.EOF && !pathNotFound {
648+ ch.Errors = append(ch.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
649+ return
650+ }
651+ // err is either io.EOF or not PathNotFoundError
652+ moreEntries = false
653+ }
654+ filled = returnedRepositories
655 }
656
657 w.Header().Set("Content-Type", "application/json; charset=utf-8")
658
659 // Add a link header if there are more entries to retrieve
660 if moreEntries {
661- lastEntry = repos[len(repos)-1]
662- urlStr, err := createLinkEntry(r.URL.String(), maxEntries, lastEntry)
663+ lastEntry = repos[filled-1]
664+ urlStr, err := createLinkEntry(r.URL.String(), entries, lastEntry)
665 if err != nil {
666 ch.Errors = append(ch.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
667 return
668--
6692.40.0