updated vendor
This commit is contained in:
@@ -109,7 +109,7 @@ func (cc *concurrentCache) Set(ctx context.Context, registry string, scheme Sche
|
||||
}, " ")
|
||||
statusValue, _ := cc.status.LoadOrStore(statusKey, syncutil.NewOnce())
|
||||
fetchOnce := statusValue.(*syncutil.Once)
|
||||
fetchedFirst, result, err := fetchOnce.Do(ctx, func() (interface{}, error) {
|
||||
fetchedFirst, result, err := fetchOnce.Do(ctx, func() (any, error) {
|
||||
return fetch(ctx)
|
||||
})
|
||||
if fetchedFirst {
|
||||
|
||||
+101
-1
@@ -23,6 +23,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -136,7 +137,50 @@ func (c *Client) send(req *http.Request) (*http.Response, error) {
|
||||
for key, values := range c.Header {
|
||||
req.Header[key] = append(req.Header[key], values...)
|
||||
}
|
||||
return c.client().Do(req)
|
||||
// Drop the Authorization header when a redirect crosses an HTTP origin
|
||||
// (scheme, host, or port). The standard library only strips sensitive
|
||||
// headers when the hostname changes, so a redirect to a different port on
|
||||
// the same host would otherwise forward credentials to an unintended
|
||||
// endpoint. Any caller-provided CheckRedirect is preserved.
|
||||
// Reference: https://github.com/oras-project/oras-go/security/advisories/GHSA-vh4v-2xq2-g5cg
|
||||
client := c.client()
|
||||
clientCopy := *client
|
||||
checkRedirect := client.CheckRedirect
|
||||
clientCopy.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) > 0 && !sameHTTPOrigin(via[len(via)-1].URL, req.URL) {
|
||||
req.Header.Del("Authorization")
|
||||
}
|
||||
if checkRedirect != nil {
|
||||
return checkRedirect(req, via)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return clientCopy.Do(req)
|
||||
}
|
||||
|
||||
// sameHTTPOrigin reports whether a and b share the same HTTP origin, i.e. the
|
||||
// same scheme and host. Default ports are normalized so that, for example,
|
||||
// "example.com" and "example.com:443" compare equal over https.
|
||||
func sameHTTPOrigin(a, b *url.URL) bool {
|
||||
if !strings.EqualFold(a.Scheme, b.Scheme) {
|
||||
return false
|
||||
}
|
||||
return canonicalHost(a) == canonicalHost(b)
|
||||
}
|
||||
|
||||
// canonicalHost returns the lower-cased host of u with the default port for its
|
||||
// scheme applied when no explicit port is present.
|
||||
func canonicalHost(u *url.URL) string {
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https":
|
||||
port = "443"
|
||||
case "http":
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
return strings.ToLower(u.Hostname()) + ":" + port
|
||||
}
|
||||
|
||||
// credential resolves the credential for the given registry.
|
||||
@@ -156,6 +200,49 @@ func (c *Client) cache() Cache {
|
||||
return c.Cache
|
||||
}
|
||||
|
||||
// validateRealm rejects bearer token realm URLs that would have the client
|
||||
// forward credentials to obviously unsafe destinations:
|
||||
//
|
||||
// - schemes other than http or https,
|
||||
// - http realms when the registry was contacted over https (TLS downgrade),
|
||||
// - hosts that are IP literals in loopback, link-local, private, or
|
||||
// unspecified ranges (e.g. cloud instance metadata services such as
|
||||
// 169.254.169.254).
|
||||
//
|
||||
// Cross-host realms with a public hostname are permitted, because the
|
||||
// distribution spec allows a separate token endpoint (e.g. Docker Hub's
|
||||
// auth.docker.io). When the registry itself is reached at the same hostname
|
||||
// as the realm, the IP-literal check is skipped so loopback and in-cluster
|
||||
// deployments continue to work.
|
||||
func validateRealm(realm string, registryURL *url.URL) error {
|
||||
if realm == "" {
|
||||
return nil
|
||||
}
|
||||
realmURL, err := url.Parse(realm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse bearer realm %q: %w", realm, err)
|
||||
}
|
||||
switch realmURL.Scheme {
|
||||
case "https":
|
||||
// always allowed
|
||||
case "http":
|
||||
if registryURL != nil && registryURL.Scheme == "https" {
|
||||
return fmt.Errorf("bearer realm %q uses http but registry was contacted over https", realm)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("bearer realm %q uses unsupported scheme %q", realm, realmURL.Scheme)
|
||||
}
|
||||
if ip := net.ParseIP(realmURL.Hostname()); ip != nil {
|
||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsPrivate() || ip.IsUnspecified() {
|
||||
if registryURL == nil || realmURL.Hostname() != registryURL.Hostname() {
|
||||
return fmt.Errorf("bearer realm host %q is a loopback, link-local, private, or unspecified address", realmURL.Hostname())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserAgent sets the user agent for all out-going requests.
|
||||
func (c *Client) SetUserAgent(userAgent string) {
|
||||
if c.Header == nil {
|
||||
@@ -182,6 +269,9 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
|
||||
var attemptedKey string
|
||||
cache := c.cache()
|
||||
host := originalReq.Host
|
||||
if host == "" {
|
||||
host = originalReq.URL.Host
|
||||
}
|
||||
scheme, err := cache.GetScheme(ctx, host)
|
||||
if err == nil {
|
||||
switch scheme {
|
||||
@@ -207,6 +297,13 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
return resp, nil
|
||||
}
|
||||
// If the challenge came from a different origin than originally requested
|
||||
// (e.g. the request was redirected to another host or port), do not resolve
|
||||
// or send the registry credentials to that origin.
|
||||
// Reference: https://github.com/oras-project/oras-go/security/advisories/GHSA-vh4v-2xq2-g5cg
|
||||
if resp.Request != nil && !sameHTTPOrigin(originalReq.URL, resp.Request.URL) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// attempt again with credentials for recognized schemes
|
||||
challenge := resp.Header.Get("Www-Authenticate")
|
||||
@@ -257,6 +354,9 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
|
||||
|
||||
// attempt with credentials
|
||||
realm := params["realm"]
|
||||
if err := validateRealm(realm, originalReq.URL); err != nil {
|
||||
return nil, fmt.Errorf("%s %q: %w", resp.Request.Method, resp.Request.URL, err)
|
||||
}
|
||||
service := params["service"]
|
||||
token, err := cache.Set(ctx, host, SchemeBearer, key, func(ctx context.Context) (string, error) {
|
||||
return c.fetchBearerToken(ctx, host, realm, service, scopes)
|
||||
|
||||
@@ -254,7 +254,7 @@ func CleanScopes(scopes []string) []string {
|
||||
actionSet = make(map[string]struct{})
|
||||
namedActions[resourceName] = actionSet
|
||||
}
|
||||
for _, action := range strings.Split(actions, ",") {
|
||||
for action := range strings.SplitSeq(actions, ",") {
|
||||
if action != "" {
|
||||
actionSet[action] = struct{}{}
|
||||
}
|
||||
|
||||
+8
-1
@@ -21,6 +21,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -128,7 +129,13 @@ func Load(configPath string) (*Config, error) {
|
||||
|
||||
// decode config content if the config file exists
|
||||
if err := json.NewDecoder(configFile).Decode(&cfg.content); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode config file at %s: %w: %v", configPath, ErrInvalidConfigFormat, err)
|
||||
if errors.Is(err, io.EOF) {
|
||||
// empty or whitespace only file
|
||||
cfg.content = make(map[string]json.RawMessage)
|
||||
cfg.authsCache = make(map[string]json.RawMessage)
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode config file %s: %w: %v", configPath, ErrInvalidConfigFormat, err)
|
||||
}
|
||||
|
||||
if credsStoreBytes, ok := cfg.content[configFieldCredentialsStore]; ok {
|
||||
|
||||
@@ -81,10 +81,12 @@ func Credential(store Store) auth.CredentialFunc {
|
||||
|
||||
// ServerAddressFromRegistry maps a registry to a server address, which is used as
|
||||
// a key for credentials store. The Docker CLI expects that the credentials of
|
||||
// the registry 'docker.io' will be added under the key "https://index.docker.io/v1/".
|
||||
// the registry 'registry-1.docker.io' or the alias 'docker.io' will be added
|
||||
// under the key "https://index.docker.io/v1/".
|
||||
// See: https://github.com/moby/moby/blob/v24.0.2/registry/config.go#L25-L48
|
||||
func ServerAddressFromRegistry(registry string) string {
|
||||
if registry == "docker.io" {
|
||||
if registry == "docker.io" ||
|
||||
registry == "registry-1.docker.io" {
|
||||
return "https://index.docker.io/v1/"
|
||||
}
|
||||
return registry
|
||||
|
||||
+2
-6
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
package remote
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
@@ -41,12 +42,7 @@ func isManifest(manifestMediaTypes []string, desc ocispec.Descriptor) bool {
|
||||
if len(manifestMediaTypes) == 0 {
|
||||
manifestMediaTypes = defaultManifestMediaTypes
|
||||
}
|
||||
for _, mediaType := range manifestMediaTypes {
|
||||
if desc.MediaType == mediaType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.Contains(manifestMediaTypes, desc.MediaType)
|
||||
}
|
||||
|
||||
// manifestAcceptHeader generates the set in the `Accept` header for resolving
|
||||
|
||||
@@ -118,8 +118,8 @@ func isReferrersFilterApplied(applied, requested string) bool {
|
||||
if applied == "" || requested == "" {
|
||||
return false
|
||||
}
|
||||
filters := strings.Split(applied, ",")
|
||||
for _, f := range filters {
|
||||
filters := strings.SplitSeq(applied, ",")
|
||||
for f := range filters {
|
||||
if f == requested {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -872,6 +873,25 @@ func (s *blobStore) Push(ctx context.Context, expected ocispec.Descriptor, conte
|
||||
return s.completePushAfterInitialPost(ctx, req, resp, expected, content)
|
||||
}
|
||||
|
||||
// sameUploadHost reports whether location and reqURL refer to the same host,
|
||||
// normalizing implicit default ports (80 for http, 443 for https) so that
|
||||
// e.g. "example.com" and "example.com:443" compare equal over HTTPS.
|
||||
func sameUploadHost(location, reqURL *url.URL) bool {
|
||||
if location.Hostname() != reqURL.Hostname() {
|
||||
return false
|
||||
}
|
||||
canonicalPort := func(u *url.URL) string {
|
||||
if p := u.Port(); p != "" {
|
||||
return p
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
return canonicalPort(location) == canonicalPort(reqURL)
|
||||
}
|
||||
|
||||
// completePushAfterInitialPost implements step 2 of the push protocol. This can be invoked either by
|
||||
// Push or by Mount when the receiving repository does not implement the
|
||||
// mount endpoint.
|
||||
@@ -894,6 +914,15 @@ func (s *blobStore) completePushAfterInitialPost(ctx context.Context, req *http.
|
||||
if reqPort == "443" && locationHostname == reqHostname && locationPort == "" {
|
||||
location.Host = locationHostname + ":" + reqPort
|
||||
}
|
||||
// Validate the Location stays on the same host to prevent credentials from
|
||||
// being forwarded to an attacker-controlled endpoint.
|
||||
// Reference: https://github.com/oras-project/oras-go/security/advisories/GHSA-jxpm-75mh-9fp7
|
||||
if !sameUploadHost(location, req.URL) {
|
||||
return fmt.Errorf("blob upload Location %q is on a different host than the registry %q", location.Host, req.URL.Host)
|
||||
}
|
||||
if req.URL.Scheme == "https" && location.Scheme != "https" {
|
||||
return fmt.Errorf("blob upload Location %q downgrades scheme from https", location.Host)
|
||||
}
|
||||
url := location.String()
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPut, url, content)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user