Skip to content

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

Open
evilgensec wants to merge 2 commits intogoogle:mainfrom
evilgensec:fix/foreign-layer-url-ssrf
Open

remote: validate foreign layer URLs to prevent SSRF (fixes #2259)#2293
evilgensec wants to merge 2 commits intogoogle:mainfrom
evilgensec:fix/foreign-layer-url-ssrf

Conversation

@evilgensec
Copy link
Copy Markdown
Contributor

@evilgensec evilgensec commented May 6, 2026

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

1 participant