Skip to content

fix(distribution): validate token realm on pull and re-challenge paths - #1038

Merged
ilopezluna merged 2 commits into
mainfrom
fix/ssrf-token-realm-pull-path
Aug 11, 2026
Merged

fix(distribution): validate token realm on pull and re-challenge paths#1038
ilopezluna merged 2 commits into
mainfrom
fix/ssrf-token-realm-pull-path

Conversation

@ilopezluna

@ilopezluna ilopezluna commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

resolveAndValidateRealm (the SSRF guard added for CVE-2026-33990 / GHSA-x2f5-332j-9xwq) is only reached from the hand-rolled Exchange(), which is used exclusively by the push flow. Two authorizers are built with containerd's default docker.NewDockerAuthorizer and never see that guard:

  • the pull path — remote.Image()createResolver();
  • the push re-challenge authorizer in createResolverWithPushScope() (also reused by every fallback in that function).

containerd follows the realm URL from a 401 WWW-Authenticate: Bearer challenge with its own auth client and no validation, so a malicious registry can point the realm at an internal address (127.0.0.1, 169.254.169.254, RFC-1918, …) and make Model Runner fetch it — SSRF, and the same class of bug the original CVE fixed only for Exchange().

How

Guard the HTTP client containerd uses for token fetches only. In this containerd version the authorizer's client (set via docker.WithAuthClient) is used solely by auth.FetchToken/FetchTokenWithOAuth to contact the realm, so a dialer-level check there covers every authorizer-building path without touching the data-plane connection to the registry the user explicitly targeted (local/insecure registries keep working).

  • newGuardedAuthClient builds that client; its DialContext validates the resolved IP against the existing private/loopback/link-local blocklist and dials that exact address, so DNS rebinding can't slip an internal IP past the check (TOCTOU-safe).
  • Wired into both createResolver and the createResolverWithPushScope re-challenge authorizer via docker.WithAuthClient.
  • Extracted resolveAndValidateHost from resolveAndValidateRealm so the realm check and the new dialer share one blocklist — no duplicated CIDRs.

Tests

  • TestCreateModelSSRF_RealmNotFollowedToInternalService — endpoint-level end to end: drives POST /models/create through the manager, distribution client, and containerd resolver against a malicious registry that advertises a loopback token realm. Asserts the registry is contacted (the pull path really runs) but the realm is never followed, so the internal service receives nothing. Verified to fail without the guard.
  • TestPullSSRF_RealmNotFollowedToInternalService — same scenario one layer down at remote.Image, the exact path the original fix missed.
  • TestNewGuardedAuthClientBlocksLoopback and TestResolveAndValidateHost — unit coverage for the guarded client and the shared validator.
  • Existing Exchange SSRF tests still pass.

Notes

  • Draft: opening for review of the approach before finalizing.
  • Not addressed here (separate, weaker vector): rangeTransport follows blob-download redirects on the data-plane without validating the destination. Happy to follow up if we want to close that too.
Fix an SSRF where a malicious registry could make Model Runner follow an unvalidated bearer-token realm on the model pull path.

CVE-2026-33990 was fixed only in the hand-rolled Exchange() used by the
push flow. The pull path (remote.Image -> createResolver) and the push
re-challenge authorizer build containerd's default authorizer, which
follows the realm URL from a 401 WWW-Authenticate challenge without
validating it, so a malicious registry can drive a token fetch at an
internal address and turn Model Runner into an SSRF proxy.

Guard the HTTP client containerd uses for token fetches via
docker.WithAuthClient. Its dialer validates the resolved IP against the
private/loopback/link-local blocklist and dials that exact address,
covering the pull, push, and fallback resolvers uniformly instead of
only the hand-rolled Exchange().
Drive the pull from POST /models/create through the manager, distribution
client, and containerd resolver against a malicious registry that
advertises a loopback token realm. Asserts the registry is contacted but
the realm is never followed, so the internal service receives nothing.
Fails without the WithAuthClient guard.
@ilopezluna
ilopezluna marked this pull request as ready for review August 11, 2026 13:31
@ilopezluna
ilopezluna requested a review from a team August 11, 2026 13:31

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've left some high level feedback:

  • newGuardedAuthClient only preserves settings when the base is an http.Transport and silently falls back to cloning http.DefaultTransport for other RoundTrippers, which can drop custom behavior (e.g., proxies, timeouts, middleware); consider either explicitly documenting this or handling non-Transport bases by wrapping them instead of replacing them.
  • resolveAndValidateHost is now a generic validator used by the guarded auth client, but the error messages are still realm-specific (e.g., "realm URL"), which may be confusing when surfaced from other callers; consider rewording or adding caller-specific context when returning these errors.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- newGuardedAuthClient only preserves settings when the base is an *http.Transport* and silently falls back to cloning http.DefaultTransport for other RoundTrippers, which can drop custom behavior (e.g., proxies, timeouts, middleware); consider either explicitly documenting this or handling non-Transport bases by wrapping them instead of replacing them.
- resolveAndValidateHost is now a generic validator used by the guarded auth client, but the error messages are still realm-specific (e.g., "realm URL"), which may be confusing when surfaced from other callers; consider rewording or adding caller-specific context when returning these errors.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@ilopezluna
ilopezluna merged commit 60c8865 into main Aug 11, 2026
14 checks passed
@ilopezluna
ilopezluna deleted the fix/ssrf-token-realm-pull-path branch August 11, 2026 13:56
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.

3 participants