updated vendor
This commit is contained in:
+2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
Vendored
+182
@@ -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
@@ -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
|
||||
}
|
||||
+3
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+68
-6
@@ -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))
|
||||
}
|
||||
|
||||
+13
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+4
-2
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user