updated vendor

This commit is contained in:
2026-06-16 08:02:19 +02:00
parent 2f7f99d3f0
commit 77299d0c64
1283 changed files with 67302 additions and 208958 deletions
+1 -2
View File
@@ -25,7 +25,6 @@ import (
"github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/cli/config/types"
"github.com/google/go-containerregistry/pkg/name"
"github.com/mitchellh/go-homedir"
)
// Resource represents a registry or repository that can be authenticated against.
@@ -95,7 +94,7 @@ func (dk *defaultKeychain) ResolveContext(_ context.Context, target Resource) (A
// First, check $HOME/.docker/config.json
foundDockerConfig := false
home, err := homedir.Dir()
home, err := os.UserHomeDir()
if err == nil {
foundDockerConfig = fileExists(filepath.Join(home, ".docker/config.json"))
}
+1
View File
@@ -44,6 +44,7 @@ func Export(img v1.Image, w io.Writer) error {
if err != nil {
return err
}
defer rc.Close()
_, err = io.Copy(w, rc)
return err
}
+2 -2
View File
@@ -24,8 +24,8 @@ import (
"strings"
)
// Detect more complex forms of local references.
var reLocal = regexp.MustCompile(`.*\.local(?:host)?(?::\d{1,5})?$`)
// Detect more complex forms of localhost references.
var reLocal = regexp.MustCompile(`.*\.localhost(?::\d{1,5})?$`)
// Detect the loopback IP (127.0.0.1)
var reLoopback = regexp.MustCompile(regexp.QuoteMeta("127.0.0.1"))
+2
View File
@@ -29,6 +29,7 @@ type Manifest struct {
Layers []Descriptor `json:"layers"`
Annotations map[string]string `json:"annotations,omitempty"`
Subject *Descriptor `json:"subject,omitempty"`
ArtifactType string `json:"artifactType,omitempty"`
}
// IndexManifest represents an OCI image index in a structured way.
@@ -38,6 +39,7 @@ type IndexManifest struct {
Manifests []Descriptor `json:"manifests"`
Annotations map[string]string `json:"annotations,omitempty"`
Subject *Descriptor `json:"subject,omitempty"`
ArtifactType string `json:"artifactType,omitempty"`
}
// Descriptor holds a reference from the manifest to one of its constituent elements.
+87 -54
View File
@@ -52,6 +52,16 @@ func (i *image) MediaType() (types.MediaType, error) {
return i.base.MediaType()
}
// isImageConfig reports whether the media type is a Docker or OCI image config.
func isImageConfig(mt types.MediaType) bool {
switch mt {
case types.DockerConfigJSON, types.OCIConfigJSON:
return true
default:
return false
}
}
func (i *image) compute() error {
i.Lock()
defer i.Unlock()
@@ -60,6 +70,24 @@ func (i *image) compute() error {
if i.computed {
return nil
}
m, err := i.base.Manifest()
if err != nil {
return err
}
manifest := m.DeepCopy()
diffIDMap := make(map[v1.Hash]v1.Layer)
digestMap := make(map[v1.Hash]v1.Layer)
// Determine effective config media type (user override takes precedence).
cfgMediaType := manifest.Config.MediaType
if i.configMediaType != nil {
cfgMediaType = *i.configMediaType
}
imageConfig := isImageConfig(cfgMediaType)
var configFile *v1.ConfigFile
if i.configFile != nil {
configFile = i.configFile
@@ -70,33 +98,32 @@ func (i *image) compute() error {
}
configFile = cf.DeepCopy()
}
diffIDs := configFile.RootFS.DiffIDs
history := configFile.History
diffIDMap := make(map[v1.Hash]v1.Layer)
digestMap := make(map[v1.Hash]v1.Layer)
// For image configs, update RootFS.DiffIDs and History from added layers.
// For artifacts, skip this: the config has no rootfs or history fields.
if imageConfig {
diffIDs := configFile.RootFS.DiffIDs
history := configFile.History
for _, add := range i.adds {
history = append(history, add.History)
if add.Layer != nil {
diffID, err := add.Layer.DiffID()
if err != nil {
return err
for _, add := range i.adds {
history = append(history, add.History)
if add.Layer != nil {
diffID, err := add.Layer.DiffID()
if err != nil {
return err
}
diffIDs = append(diffIDs, diffID)
diffIDMap[diffID] = add.Layer
}
diffIDs = append(diffIDs, diffID)
diffIDMap[diffID] = add.Layer
}
configFile.RootFS.DiffIDs = diffIDs
configFile.History = history
}
m, err := i.base.Manifest()
if err != nil {
return err
}
manifest := m.DeepCopy()
manifestLayers := manifest.Layers
for _, add := range i.adds {
if add.Layer == nil {
// Empty layers include only history in manifest.
continue
}
@@ -105,14 +132,12 @@ func (i *image) compute() error {
return err
}
// Fields in the addendum override the original descriptor.
if len(add.Annotations) != 0 {
desc.Annotations = add.Annotations
}
if len(add.URLs) != 0 {
desc.URLs = add.URLs
}
if add.MediaType != "" {
desc.MediaType = add.MediaType
}
@@ -120,42 +145,38 @@ func (i *image) compute() error {
manifestLayers = append(manifestLayers, *desc)
digestMap[desc.Digest] = add.Layer
}
configFile.RootFS.DiffIDs = diffIDs
configFile.History = history
manifest.Layers = manifestLayers
rcfg, err := json.Marshal(configFile)
if err != nil {
return err
}
d, sz, err := v1.SHA256(bytes.NewBuffer(rcfg))
if err != nil {
return err
}
manifest.Config.Digest = d
manifest.Config.Size = sz
// For image configs, re-marshal the config and update the manifest digest.
// For artifacts, preserve the original config blob as-is to avoid
// corrupting the digest via re-marshaling.
if imageConfig {
rcfg, err := json.Marshal(configFile)
if err != nil {
return err
}
d, sz, err := v1.SHA256(bytes.NewBuffer(rcfg))
if err != nil {
return err
}
manifest.Config.Digest = d
manifest.Config.Size = sz
// If Data was set in the base image, we need to update it in the mutated image.
if m.Config.Data != nil {
manifest.Config.Data = rcfg
if m.Config.Data != nil {
manifest.Config.Data = rcfg
}
}
// If the user wants to mutate the media type of the config
if i.configMediaType != nil {
manifest.Config.MediaType = *i.configMediaType
}
if i.mediaType != nil {
manifest.MediaType = *i.mediaType
}
if i.annotations != nil {
if manifest.Annotations == nil {
manifest.Annotations = map[string]string{}
}
for k, v := range i.annotations {
manifest.Annotations[k] = v
}
@@ -173,29 +194,34 @@ func (i *image) compute() error {
// Layers returns the ordered collection of filesystem layers that comprise this image.
// The order of the list is oldest/base layer first, and most-recent/top layer last.
func (i *image) Layers() ([]v1.Layer, error) {
if err := i.compute(); errors.Is(err, stream.ErrNotComputed) {
// Image contains a streamable layer which has not yet been
// consumed. Just return the layers we have in case the caller
// is going to consume the layers.
if err := i.compute(); errors.Is(err, stream.ErrNotComputed) || (i.manifest != nil && !isImageConfig(i.manifest.Config.MediaType)) {
// Stream not yet consumed, or non-image OCI artifact (RootFS.DiffIDs
// is empty so partial.DiffIDs returns nothing). Fall back to the base
// layers plus any added layers.
layers, err := i.base.Layers()
if err != nil {
return nil, err
}
for _, add := range i.adds {
layers = append(layers, add.Layer)
if add.Layer != nil {
layers = append(layers, add.Layer)
}
}
return layers, nil
} else if err != nil {
return nil, err
}
diffIDs, err := partial.DiffIDs(i)
if err != nil {
return nil, err
}
ls := make([]v1.Layer, 0, len(diffIDs))
for _, h := range diffIDs {
l, err := i.LayerByDiffID(h)
// Walk manifest layer descriptors by digest rather than rootfs diff
// IDs. Two layers can legitimately share a diff ID — same uncompressed
// content, different compression — and produce distinct digests. The
// manifest preserves the per-occurrence digest; LayerByDiffID does not,
// which previously caused duplicate-diff-ID layers to collapse to a
// single entry in the returned slice and break blob upload for
// downstream pushers (see #2034).
ls := make([]v1.Layer, 0, len(i.manifest.Layers))
for _, desc := range i.manifest.Layers {
l, err := i.LayerByDigest(desc.Digest)
if err != nil {
return nil, err
}
@@ -220,11 +246,18 @@ func (i *image) ConfigFile() (*v1.ConfigFile, error) {
return i.configFile.DeepCopy(), nil
}
// RawConfigFile returns the serialized bytes of ConfigFile()
// RawConfigFile returns the serialized bytes of ConfigFile().
// For non-image OCI artifacts, returns the original raw config to preserve
// the config blob digest.
func (i *image) RawConfigFile() ([]byte, error) {
if err := i.compute(); err != nil {
return nil, err
}
// If the manifest config is not a standard image config, return the
// original raw bytes to avoid corrupting the digest via re-marshaling.
if i.manifest != nil && !isImageConfig(i.manifest.Config.MediaType) {
return i.base.RawConfigFile()
}
return json.Marshal(i.configFile)
}
+73 -72
View File
@@ -277,90 +277,91 @@ func extract(img v1.Image, w io.Writer) error {
// whiteout layers more efficient, since we can just keep track of the removed
// files as we see .wh. layers and ignore those in previous layers.
for i := len(layers) - 1; i >= 0; i-- {
layer := layers[i]
layerReader, err := layer.Uncompressed()
if err != nil {
return fmt.Errorf("reading layer contents: %w", err)
if err := extractLayer(tarWriter, fileMap, layers[i]); err != nil {
return err
}
defer layerReader.Close()
tarReader := tar.NewReader(layerReader)
for {
header, err := tarReader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("reading tar: %w", err)
}
}
return nil
}
// Some tools prepend everything with "./", so if we don't Clean the
// name, we may have duplicate entries, which angers tar-split.
header.Name = filepath.Clean(header.Name)
func extractLayer(tarWriter *tar.Writer, fileMap map[string]bool, layer v1.Layer) error {
layerReader, err := layer.Uncompressed()
if err != nil {
return fmt.Errorf("reading layer contents: %w", err)
}
defer layerReader.Close()
// Normalize absolute paths to relative to prevent writing outside
// the extraction root (Zip Slip / CVE-2018-15664 class).
// Many OCI tools emit absolute paths; stripping the leading slash
// preserves the entry while removing the danger.
if filepath.IsAbs(header.Name) {
header.Name = strings.TrimLeft(header.Name, "/")
}
// After normalization, reject any remaining path traversal.
if strings.HasPrefix(header.Name, "..") {
continue
}
tarReader := tar.NewReader(layerReader)
for {
header, err := tarReader.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("reading tar: %w", err)
}
// Reject symlinks and hardlinks that point outside the extraction
// root. An attacker can create a symlink to /etc and then write
// files through it in a subsequent layer entry.
if header.Typeflag == tar.TypeSymlink || header.Typeflag == tar.TypeLink {
linkTarget := filepath.Clean(header.Linkname)
if strings.HasPrefix(linkTarget, "..") || filepath.IsAbs(linkTarget) {
// Some tools prepend everything with "./", so if we don't Clean the
// name, we may have duplicate entries, which angers tar-split.
header.Name = filepath.Clean(header.Name)
// Reject relative symlinks and hardlinks whose targets escape the
// image rootfs. Relative targets are resolved against the symlink's
// own directory: if the clean result starts with ".." the link would
// leave the rootfs. Relative symlinks that stay within the rootfs
// (common for glibc, C toolchains, etc.) are preserved unchanged.
// Absolute targets are left as-is; see #2238 for ongoing discussion
// on whether they should be pruned.
if header.Typeflag == tar.TypeSymlink || header.Typeflag == tar.TypeLink {
if !filepath.IsAbs(header.Linkname) {
resolved := filepath.Clean(filepath.Join(filepath.Dir(header.Name), header.Linkname)) //nolint:gosec // G305: path is only used for validation, not file I/O
if strings.HasPrefix(resolved, "..") {
continue
}
}
}
// force PAX format to remove Name/Linkname length limit of 100 characters
// required by USTAR and to not depend on internal tar package guess which
// prefers USTAR over PAX
header.Format = tar.FormatPAX
// force PAX format to remove Name/Linkname length limit of 100 characters
// required by USTAR and to not depend on internal tar package guess which
// prefers USTAR over PAX
header.Format = tar.FormatPAX
basename := filepath.Base(header.Name)
dirname := filepath.Dir(header.Name)
tombstone := strings.HasPrefix(basename, whiteoutPrefix)
if tombstone {
basename = basename[len(whiteoutPrefix):]
basename := filepath.Base(header.Name)
dirname := filepath.Dir(header.Name)
tombstone := strings.HasPrefix(basename, whiteoutPrefix)
if tombstone {
basename = basename[len(whiteoutPrefix):]
}
// check if we have seen value before
// if we're checking a directory, don't filepath.Join names
var name string
if header.Typeflag == tar.TypeDir {
name = header.Name
} else {
name = filepath.Join(dirname, basename)
}
if _, ok := fileMap[name]; ok && !tombstone {
continue
}
// check for a whited out parent directory
if inWhiteoutDir(fileMap, name) {
continue
}
// mark file as handled. non-directory implicitly tombstones
// any entries with a matching (or child) name
fileMap[name] = tombstone || (header.Typeflag != tar.TypeDir)
if !tombstone {
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
// check if we have seen value before
// if we're checking a directory, don't filepath.Join names
var name string
if header.Typeflag == tar.TypeDir {
name = header.Name
} else {
name = filepath.Join(dirname, basename)
}
if _, ok := fileMap[name]; ok && !tombstone {
continue
}
// check for a whited out parent directory
if inWhiteoutDir(fileMap, name) {
continue
}
// mark file as handled. non-directory implicitly tombstones
// any entries with a matching (or child) name
fileMap[name] = tombstone || (header.Typeflag != tar.TypeDir)
if !tombstone {
if err := tarWriter.WriteHeader(header); err != nil {
if header.Size > 0 {
if _, err := io.CopyN(tarWriter, tarReader, header.Size); err != nil {
return err
}
if header.Size > 0 {
if _, err := io.CopyN(tarWriter, tarReader, header.Size); err != nil {
return err
}
}
}
}
}
+10 -3
View File
@@ -337,8 +337,12 @@ func Descriptor(d Describable) (*v1.Descriptor, error) {
mf, _ := Manifest(wrm)
// Failing to parse as a manifest should just be ignored.
// The manifest might not be valid, and that's okay.
if mf != nil && !mf.Config.MediaType.IsConfig() {
desc.ArtifactType = string(mf.Config.MediaType)
if mf != nil {
if mf.ArtifactType != "" {
desc.ArtifactType = mf.ArtifactType
} else {
desc.ArtifactType = string(mf.Config.MediaType)
}
}
}
}
@@ -429,7 +433,10 @@ func ArtifactType(w WithManifest) (string, error) {
mf, _ := w.Manifest()
// Failing to parse as a manifest should just be ignored.
// The manifest might not be valid, and that's okay.
if mf != nil && !mf.Config.MediaType.IsConfig() {
if mf != nil {
if mf.ArtifactType != "" {
return mf.ArtifactType, nil
}
return string(mf.Config.MediaType), nil
}
return "", nil
+122 -6
View File
@@ -19,6 +19,7 @@ import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
@@ -40,8 +41,9 @@ const (
// fetcher implements methods for reading from a registry.
type fetcher struct {
target resource
client *http.Client
target resource
client *http.Client
limiter *pullLimiter
}
func makeFetcher(ctx context.Context, target resource, o *options) (*fetcher, error) {
@@ -69,10 +71,40 @@ func makeFetcher(ctx context.Context, target resource, o *options) (*fetcher, er
}
return &fetcher{
target: target,
client: &http.Client{Transport: tr},
client: &http.Client{
Transport: tr,
CheckRedirect: checkRedirectSSRF,
},
limiter: o.limiter,
}, nil
}
// checkRedirectSSRF rejects HTTP redirects that cross from a public host to a
// private or link-local IP literal. This prevents a malicious registry from
// issuing a 302 to a cloud instance metadata service (e.g. 169.254.169.254)
// or another internal network address during blob or manifest downloads.
//
// Same-host redirects and redirects to non-IP hostnames (including DNS names
// that may resolve to private addresses) are allowed. The first redirect in
// the chain uses the original request URL as the "origin host" via
// req.Response.Request, falling back to req.URL when no prior response exists.
func checkRedirectSSRF(req *http.Request, via []*http.Request) error {
if len(via) == 0 || req.Response == nil {
return nil
}
origHost := via[0].URL.Hostname()
destHost := req.URL.Hostname()
if destHost == origHost {
return nil // same-host redirect is always allowed
}
if ip := net.ParseIP(destHost); ip != nil {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsPrivate() || ip.IsUnspecified() {
return fmt.Errorf("SSRF protection: redirect from %q to private/link-local host %q denied", origHost, destHost)
}
}
return nil
}
func (f *fetcher) Do(req *http.Request) (*http.Response, error) {
return f.client.Do(req)
}
@@ -162,11 +194,20 @@ func (f *fetcher) fetchManifest(ctx context.Context, ref name.Reference, accepta
}
var artifactType string
var annotations map[string]string
mf, _ := v1.ParseManifest(bytes.NewReader(manifest))
// Failing to parse as a manifest should just be ignored.
// The manifest might not be valid, and that's okay.
if mf != nil && !mf.Config.MediaType.IsConfig() {
artifactType = string(mf.Config.MediaType)
if mf != nil {
// Per the OCI distribution spec, artifactType on the descriptor is
// set to the manifest's artifactType if present, otherwise it falls
// back to the config descriptor's mediaType.
if mf.ArtifactType != "" {
artifactType = mf.ArtifactType
} else {
artifactType = string(mf.Config.MediaType)
}
annotations = mf.Annotations
}
// Do nothing for tags; I give up.
@@ -183,6 +224,7 @@ func (f *fetcher) fetchManifest(ctx context.Context, ref name.Reference, accepta
Size: size,
MediaType: mediaType,
ArtifactType: artifactType,
Annotations: annotations,
}
return manifest, &desc, nil
@@ -247,18 +289,30 @@ func (f *fetcher) headManifest(ctx context.Context, ref name.Reference, acceptab
func (f *fetcher) fetchBlob(ctx context.Context, size int64, h v1.Hash) (io.ReadCloser, error) {
u := f.url("blobs", h.String())
return f.fetchBlobURL(ctx, u, size, h)
}
func (f *fetcher) fetchBlobURL(ctx context.Context, u url.URL, size int64, h v1.Hash) (io.ReadCloser, error) {
release, err := f.limiter.acquire(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
release()
return nil, err
}
resp, err := f.client.Do(req.WithContext(ctx))
if err != nil {
release()
return nil, redact.Error(err)
}
if err := transport.CheckError(resp, http.StatusOK); err != nil {
resp.Body.Close()
release()
return nil, err
}
@@ -269,11 +323,22 @@ func (f *fetcher) fetchBlob(ctx context.Context, size int64, h v1.Hash) (io.Read
if size == verify.SizeUnknown {
size = hsize
} else if hsize != size {
resp.Body.Close()
release()
return nil, fmt.Errorf("GET %s: Content-Length header %d does not match expected size %d", u.String(), hsize, size)
}
}
return verify.ReadCloser(resp.Body, size, h)
rc, err := verify.ReadCloser(resp.Body, size, h)
if err != nil {
resp.Body.Close()
release()
return nil, err
}
return &limitedReadCloser{
ReadCloser: rc,
release: release,
}, nil
}
func (f *fetcher) headBlob(ctx context.Context, h v1.Hash) (*http.Response, error) {
@@ -296,6 +361,57 @@ func (f *fetcher) headBlob(ctx context.Context, h v1.Hash) (*http.Response, erro
return resp, nil
}
// validateForeignURL rejects foreign layer URLs that use a disallowed scheme
// or resolve to a private / link-local IP address (SSRF protection). DNS-based
// SSRF is out of scope, matching transport.validateRealmURL.
func validateForeignURL(rawURL string, insecure bool) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("parsing foreign layer URL %q: %w", rawURL, err)
}
switch u.Scheme {
case "https":
case "http":
if !insecure {
return fmt.Errorf("foreign layer URL scheme %q not allowed for a secure registry; use https", u.Scheme)
}
default:
return fmt.Errorf("foreign layer URL scheme %q not allowed; must be https (or http for insecure registries)", u.Scheme)
}
host := u.Hostname()
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsPrivate() || ip.IsUnspecified() {
return fmt.Errorf("foreign layer URL host %q is a private or link-local address", host)
}
}
return nil
}
// fetchForeignBlobURL fetches a foreign-layer blob, validating every redirect
// destination through validateForeignURL (SSRF protection).
func (f *fetcher) fetchForeignBlobURL(ctx context.Context, u url.URL, size int64, h v1.Hash, insecure bool) (io.ReadCloser, error) {
safeClient := &http.Client{
Transport: f.client.Transport,
CheckRedirect: func(req *http.Request, _ []*http.Request) error {
return validateForeignURL(req.URL.String(), insecure)
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
resp, err := safeClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("GET %s: unexpected status %s", u.String(), resp.Status)
}
return verify.ReadCloser(resp.Body, size, h)
}
func (f *fetcher) blobExists(ctx context.Context, h v1.Hash) (bool, error) {
u := f.url("blobs", h.String())
req, err := http.NewRequest(http.MethodHead, u.String(), nil)
+19 -21
View File
@@ -18,7 +18,6 @@ import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"sync"
@@ -27,7 +26,6 @@ import (
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/partial"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
"github.com/google/go-containerregistry/pkg/v1/types"
)
@@ -172,7 +170,7 @@ func (rl *remoteImageLayer) Digest() (v1.Hash, error) {
// Compressed implements partial.CompressedLayer
func (rl *remoteImageLayer) Compressed() (io.ReadCloser, error) {
urls := []url.URL{rl.ri.fetcher.url("blobs", rl.digest.String())}
u := rl.ri.fetcher.url("blobs", rl.digest.String())
// Add alternative layer sources from URLs (usually none).
d, err := partial.BlobDescriptor(rl, rl.digest)
@@ -187,38 +185,38 @@ func (rl *remoteImageLayer) Compressed() (io.ReadCloser, error) {
// We don't want to log binary layers -- this can break terminals.
ctx := redact.NewContext(rl.ctx, "omitting binary blobs from logs")
for _, s := range d.URLs {
u, err := url.Parse(s)
if err != nil {
return nil, err
}
urls = append(urls, *u)
}
insecure := rl.ri.fetcher.target.Scheme() == "http"
// The lastErr for most pulls will be the same (the first error), but for
// foreign layers we'll want to surface the last one, since we try to pull
// from the registry first, which would often fail.
// TODO: Maybe we don't want to try pulling from the registry first?
var lastErr error
for _, u := range urls {
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
rc, err := rl.ri.fetcher.fetchBlobURL(ctx, u, d.Size, rl.digest)
if err == nil {
return rc, nil
}
lastErr = err
foreignURLs := make([]url.URL, 0, len(d.URLs))
for _, s := range d.URLs {
if err := validateForeignURL(s, insecure); err != nil {
return nil, err
}
fu, err := url.Parse(s)
if err != nil {
return nil, err
}
foreignURLs = append(foreignURLs, *fu)
}
resp, err := rl.ri.fetcher.Do(req.WithContext(ctx))
for _, fu := range foreignURLs {
rc, err := rl.ri.fetcher.fetchForeignBlobURL(ctx, fu, d.Size, rl.digest, insecure)
if err != nil {
lastErr = err
continue
}
if err := transport.CheckError(resp, http.StatusOK); err != nil {
resp.Body.Close()
lastErr = err
continue
}
return verify.ReadCloser(resp.Body, d.Size, rl.digest)
return rc, nil
}
return nil, lastErr
+6 -2
View File
@@ -225,8 +225,12 @@ func (r *remoteIndex) childDescriptor(child v1.Descriptor, platform v1.Platform)
mf, _ := v1.ParseManifest(bytes.NewReader(manifest))
// Failing to parse as a manifest should just be ignored.
// The manifest might not be valid, and that's okay.
if mf != nil && !mf.Config.MediaType.IsConfig() {
child.ArtifactType = string(mf.Config.MediaType)
if mf != nil {
if mf.ArtifactType != "" {
child.ArtifactType = mf.ArtifactType
} else {
child.ArtifactType = string(mf.Config.MediaType)
}
}
}
@@ -0,0 +1,182 @@
// Copyright 2014 Docker, Inc.
// Copyright 2021-2026 The Distribution contributors
// Copyright 2026 Google LLC All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package authchallenge
import (
"net/http"
"strings"
)
// Octet types from RFC 2616.
type octetType byte
var octetTypes [256]octetType
const (
isToken octetType = 1 << iota
isSpace
)
func init() {
// OCTET = <any 8-bit sequence of data>
// CHAR = <any US-ASCII character (octets 0 - 127)>
// CTL = <any US-ASCII control character (octets 0 - 31) and DEL (127)>
// CR = <US-ASCII CR, carriage return (13)>
// LF = <US-ASCII LF, linefeed (10)>
// SP = <US-ASCII SP, space (32)>
// HT = <US-ASCII HT, horizontal-tab (9)>
// <"> = <US-ASCII double-quote mark (34)>
// CRLF = CR LF
// LWS = [CRLF] 1*( SP | HT )
// TEXT = <any OCTET except CTLs, but including LWS>
// separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <">
// | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT
// token = 1*<any CHAR except CTLs or separators>
// qdtext = <any TEXT except <">>
for c := range 256 {
var t octetType
isCtl := c <= 31 || c == 127
isChar := 0 <= c && c <= 127
isSeparator := strings.ContainsRune(" \t\"(),/:;<=>?@[]\\{}", rune(c))
if strings.ContainsRune(" \t\r\n", rune(c)) {
t |= isSpace
}
if isChar && !isCtl && !isSeparator {
t |= isToken
}
octetTypes[c] = t
}
}
// Challenge carries information from a WWW-Authenticate response header.
// See RFC 2617.
type Challenge struct {
// Scheme is the auth-scheme according to RFC 2617
Scheme string
// Parameters are the auth-params according to RFC 2617
Parameters map[string]string
}
// ResponseChallenges returns a list of authorization challenges
// for the given http Response. Challenges are only checked if
// the response status code was a 401.
func ResponseChallenges(resp *http.Response) []Challenge {
if resp.StatusCode == http.StatusUnauthorized {
// Parse the WWW-Authenticate Header and store the challenges
// on this endpoint object.
return parseAuthHeader(resp.Header)
}
return nil
}
func parseAuthHeader(header http.Header) []Challenge {
challenges := []Challenge{}
for _, h := range header[http.CanonicalHeaderKey("WWW-Authenticate")] {
v, p := parseValueAndParams(h)
if v != "" {
challenges = append(challenges, Challenge{Scheme: v, Parameters: p})
}
}
return challenges
}
func parseValueAndParams(header string) (value string, params map[string]string) {
params = make(map[string]string)
value, s := expectToken(header)
if value == "" {
return
}
value = strings.ToLower(value)
s = "," + skipSpace(s)
for strings.HasPrefix(s, ",") {
var pkey string
pkey, s = expectToken(skipSpace(s[1:]))
if pkey == "" {
return
}
if !strings.HasPrefix(s, "=") {
return
}
var pvalue string
pvalue, s = expectTokenOrQuoted(s[1:])
if pvalue == "" {
return
}
pkey = strings.ToLower(pkey)
params[pkey] = pvalue
s = skipSpace(s)
}
return
}
func skipSpace(s string) (rest string) {
i := 0
for ; i < len(s); i++ {
if octetTypes[s[i]]&isSpace == 0 {
break
}
}
return s[i:]
}
func expectToken(s string) (token, rest string) {
i := 0
for ; i < len(s); i++ {
if octetTypes[s[i]]&isToken == 0 {
break
}
}
return s[:i], s[i:]
}
func expectTokenOrQuoted(s string) (value string, rest string) {
if !strings.HasPrefix(s, "\"") {
return expectToken(s)
}
s = s[1:]
for i := 0; i < len(s); i++ {
switch s[i] {
case '"':
return s[:i], s[i+1:]
case '\\':
p := make([]byte, len(s)-1)
j := copy(p, s[:i])
escape := true
for i = i + 1; i < len(s); i++ {
b := s[i]
switch {
case escape:
escape = false
p[j] = b
j++
case b == '\\':
escape = true
case b == '"':
return string(p[:j]), s[i+1:]
default:
p[j] = b
j++
}
}
return "", ""
}
}
return "", ""
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 Google LLC All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package remote
import (
"context"
"io"
"sync"
)
type pullLimiter struct {
tokens chan struct{}
}
func newPullLimiter(jobs int) *pullLimiter {
return &pullLimiter{
tokens: make(chan struct{}, jobs),
}
}
func (l *pullLimiter) acquire(ctx context.Context) (func(), error) {
if l == nil {
return func() {}, nil
}
select {
case l.tokens <- struct{}{}:
return func() { <-l.tokens }, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
type limitedReadCloser struct {
io.ReadCloser
release func()
once sync.Once
}
func (l *limitedReadCloser) Close() error {
err := l.ReadCloser.Close()
l.once.Do(l.release)
return err
}
@@ -45,6 +45,7 @@ type options struct {
retryBackoff Backoff
retryPredicate retry.Predicate
retryStatusCodes []int
limiter *pullLimiter
// Only these options can overwrite Reuse()d options.
platform v1.Platform
@@ -92,6 +93,7 @@ var fastBackoff = Backoff{
var defaultRetryStatusCodes = []int{
http.StatusRequestTimeout,
http.StatusTooManyRequests, // 429: OCI distribution-spec rate limit; TooManyRequestsErrorCode is already classified temporary in transport/error.go
http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
@@ -142,6 +144,7 @@ func makeOptions(opts ...Option) (*options, error) {
return nil, err
}
}
o.limiter = newPullLimiter(o.jobs)
switch {
case o.auth != nil && o.keychain != nil:
+6 -2
View File
@@ -156,7 +156,11 @@ func (p *Pusher) Upload(ctx context.Context, repo name.Repository, l v1.Layer) e
}
func (p *Pusher) Delete(ctx context.Context, ref name.Reference) error {
w, err := p.writer(ctx, ref.Context(), p.o)
// Use a transport scoped for delete. Requesting DeleteScope (which
// includes the "delete" action) allows registries that require an
// explicit delete permission—such as IBM Cloud Container Registry—to
// grant access.
client, err := makeDeleteClient(ctx, ref.Context(), p.o)
if err != nil {
return err
}
@@ -172,7 +176,7 @@ func (p *Pusher) Delete(ctx context.Context, ref name.Reference) error {
return err
}
resp, err := w.w.client.Do(req.WithContext(ctx))
resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return err
}
+1 -1
View File
@@ -67,7 +67,7 @@ func (f *fetcher) fetchReferrers(ctx context.Context, filter map[string]string,
var b []byte
if resp.StatusCode == http.StatusOK && resp.Header.Get("Content-Type") == string(types.OCIImageIndex) {
b, err = io.ReadAll(resp.Body)
b, err = io.ReadAll(io.LimitReader(resp.Body, manifestLimit))
if err != nil {
return nil, err
}
@@ -26,14 +26,17 @@ import (
"strings"
"sync"
authchallenge "github.com/docker/distribution/registry/client/auth/challenge"
"github.com/google/go-containerregistry/internal/redact"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/logs"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote/internal/authchallenge"
)
// maxTokenBodySize limits bearer token response body reads to prevent OOM
// when a token endpoint returns an unexpectedly large body.
const maxTokenBodySize = 64 * 1024 // 64 KiB
type Token struct {
Token string `json:"token"`
AccessToken string `json:"access_token,omitempty"`
@@ -83,6 +86,13 @@ func fromChallenge(reg name.Registry, auth authn.Authenticator, t http.RoundTrip
if !ok {
return nil, fmt.Errorf("malformed www-authenticate, missing realm: %v", pr.Parameters)
}
// Validate the realm URL before storing it. A malicious or compromised
// registry can supply a realm pointing at an internal service or cloud
// metadata endpoint (e.g. 169.254.169.254), causing SSRF when the client
// subsequently fetches a token.
if err := validateRealmURL(realm, reg.RegistryStr(), pr.Insecure); err != nil {
return nil, fmt.Errorf("invalid realm in www-authenticate: %w", err)
}
service := pr.Parameters["service"]
scheme := "https"
if pr.Insecure {
@@ -99,6 +109,56 @@ func fromChallenge(reg name.Registry, auth authn.Authenticator, t http.RoundTrip
}, nil
}
// realmRedirectCheck mimics the default http.Client redirect policy but also
// validates each redirect URL with validateRealmURL.
func realmRedirectCheck(registryHost string, insecure bool) func(*http.Request, []*http.Request) error {
return func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
if err := validateRealmURL(req.URL.String(), registryHost, insecure); err != nil {
return fmt.Errorf("refusing token-server redirect to %q: %w", req.URL, err)
}
return nil
}
}
// validateRealmURL returns an error if the realm URL uses a disallowed scheme
// or resolves to a private / link-local IP address. Realm URLs matching the
// registry host:port are always allowed. See #2258.
func validateRealmURL(realm, registryHost string, insecure bool) error {
u, err := url.Parse(realm)
if err != nil {
return fmt.Errorf("parsing realm %q: %w", realm, err)
}
switch u.Scheme {
case "https":
// always allowed
case "http":
if !insecure {
return fmt.Errorf("realm scheme %q not allowed for a secure registry; use https", u.Scheme)
}
default:
return fmt.Errorf("realm scheme %q not allowed; must be https (or http for insecure registries)", u.Scheme)
}
// Always allow realms matching the registry host:port.
if registryHost != "" && u.Host == registryHost {
return nil
}
// Reject IP literals that resolve to private or link-local ranges.
// This blocks direct references to RFC 1918 addresses, loopback, and
// link-local ranges including the cloud instance metadata service
// (169.254.169.254 / fd00:ec2::254). DNS-based SSRF is out of scope
// here; callers should apply network-level controls if needed.
host := u.Hostname()
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsPrivate() || ip.IsUnspecified() {
return fmt.Errorf("realm host %q is a private or link-local address", host)
}
}
return nil
}
type bearerTransport struct {
mx sync.RWMutex
// Wrapped by bearerTransport.
@@ -336,7 +396,8 @@ func (bt *bearerTransport) refreshOauth(ctx context.Context) ([]byte, error) {
v.Set("access_type", "offline")
}
client := http.Client{Transport: bt.inner}
allowInsecure := bt.scheme == "http"
client := http.Client{Transport: bt.inner, CheckRedirect: realmRedirectCheck(bt.registry.RegistryStr(), allowInsecure)}
req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(v.Encode()))
if err != nil {
return nil, err
@@ -359,7 +420,7 @@ func (bt *bearerTransport) refreshOauth(ctx context.Context) ([]byte, error) {
return nil, err
}
return io.ReadAll(resp.Body)
return io.ReadAll(io.LimitReader(resp.Body, maxTokenBodySize))
}
// https://docs.docker.com/registry/spec/auth/token/
@@ -373,7 +434,8 @@ func (bt *bearerTransport) refreshBasic(ctx context.Context) ([]byte, error) {
auth: bt.basic,
target: u.Host,
}
client := http.Client{Transport: b}
allowInsecure := bt.scheme == "http"
client := http.Client{Transport: b, CheckRedirect: realmRedirectCheck(bt.registry.RegistryStr(), allowInsecure)}
v := u.Query()
bt.mx.RLock()
@@ -403,5 +465,5 @@ func (bt *bearerTransport) refreshBasic(ctx context.Context) ([]byte, error) {
return nil, err
}
return io.ReadAll(resp.Body)
return io.ReadAll(io.LimitReader(resp.Body, maxTokenBodySize))
}
@@ -15,6 +15,7 @@
package transport
import (
"bytes"
"encoding/json"
"fmt"
"io"
@@ -112,6 +113,10 @@ type ErrorCode string
// The set of error conditions a registry may return:
// https://github.com/distribution/distribution/blob/aac2f6c8b7c5a6c60190848bab5cbeed2b5ba0a9/docs/spec/api.md#errors-2
// maxErrorBodySize limits HTTP error response body reads to prevent OOM when
// a registry returns an unexpectedly large error body.
const maxErrorBodySize = 64 * 1024 // 64 KiB
const (
BlobUnknownErrorCode ErrorCode = "BLOB_UNKNOWN"
BlobUploadInvalidErrorCode ErrorCode = "BLOB_UPLOAD_INVALID"
@@ -146,6 +151,7 @@ var temporaryErrorCodes = map[ErrorCode]struct{}{
var temporaryStatusCodes = map[int]struct{}{
http.StatusRequestTimeout: {},
http.StatusTooManyRequests: {}, // matches TooManyRequestsErrorCode in temporaryErrorCodes
http.StatusInternalServerError: {},
http.StatusBadGateway: {},
http.StatusServiceUnavailable: {},
@@ -161,7 +167,7 @@ func CheckError(resp *http.Response, codes ...int) error {
}
}
b, err := io.ReadAll(resp.Body)
b, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
if err != nil {
return err
}
@@ -185,11 +191,16 @@ func makeError(resp *http.Response, body []byte) *Error {
}
func retryError(resp *http.Response) error {
b, err := io.ReadAll(resp.Body)
b, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
if err != nil {
return err
}
// Restore the body so that a subsequent CheckError call (after the
// retry loop exhausts its retries) can still read and parse the
// structured registry error from the response.
resp.Body = io.NopCloser(bytes.NewReader(b))
rerr := makeError(resp, b)
rerr.temporary = true
return rerr
@@ -24,9 +24,9 @@ import (
"strings"
"time"
authchallenge "github.com/docker/distribution/registry/client/auth/challenge"
"github.com/google/go-containerregistry/pkg/logs"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote/internal/authchallenge"
)
// 300ms is the default fallback period for go's DNS dialer but we could make this configurable.
@@ -18,7 +18,9 @@ package transport
const (
PullScope string = "pull"
PushScope string = "push,pull"
// For now DELETE is PUSH, which is the read/write ACL.
DeleteScope string = PushScope
// DeleteScope requests "delete" in addition to push/pull so that
// registries requiring an explicit delete action (e.g. IBM Cloud
// Container Registry) grant the necessary access.
DeleteScope string = "push,pull,delete"
CatalogScope string = "catalog"
)
+55 -8
View File
@@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
@@ -66,6 +67,25 @@ type writer struct {
scopes []string
}
// makeDeleteClient returns an HTTP client whose token includes the "delete"
// action so that registries requiring an explicit delete permission grant
// access for manifest deletion.
func makeDeleteClient(ctx context.Context, repo name.Repository, o *options) (*http.Client, error) {
auth := o.auth
if o.keychain != nil {
kauth, err := authn.Resolve(ctx, o.keychain, repo)
if err != nil {
return nil, err
}
auth = kauth
}
tr, err := transport.NewWithContext(ctx, repo.Registry, auth, o.transport, []string{repo.Scope(transport.DeleteScope)})
if err != nil {
return nil, err
}
return &http.Client{Transport: tr}, nil
}
func makeWriter(ctx context.Context, repo name.Repository, ls []v1.Layer, o *options) (*writer, error) {
auth := o.auth
if o.keychain != nil {
@@ -148,7 +168,29 @@ func (w *writer) nextLocation(resp *http.Response) (string, error) {
// If the location header returned is just a url path, then fully qualify it.
// We cannot simply call w.url, since there might be an embedded query string.
return resp.Request.URL.ResolveReference(u).String(), nil
resolved := resp.Request.URL.ResolveReference(u)
// Reject Location headers that redirect to a DIFFERENT host that resolves to
// a private or link-local IP literal. A malicious or compromised registry can
// respond to a blob upload initiation (POST /v2/.../blobs/uploads/) with a
// crafted Location header pointing at an internal service, causing the client
// to send subsequent PATCH/PUT requests (including the layer data as the body)
// to that internal address. Pre-signed blob URLs from cloud storage providers
// (GCS, S3, Azure Blob) use public hostnames, so legitimate cross-host
// redirects are unaffected.
//
// Same-host redirects (e.g. a different path on the registry itself) are
// always allowed regardless of whether the registry IP is private.
origHost := resp.Request.URL.Hostname()
if destHost := resolved.Hostname(); destHost != origHost {
if ip := net.ParseIP(destHost); ip != nil {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsPrivate() || ip.IsUnspecified() {
return "", fmt.Errorf("SSRF protection: Location header redirects to private/link-local host %q", destHost)
}
}
}
return resolved.String(), nil
}
// checkExistingBlob checks if a blob exists already in the repository by making a
@@ -555,9 +597,10 @@ func (w *writer) commitManifest(ctx context.Context, t Taggable, ref name.Refere
return err
}
var mf struct {
MediaType types.MediaType `json:"mediaType"`
Subject *v1.Descriptor `json:"subject,omitempty"`
Config struct {
MediaType types.MediaType `json:"mediaType"`
Subject *v1.Descriptor `json:"subject,omitempty"`
ArtifactType string `json:"artifactType,omitempty"`
Config struct {
MediaType types.MediaType `json:"mediaType"`
} `json:"config"`
}
@@ -599,10 +642,14 @@ func (w *writer) commitManifest(ctx context.Context, t Taggable, ref name.Refere
return err
}
desc := v1.Descriptor{
ArtifactType: string(mf.Config.MediaType),
MediaType: mf.MediaType,
Digest: h,
Size: size,
MediaType: mf.MediaType,
Digest: h,
Size: size,
}
if mf.ArtifactType != "" {
desc.ArtifactType = mf.ArtifactType
} else {
desc.ArtifactType = string(mf.Config.MediaType)
}
if err := w.commitSubjectReferrers(ctx,
ref.Context().Digest(mf.Subject.Digest.String()),
+5 -52
View File
@@ -22,10 +22,7 @@ import (
"os"
"sync"
"github.com/containerd/stargz-snapshotter/estargz"
"github.com/google/go-containerregistry/internal/and"
comp "github.com/google/go-containerregistry/internal/compression"
gestargz "github.com/google/go-containerregistry/internal/estargz"
ggzip "github.com/google/go-containerregistry/internal/gzip"
"github.com/google/go-containerregistry/internal/zstd"
"github.com/google/go-containerregistry/pkg/compression"
@@ -43,7 +40,6 @@ type layer struct {
compression compression.Compression
compressionLevel int
annotations map[string]string
estgzopts []estargz.Option
mediaType types.MediaType
}
@@ -161,53 +157,15 @@ func WithCompressedCaching(l *layer) {
// through estargz.Options to the underlying compression layer. This is
// only meaningful when estargz is enabled.
//
// Deprecated: WithEstargz is deprecated, and will be removed in a future release.
func WithEstargzOptions(opts ...estargz.Option) LayerOption {
return func(l *layer) {
l.estgzopts = opts
}
// Deprecated: WithEstargz is deprecated; it is a no-op.
func WithEstargzOptions(...any) LayerOption {
return func(*layer) {}
}
// WithEstargz is a functional option that explicitly enables estargz support.
//
// Deprecated: WithEstargz is deprecated, and will be removed in a future release.
func WithEstargz(l *layer) {
oguncompressed := l.uncompressedopener
estargz := func() (io.ReadCloser, error) {
crc, err := oguncompressed()
if err != nil {
return nil, err
}
eopts := append(l.estgzopts, estargz.WithCompressionLevel(l.compressionLevel))
rc, h, err := gestargz.ReadCloser(crc, eopts...)
if err != nil {
return nil, err
}
l.annotations[estargz.TOCJSONDigestAnnotation] = h.String()
return &and.ReadCloser{
Reader: rc,
CloseFunc: func() error {
err := rc.Close()
if err != nil {
return err
}
// As an optimization, leverage the DiffID exposed by the estargz ReadCloser
l.diffID, err = v1.NewHash(rc.DiffID().String())
return err
},
}, nil
}
uncompressed := func() (io.ReadCloser, error) {
urc, err := estargz()
if err != nil {
return nil, err
}
return ggzip.UnzipReadCloser(urc)
}
l.compressedopener = estargz
l.uncompressedopener = uncompressed
}
// Deprecated: WithEstargz is deprecated; it is a no-op.
func WithEstargz(*layer) {}
// LayerFromFile returns a v1.Layer given a tarball
func LayerFromFile(path string, opts ...LayerOption) (v1.Layer, error) {
@@ -241,11 +199,6 @@ func LayerFromOpener(opener Opener, opts ...LayerOption) (v1.Layer, error) {
mediaType: types.DockerLayer,
}
if estgz := os.Getenv("GGCR_EXPERIMENT_ESTARGZ"); estgz == "1" {
logs.Warn.Println("GGCR_EXPERIMENT_ESTARGZ is deprecated, and will be removed in a future release.")
opts = append([]LayerOption{WithEstargz}, opts...)
}
switch comp {
case compression.GZip:
layer.compressedopener = opener