fix(proxy): clamp oversized n query param instead of returning 400

PR #4353 made MaxTags (default 1000) a hard ceiling on the `n` query
parameter — anything larger and the handler returns 400
PAGINATION_NUMBER_INVALID before the request ever reaches storage or
the proxy tag service. That broke clients like Renovate which use
n=10000 against pull-through caches. #4846 fixed a related 500 in
proxy mode but not this 400, so users reported the regression still
persisted.

The OCI distribution-spec describes pagination differently: a server
MAY return fewer than `n` results "when the total number of tags
attached to the repository is less than <int> or a Link header is
provided" — otherwise it MUST include `<int>` results. In other
words, the right answer for "client asked for more than we'll serve"
is `maxtags` results plus a Link header, not a rejection.
PAGINATION_NUMBER_INVALID isn't among the 14 error codes the spec
defines, either.

Drop the oversized-n rejection and clamp to MaxTags instead; the
existing Link-header path already handles continuation correctly.
Malformed (non-integer) and negative `n` values keep returning 400,
since the spec defines `n` as a non-negative integer and those
requests are genuinely invalid.

Verified end-to-end against registry-1.docker.io in proxy mode:
n=10000 now returns the tag list (or a clamped page with Link)
instead of 400. Also restores pre-3.1.0 behavior for Renovate-style
clients without needing proxy-specific logic.

Spec reference:
https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-tags

Signed-off-by: Milos Gajdos <milosthegajdos@gmail.com>
This commit is contained in:
Milos Gajdos committed 2026-04-18 16:53:53 -07:00
1 parent ca7d4de0ee
commit c6f552e5db
3 files changed
+68 -7

No files matched your search

+4 -2
View File
@@ -805,8 +805,10 @@ location of a proxy for the layer stored by the S3 storage driver.
## `tags`
The `tags` subsection provides configuration to limit the maximum number of tags
returned by the tags API endpoint. When a client requests the list of tags for
a repository, the registry will return at most `maxtags` tags.
returned in a single response from the tags API endpoint. When a client requests
more than `maxtags` via the `n` query parameter, the server returns `maxtags`
results along with a `Link` header so the client can paginate to retrieve the
rest.
```yaml
tags:
+57
View File
@@ -612,6 +612,63 @@ func TestTagsAPI(t *testing.T) {
}
}
// TestTagsAPIMaxTagsClamp verifies that the tags/list endpoint clamps an n
// query parameter greater than the server's configured MaxTags down to MaxTags
// rather than returning 400 — the OCI distribution-spec permits a server to
// return fewer than n results when a Link header is provided.
func TestTagsAPIMaxTagsClamp(t *testing.T) {
config := configuration.Configuration{
Storage: configuration.Storage{
"inmemory": configuration.Parameters{},
"maintenance": configuration.Parameters{"uploadpurging": map[any]any{"enabled": false}},
},
Catalog: configuration.Catalog{MaxEntries: 5},
Tags: configuration.Tags{MaxTags: 2},
}
config.HTTP.Headers = headerConfig
env := newTestEnvWithConfig(t, &config)
defer env.Shutdown()
imageName, err := reference.WithName("test")
if err != nil {
t.Fatalf("unable to parse reference: %v", err)
}
tags := []string{"2j2ar", "asj9e", "jyi7b", "kb0j5", "sb71y"}
for _, tag := range tags {
createRepository(env, t, imageName.Name(), tag)
}
tagsURL, err := env.builder.BuildTagsURL(imageName, url.Values{"n": []string{"100"}})
if err != nil {
t.Fatalf("unexpected error building tags URL: %v", err)
}
resp, err := http.Get(tagsURL)
if err != nil {
t.Fatalf("unexpected error issuing request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected response status code to be %d, got %d", http.StatusOK, resp.StatusCode)
}
var body tagsAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("unexpected error decoding response body: %v", err)
}
expected := tagsAPIResponse{Name: imageName.Name(), Tags: []string{"2j2ar", "asj9e"}}
if !reflect.DeepEqual(body, expected) {
t.Fatalf("expected response body to be:\n%+v\ngot:\n%+v", expected, body)
}
wantLink := `</v2/test/tags/list?last=asj9e&n=2>; rel="next"`
if got := resp.Header.Get("Link"); got != wantLink {
t.Fatalf("expected response Link header to be %q, got %q", wantLink, got)
}
}
func checkLink(t *testing.T, urlStr string, numEntries int, last string) url.Values {
re := regexp.MustCompile("<(/v2/_catalog.*)>; rel=\"next\"")
matches := re.FindStringSubmatch(urlStr)
+7 -5
View File
@@ -41,17 +41,19 @@ func (th *tagsHandler) GetTags(w http.ResponseWriter, r *http.Request) {
limit := -1
// parse n, if n unparseable, or negative assign it to defaultReturnedEntries
if n := q.Get("n"); n != "" {
if th.App.Config.Tags.MaxTags > 0 {
limit = th.App.Config.Tags.MaxTags
}
parsedMax, err := strconv.Atoi(n)
if err != nil || (limit > 0 && parsedMax > limit) || parsedMax < 0 {
if err != nil || parsedMax < 0 {
th.Errors = append(th.Errors, errcode.ErrorCodePaginationNumberInvalid.WithDetail(map[string]int{"n": parsedMax}))
return
}
limit = parsedMax
// Per the OCI distribution-spec, a server MAY return fewer than n
// results when a Link header is provided for continuation. Clamp to
// MaxTags instead of rejecting oversized requests.
if maxTags := th.App.Config.Tags.MaxTags; maxTags > 0 && limit > maxTags {
limit = maxTags
}
}
filled := make([]string, 0)