Skip to content

remote: validate foreign layer URLs to prevent SSRF (fixes #2259)#2293

Merged
Subserial merged 5 commits into
google:mainfrom
evilgensec:fix/foreign-layer-url-ssrf
May 14, 2026
Merged

remote: validate foreign layer URLs to prevent SSRF (fixes #2259)#2293
Subserial merged 5 commits into
google:mainfrom
evilgensec:fix/foreign-layer-url-ssrf

Conversation

@evilgensec

@evilgensec evilgensec commented May 6, 2026

Copy link
Copy Markdown
Contributor

Summary

OCI and Docker manifests may include a `urls` field in layer descriptors
specifying alternative sources for foreign layers. Without validation, a
malicious registry can set these URLs to private or link-local addresses
(e.g. `http://169.254.169.254/latest/meta-data/iam/security-credentials/\`),
causing the client to exfiltrate cloud credentials when pulling an image.

Attack scenario 1 — direct private IP in `urls`

```json
{
"mediaType": "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
"digest": "sha256:...",
"size": 1024,
"urls": ["http://169.254.169.254/latest/meta-data/iam/security-credentials/my-role"]
}
```

The client calls `Compressed()`, which iterates `d.URLs` and fetches each
URL via `fetchBlobURL`. On AWS/GCP/Azure the metadata service returns IAM
tokens.

Attack scenario 2 — redirect-based bypass (added in this update)

Initial URL validation checks only the URL literal. A public CDN under
attacker control can pass validation and then issue a redirect:

  1. `"urls": ["https://cdn.attacker.com/layer.tar.gz"]` — passes the IP check.
  2. `cdn.attacker.com` returns `HTTP 302 → http://169.254.169.254/credentials\`.
  3. Go's HTTP client follows the redirect transparently, leaking cloud creds.

Fix

  1. Add `validateForeignURL`: rejects non-HTTP(S) schemes and private/loopback
    IP literals. HTTP is only allowed when the registry itself uses HTTP
    (insecure mode), matching the precedent in `transport.validateRealmURL`.
  2. Add `fetchForeignBlobURL` on `*fetcher`: reuses the existing transport but
    sets a `CheckRedirect` hook that passes every redirect destination through
    `validateForeignURL`, closing the redirect-bypass path.
  3. `Compressed()` routes foreign layer fetches through `fetchForeignBlobURL`
    (not the shared `fetchBlobURL`) so both the initial URL and each redirect
    hop are validated.

Test plan

  • `TestValidateForeignURL`: unit tests covering loopback, link-local
    (169.254.169.254), RFC-1918, unspecified, disallowed schemes, insecure HTTP.
  • `TestPullingForeignLayerSSRF`: end-to-end test — manifest with a metadata
    URL is rejected before any request reaches 169.254.169.254.
  • `TestPullingForeignLayerSSRFViaRedirect` (new): two `httptest` servers —
    attacker server redirects to loopback victim; confirms `Compressed()` returns
    a "private or link-local" error before any data is returned.

Fixes #2259.

evilgensec added 2 commits May 6, 2026 17:36
Foreign layer descriptors in OCI/Docker manifests may carry arbitrary
URLs in the descriptor's "urls" field.  When the registry blob endpoint
returns 404, the client fetches these URLs directly with no validation,
allowing a malicious registry to point them at internal services (e.g.
the cloud instance-metadata endpoint 169.254.169.254).

Add validateForeignURL, which applies the same scheme and private-IP
checks as transport.validateRealmURL to every foreign layer URL before
making a network request.  HTTP is only permitted when the registry
itself was reached over HTTP (insecure=true).  DNS-based SSRF remains
out of scope, consistent with the design decision in validateRealmURL.

Fixes google#2259.
The previous fix validates each foreign layer URL with validateForeignURL
before adding it to the fetch list.  However, http.Client follows
redirects by default: an attacker can host a foreign layer URL on a
public domain that passes the initial check, then redirect the client to
http://169.254.169.254/... (AWS/GCP instance-metadata), leaking cloud
credentials.

Add fetchForeignBlobURL (on *fetcher) that reuses the existing transport
but sets a CheckRedirect hook calling validateForeignURL on every redirect
hop.  Compressed() now routes foreign layer fetches through this method
instead of the shared fetchBlobURL so that the validation happens at both
the initial URL and each redirect destination.

New tests:
- TestPullingForeignLayerSSRFViaRedirect: attacker httptest server issues
  a 302 to a loopback victim; confirms Compressed() returns an error
  before any credentials are returned.

@Subserial Subserial 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.

Looks good, but needs some code cleanup before merge.

Comment thread pkg/v1/remote/image.go Outdated
Comment thread pkg/v1/remote/image.go Outdated
Move validateForeignURL and fetchForeignBlobURL (plus their tests) next
to the fetcher receiver, trim verbose function comments, and rework
Compressed() to call fetchBlobURL on the single registry URL with
foreign URLs collected and iterated separately.
@codecov-commenter

codecov-commenter commented May 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.83333% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.75%. Comparing base (78bdf1b) to head (5cfd0c2).

Files with missing lines Patch % Lines
pkg/v1/remote/fetcher.go 62.50% 7 Missing and 5 partials ⚠️
pkg/v1/remote/image.go 87.50% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2293      +/-   ##
==========================================
+ Coverage   56.73%   56.75%   +0.02%     
==========================================
  Files         165      165              
  Lines       11259    11299      +40     
==========================================
+ Hits         6388     6413      +25     
- Misses       4112     4121       +9     
- Partials      759      765       +6     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@evilgensec evilgensec requested a review from Subserial May 14, 2026 18:25
@Subserial Subserial merged commit 6dad820 into google:main May 14, 2026
17 checks passed
Subserial pushed a commit to Subserial/go-containerregistry that referenced this pull request May 15, 2026
… (google#2293)

* remote: validate foreign layer URLs to prevent SSRF

Foreign layer descriptors in OCI/Docker manifests may carry arbitrary
URLs in the descriptor's "urls" field.  When the registry blob endpoint
returns 404, the client fetches these URLs directly with no validation,
allowing a malicious registry to point them at internal services (e.g.
the cloud instance-metadata endpoint 169.254.169.254).

Add validateForeignURL, which applies the same scheme and private-IP
checks as transport.validateRealmURL to every foreign layer URL before
making a network request.  HTTP is only permitted when the registry
itself was reached over HTTP (insecure=true).  DNS-based SSRF remains
out of scope, consistent with the design decision in validateRealmURL.

Fixes google#2259.

* remote: block SSRF redirect bypass for foreign layer URLs

The previous fix validates each foreign layer URL with validateForeignURL
before adding it to the fetch list.  However, http.Client follows
redirects by default: an attacker can host a foreign layer URL on a
public domain that passes the initial check, then redirect the client to
http://169.254.169.254/... (AWS/GCP instance-metadata), leaking cloud
credentials.

Add fetchForeignBlobURL (on *fetcher) that reuses the existing transport
but sets a CheckRedirect hook calling validateForeignURL on every redirect
hop.  Compressed() now routes foreign layer fetches through this method
instead of the shared fetchBlobURL so that the validation happens at both
the initial URL and each redirect destination.

New tests:
- TestPullingForeignLayerSSRFViaRedirect: attacker httptest server issues
  a 302 to a loopback victim; confirms Compressed() returns an error
  before any credentials are returned.

* remote: move foreign layer SSRF helpers to fetcher.go per review

Move validateForeignURL and fetchForeignBlobURL (plus their tests) next
to the fetcher receiver, trim verbose function comments, and rework
Compressed() to call fetchBlobURL on the single registry URL with
foreign URLs collected and iterated separately.

* remote: rename unused params to satisfy revive linter
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