diff options
author | Narpat Mali <narpat.mali@windriver.com> | 2023-07-25 11:53:19 +0000 |
---|---|---|
committer | Bruce Ashfield <bruce.ashfield@gmail.com> | 2023-07-25 14:53:37 -0400 |
commit | af02908efda1580e77b3fdeed25b124a2b8d9482 (patch) | |
tree | 24fdc54ec2990bbf82da577f1c3431f47c25a750 | |
parent | b3b3dbc67504e8cd498d6db202ddcf5a9dd26a9d (diff) | |
download | meta-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.bb | 1 | ||||
-rw-r--r-- | recipes-containers/docker-distribution/files/0001-Fix-runaway-allocation-on-v2-_catalog.patch | 669 |
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" | |||
7 | SRC_URI = "git://github.com/docker/distribution.git;branch=release/2.8;name=distribution;destsuffix=git/src/github.com/docker/distribution;protocol=https \ | 7 | SRC_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 | ||
12 | PACKAGES =+ "docker-registry" | 13 | PACKAGES =+ "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 @@ | |||
1 | From 521ea3d973cb0c7089ebbcdd4ccadc34be941f54 Mon Sep 17 00:00:00 2001 | ||
2 | From: "Jose D. Gomez R" <jose.gomez@suse.com> | ||
3 | Date: Mon, 24 Apr 2023 18:52:27 +0200 | ||
4 | Subject: [PATCH] Fix runaway allocation on /v2/_catalog | ||
5 | MIME-Version: 1.0 | ||
6 | Content-Type: text/plain; charset=UTF-8 | ||
7 | Content-Transfer-Encoding: 8bit | ||
8 | |||
9 | Introduced a Catalog entry in the configuration struct. With it, | ||
10 | it's possible to control the maximum amount of entries returned | ||
11 | by /v2/catalog (`GetCatalog` in registry/handlers/catalog.go). | ||
12 | |||
13 | It's set to a default value of 1000. | ||
14 | |||
15 | `GetCatalog` returns 100 entries by default if no `n` is | ||
16 | provided. When provided it will be validated to be between `0` | ||
17 | and `MaxEntries` defined in Configuration. When `n` is outside | ||
18 | the aforementioned boundary, ErrorCodePaginationNumberInvalid is | ||
19 | returned. | ||
20 | |||
21 | `GetCatalog` now handles `n=0` gracefully with an empty response | ||
22 | as well. | ||
23 | |||
24 | Signed-off-by: José D. Gómez R. <1josegomezr@gmail.com> | ||
25 | Co-authored-by: Cory Snider <corhere@gmail.com> | ||
26 | |||
27 | CVE: CVE-2023-2253 | ||
28 | |||
29 | Upstream-Status: Backport [https://github.com/distribution/distribution/commit/521ea3d973cb0c7089ebbcdd4ccadc34be941f54] | ||
30 | |||
31 | Signed-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 | |||
41 | diff --git a/configuration/configuration.go b/configuration/configuration.go | ||
42 | index 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 | } | ||
84 | diff --git a/configuration/configuration_test.go b/configuration/configuration_test.go | ||
85 | index 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 | ||
106 | diff --git a/registry/api/v2/descriptors.go b/registry/api/v2/descriptors.go | ||
107 | index 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 | }, | ||
148 | diff --git a/registry/api/v2/errors.go b/registry/api/v2/errors.go | ||
149 | index 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 | ) | ||
166 | diff --git a/registry/handlers/api_test.go b/registry/handlers/api_test.go | ||
167 | index 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 | |||
581 | diff --git a/registry/handlers/catalog.go b/registry/handlers/catalog.go | ||
582 | index 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 | -- | ||
669 | 2.40.0 | ||