updated vendor
This commit is contained in:
+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()),
|
||||
|
||||
Reference in New Issue
Block a user