diff options
| -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 86fcd628..bca41397 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= "dc5b207fdd294c57dfef59017df60088b27d2668" | |||
| 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 | ||
