Skip to content

fix(referrer): apply default page size when request has no pagination#3023

Merged
migmartri merged 1 commit intochainloop-dev:mainfrom
waveywaves:fix/referrer-unbounded-references
Apr 15, 2026
Merged

fix(referrer): apply default page size when request has no pagination#3023
migmartri merged 1 commit intochainloop-dev:mainfrom
waveywaves:fix/referrer-unbounded-references

Conversation

@waveywaves
Copy link
Copy Markdown
Contributor

Summary

Fixes #2890.

The ReferrerService.DiscoverPrivate and DiscoverPublicShared endpoints return an unbounded number of direct references (SBOMs, SARIF reports, …) when a client sends an empty pagination field. A container image that is attested repeatedly accumulates these references over time, so a single call can return hundreds of items.

Root cause

referrerPaginationOptsFromProto in app/controlplane/internal/service/referrer.go returned (nil, nil) whenever the request's pagination was nil, and the data layer treated nil *CursorOptions as "skip pagination entirely":

```go
// app/controlplane/pkg/data/referrer.go (unchanged)
if p != nil {
q = q.Limit(p.Limit + 1)
// …
}
```

This was intentional "backward compatibility" per the helper's comment, but it is the bug the issue describes.

Fix

Always return *pagination.CursorOptions with the existing defaultReferrerPageSize = 20:

  • nil request → default page size
  • empty request → default page size
  • Limit: 0 → default page size
  • explicit Limit: N → honored as-is (still capped at 100 by the proto validation rule)

The biz-layer traversal is still bounded by maxTraverseLevels = 1 (app/controlplane/pkg/data/referrer.go:196), so level-0 refs are now bounded and nothing deeper needs a change.

Client impact

  • CLI: unaffected. app/cli/cmd/referrer_discover.go already sends a non-nil pagination request with DefaultLimit: 20.
  • Raw gRPC/REST clients: the default response now contains at most 20 direct references. Clients that want more must pass pagination.limit explicitly (up to 100) or page through with pagination.cursor. This is a behavior change but it is the fix the issue asks for.

Test plan

  • go test ./internal/service/... -run TestReferrerPaginationOptsFromProto -v — 5 subtests covering nil / empty / zero / explicit / boundary limit
  • go test ./internal/service/... — full service package passes
  • go vet ./internal/service/... clean
  • gofmt -l clean
  • Commits GPG-signed and DCO signed-off

The ReferrerService.DiscoverPrivate and DiscoverPublicShared endpoints
were unbounded when clients sent an empty request. The helper
referrerPaginationOptsFromProto returned (nil, nil) for a nil pagination
field, and the data layer interpreted nil CursorOptions as "return every
reference". A container image that had been attested many times would
accumulate hundreds of SBOMs/SARIF referrers, all returned in a single
response.

Apply the existing defaultReferrerPageSize (20) whenever no pagination
is supplied or the limit is zero. Clients that already send pagination
are unaffected. The CLI already sends a non-nil pagination request with
a default limit of 20, so no client-side change is needed.

Fixes: chainloop-dev#2890

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com>
@waveywaves waveywaves force-pushed the fix/referrer-unbounded-references branch from 99b794c to d5df1d7 Compare April 14, 2026 00:44
@waveywaves waveywaves marked this pull request as ready for review April 14, 2026 01:03
@waveywaves
Copy link
Copy Markdown
Contributor Author

@jiparis @migmartri Ready for review. Fixes #2890 (referrer Discover* endpoints returning unbounded references).

Self-reviewed and hardened after feedback — the PR now addresses the bug at two layers for defense in depth:

  • Service layer (internal/service/referrer.go): referrerPaginationOptsFromProto now always returns non-nil *pagination.CursorOptions with defaultReferrerPageSize = 20 when the client omits pagination. Explicit limits still honored (proto max 100).
  • Data layer (pkg/data/referrer.go): doGet now falls back to pagination.DefaultCursorLimit (10) if the caller passes nil, so future biz-layer callers (tests, internal jobs) can't accidentally re-introduce the unbounded query.

Why two defaults (20 vs 10)? The service-layer 20 is a UX choice for this RPC; the data-layer 10 is a safety backstop that shouldn't encode API-surface knowledge. Commented inline to explain.

Tests: 7 table-driven cases on the helper covering nil / empty / zero / negative / 1 / 50 / 100 limits. All pass. go vet and gofmt clean. Commits GPG-signed and DCO signed-off. CLI unaffected (already sends DefaultLimit: 20).

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Copy link
Copy Markdown
Member

@migmartri migmartri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution, my understanding is that I fixed this issue in this PR #2896 but forgot to link it to the issue (sorry)

I might have missed something though, what issue is exactly this fixing that the original PR didn't?

@waveywaves
Copy link
Copy Markdown
Contributor Author

Thanks @migmartri#2896 absolutely did add the cursor-pagination plumbing (proto, biz, data, CLI), and the CLI path is bounded by the default --limit 20. The gap I ran into is in the backward-compat path that #2896's description describes as "existing clients that omit pagination transparently receive the first page":

Post-#2896 code on main:

```go
// app/controlplane/internal/service/referrer.go
func referrerPaginationOptsFromProto(p *pb.CursorPaginationRequest) (*pagination.CursorOptions, error) {
if p == nil {
return nil, nil // <-- (1) returns nil for "backward compat"
}

}

// app/controlplane/pkg/data/referrer.go
if p != nil {
q = q.Limit(p.Limit + 1) // <-- (2) only applies a limit when p != nil
if p.Cursor != nil { … }
}
```

A caller that omits pagination entirely (the intended "existing client" path) hits p == nil at (1), the data layer at (2) skips .Limit(), and the query returns every direct reference. Reproducible via either the HTTP gateway (GET /discover/{digest} with no query params) or a raw gRPC client that leaves the field unset. The CLI is unaffected because app/cli/cmd/referrer_discover.go already forces DefaultLimit: 20, so it never triggers this path — which is probably why it wasn't caught in manual testing.

This PR closes that gap at two layers for defense in depth:

  • Service: referrerPaginationOptsFromProto now always returns non-nil opts, defaulting to defaultReferrerPageSize = 20 when the client omits pagination (matching the intent documented in feat(referrer): add cursor-based pagination to discover endpoints #2896).
  • Data: doGet falls back to pagination.DefaultCursorLimit = 10 if it ever receives a nil *CursorOptions, so future biz-layer callers (integration tests, internal jobs) can't silently re-introduce the unbounded query.

Let me know if you'd rather have only one layer changed, or want the default consolidated into pagination.NewCursor instead — happy to restructure.

@migmartri
Copy link
Copy Markdown
Member

Thanks!

@migmartri migmartri merged commit 45fa952 into chainloop-dev:main Apr 15, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Referrer discover API returns unbounded references

2 participants