Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions cli/config/configfile/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import (
"maps"
"os"
"path/filepath"
"slices"
"strings"

"github.com/docker/cli/cli/config/credentials"
"github.com/docker/cli/cli/config/internal/hostmatch"
"github.com/docker/cli/cli/config/memorystore"
"github.com/docker/cli/cli/config/types"
"github.com/sirupsen/logrus"
Expand Down Expand Up @@ -140,7 +142,25 @@ func (c *ConfigFile) LoadFromReader(configData io.Reader) error {
ac.ServerAddress = addr
c.AuthConfigs[addr] = ac
}
return nil
return c.validateRegistryPatterns()
}

// validateRegistryPatterns returns an error for keys in the "auths" and
// "credHelpers" sections that contain a "*" wildcard, but are not valid
// wildcard patterns (see [hostmatch.Validate]).
func (c *ConfigFile) validateRegistryPatterns() error {
var errs []error
for _, addr := range slices.Sorted(maps.Keys(c.AuthConfigs)) {
if err := hostmatch.Validate(addr); err != nil {
errs = append(errs, fmt.Errorf("auths: %w", err))
}
}
for _, addr := range slices.Sorted(maps.Keys(c.CredentialHelpers)) {
if err := hostmatch.Validate(addr); err != nil {
errs = append(errs, fmt.Errorf("credHelpers: %w", err))
}
}
return errors.Join(errs...)
}

// ContainsAuth returns whether there is authentication configured
Expand Down Expand Up @@ -391,12 +411,16 @@ func (c *ConfigFile) GetAuthConfig(registryHostname string) (types.AuthConfig, e

// getConfiguredCredentialStore returns the credential helper configured for the
// given registry, the default credsStore, or the empty string if neither are
// configured.
// configured. An exact match in credHelpers takes precedence over the most
// specific wildcard pattern (for example, "*.example.com") matching the registry.
func getConfiguredCredentialStore(c *ConfigFile, registryHostname string) string {
if c.CredentialHelpers != nil && registryHostname != "" {
if helper, exists := c.CredentialHelpers[registryHostname]; exists {
return helper
}
if pattern, ok := hostmatch.Best(maps.Keys(c.CredentialHelpers), registryHostname); ok {
return c.CredentialHelpers[pattern]
}
}
return c.CredentialsStore
}
Expand All @@ -418,6 +442,11 @@ func (c *ConfigFile) GetAllCredentials() (map[string]types.AuthConfig, error) {

// Auth configs from a registry-specific helper should override those from the default store.
for registryHostname := range c.CredentialHelpers {
if hostmatch.IsPattern(registryHostname) {
// Wildcard patterns are not registry hostnames, so
// there are no credentials to look up for them.
continue
}
newAuth, err := c.GetAuthConfig(registryHostname)
if err != nil {
// TODO(thaJeztah): use context-logger, so that this output can be suppressed (in tests).
Expand Down
88 changes: 88 additions & 0 deletions cli/config/configfile/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"os"
"strings"
"testing"

"github.com/docker/cli/cli/config/credentials"
Expand Down Expand Up @@ -446,6 +447,58 @@ func TestGetAllCredentialsCredHelperOverridesDefaultStore(t *testing.T) {
assert.Check(t, is.Equal(0, testCredHelper.(*mockNativeStore).GetAllCallCount))
}

func TestGetConfiguredCredentialStoreWildcard(t *testing.T) {
configFile := New("filename")
configFile.CredentialsStore = "default_store"
configFile.CredentialHelpers = map[string]string{
"registry.example.com": "exact_helper",
"*.example.com": "wildcard_helper",
"*.docker.example.com": "specific_wildcard_helper",
"*.com": "invalid_wildcard_helper",
}

tests := []struct {
registryHostname string
expected string
}{
{registryHostname: "registry.example.com", expected: "exact_helper"},
{registryHostname: "other.example.com", expected: "wildcard_helper"},
{registryHostname: "foo.docker.example.com", expected: "specific_wildcard_helper"},
{registryHostname: "foo.bar.example.com", expected: "default_store"},
{registryHostname: "example.com", expected: "default_store"},
{registryHostname: "other.com", expected: "default_store"},
}
for _, tc := range tests {
t.Run(tc.registryHostname, func(t *testing.T) {
assert.Check(t, is.Equal(getConfiguredCredentialStore(configFile, tc.registryHostname), tc.expected))
})
}
}

func TestGetAllCredentialsSkipsWildcardCredHelpers(t *testing.T) {
const (
testCredHelperSuffix = "test_cred_helper"
testCredHelperKey = "*.example.com"
)

configFile := New("filename")
configFile.CredentialHelpers = map[string]string{testCredHelperKey: testCredHelperSuffix}

testCredHelper := NewMockNativeStore(map[string]types.AuthConfig{
testCredHelperKey: {Username: "cred_helper_user", Password: "cred_helper_pass"},
}, nil)

tmpNewNativeStore := newNativeStore
defer func() { newNativeStore = tmpNewNativeStore }()
newNativeStore = func(configFile *ConfigFile, helperSuffix string) credentials.Store {
return testCredHelper
}

authConfigs, err := configFile.GetAllCredentials()
assert.NilError(t, err)
assert.Check(t, is.Len(authConfigs, 0), "wildcard patterns should not be looked up in credential helpers")
}

func TestLoadFromReaderWithUsernamePassword(t *testing.T) {
configFile := New("test-load")
defer os.Remove("test-load")
Expand Down Expand Up @@ -499,6 +552,41 @@ const envTestAuthConfig = `{
}
}`

func TestLoadFromReaderInvalidRegistryPatterns(t *testing.T) {
const configJSON = `{
"auths": {
"registry.example.com": {},
"*.example.com": {},
"*.com": {},
"https://*.example.org": {}
},
"credHelpers": {
"*.dkr.ecr.*.amazonaws.com": "ecr-login",
"foo.*.com": "secretservice"
}
}`

configFile := New("test-load")
err := configFile.LoadFromReader(strings.NewReader(configJSON))
assert.Check(t, is.Error(err, `auths: invalid registry pattern "*.com": wildcards are not allowed in the last two labels
auths: invalid registry pattern "https://*.example.org": must be a hostname, optionally including a port, without scheme or path
credHelpers: invalid registry pattern "foo.*.com": wildcards are not allowed in the last two labels`))

// Invalid patterns are reported, but are never used for matching.
assert.Check(t, is.Equal(getConfiguredCredentialStore(configFile, "foo.bar.com"), ""))
assert.Check(t, is.Equal(getConfiguredCredentialStore(configFile, "123.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login"))
}

func TestLoadFromReaderValidRegistryPatterns(t *testing.T) {
const configJSON = `{
"auths": {"*.example.com": {}},
"credHelpers": {"*-docker.pkg.dev": "gcloud"}
}`

configFile := New("test-load")
assert.NilError(t, configFile.LoadFromReader(strings.NewReader(configJSON)))
}

func TestGetAllCredentialsFromEnvironment(t *testing.T) {
t.Run("can parse DOCKER_AUTH_CONFIG auth field", func(t *testing.T) {
config := &ConfigFile{}
Expand Down
13 changes: 13 additions & 0 deletions cli/config/credentials/file_store.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26

package credentials

import (
"fmt"
"maps"
"net"
"net/url"
"os"
"strings"
"sync/atomic"

"github.com/docker/cli/cli/config/internal/hostmatch"
"github.com/docker/cli/cli/config/types"
)

Expand Down Expand Up @@ -51,6 +56,14 @@ func (c *fileStore) Get(serverAddress string) (types.AuthConfig, error) {
}
}

// Fall back to the most specific wildcard pattern (for example,
// "*.example.com") matching the server address, if any.
if pattern, ok := hostmatch.Best(maps.Keys(c.file.GetAuthConfigs()), serverAddress); ok {
authConfig = c.file.GetAuthConfigs()[pattern]
authConfig.ServerAddress = serverAddress
return authConfig, nil
}

authConfig = types.AuthConfig{}
}
return authConfig, nil
Expand Down
63 changes: 63 additions & 0 deletions cli/config/credentials/file_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,69 @@ func TestFileStoreGet(t *testing.T) {
}
}

func TestFileStoreGetWildcard(t *testing.T) {
f := &fakeStore{configs: map[string]types.AuthConfig{
"registry.example.com": {
Username: "exact",
ServerAddress: "registry.example.com",
},
"*.example.com": {
Username: "wildcard",
ServerAddress: "*.example.com",
},
"*.docker.example.com": {
Username: "more-specific-wildcard",
ServerAddress: "*.docker.example.com",
},
"*.com": {
Username: "invalid-wildcard",
ServerAddress: "*.com",
},
}}
s := NewFileStore(f)

tests := []struct {
serverAddress string
expected types.AuthConfig
}{
{
serverAddress: "registry.example.com",
expected: types.AuthConfig{Username: "exact", ServerAddress: "registry.example.com"},
},
{
serverAddress: "other.example.com",
expected: types.AuthConfig{Username: "wildcard", ServerAddress: "other.example.com"},
},
{
serverAddress: "foo.docker.example.com",
expected: types.AuthConfig{Username: "more-specific-wildcard", ServerAddress: "foo.docker.example.com"},
},
{
serverAddress: "foo.bar.example.com",
expected: types.AuthConfig{},
},
{
serverAddress: "example.com",
expected: types.AuthConfig{},
},
{
serverAddress: "foo.example.com:5000",
expected: types.AuthConfig{},
},
{
serverAddress: "https://index.docker.io/v1/",
expected: types.AuthConfig{},
},
}
for _, tc := range tests {
t.Run(tc.serverAddress, func(t *testing.T) {
actual, err := s.Get(tc.serverAddress)
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(actual, tc.expected))
})
}
}

func TestFileStoreGetAll(t *testing.T) {
s1 := "https://example.com"
s2 := "https://example2.example.com"
Expand Down
114 changes: 114 additions & 0 deletions cli/config/internal/hostmatch/hostmatch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26

// Package hostmatch implements matching of registry hostnames against
// patterns containing "*" wildcards, as used for keys in the "auths" and
// "credHelpers" sections of the CLI configuration file.
package hostmatch

import (
"fmt"
"iter"
"slices"
"strings"
)

// IsPattern reports whether key is a valid wildcard host pattern; see
// [Validate] for the rules a pattern must follow.
func IsPattern(key string) bool {
return strings.Contains(key, "*") && Validate(key) == nil
}

// Validate returns an error if key contains a "*" wildcard, but is not a
// valid wildcard host pattern. Keys without a wildcard are not validated.
//
// A pattern is a hostname (optionally including ":port") in which one or more
// labels contain a "*" wildcard, for example "*.example.com" or
// "*.dkr.ecr.*.amazonaws.com". A wildcard matches any sequence of characters
// within a single label; it never matches a ".".
//
// Patterns must not contain a scheme or path. To prevent patterns from
// matching an overly broad set of registries, the last two labels (for
// example, the registrable domain and top-level domain, and port, if any)
// must not contain a wildcard; "*.com" and "example.*" are not valid
// patterns.
func Validate(key string) error {
if !strings.Contains(key, "*") {
return nil
}
if strings.Contains(key, "/") {
return fmt.Errorf("invalid registry pattern %q: must be a hostname, optionally including a port, without scheme or path", key)
}
labels := strings.Split(key, ".")
if slices.Contains(labels, "") {
return fmt.Errorf("invalid registry pattern %q: contains an empty label", key)
}
if len(labels) < 3 || strings.Contains(labels[len(labels)-2], "*") || strings.Contains(labels[len(labels)-1], "*") {
return fmt.Errorf("invalid registry pattern %q: wildcards are not allowed in the last two labels", key)
}
return nil
}

// Match reports whether host matches pattern. It returns false if pattern
// is not a valid pattern (see [IsPattern]) or if host is not a plain
// hostname (optionally including ":port").
func Match(pattern, host string) bool {
if !IsPattern(pattern) || strings.Contains(host, "/") {
return false
}
patternLabels := strings.Split(pattern, ".")
hostLabels := strings.Split(host, ".")
if len(patternLabels) != len(hostLabels) {
return false
}
for i, label := range hostLabels {
if label == "" || !matchLabel(patternLabels[i], label) {
return false
}
}
return true
}

// Best returns the most specific pattern in keys that matches host. Keys that
// are not valid patterns are ignored. Patterns are ranked by the number of
// non-wildcard characters they contain; ties are broken by lexical order, so
// that the result is deterministic.
func Best(keys iter.Seq[string], host string) (string, bool) {
var (
best string
bestScore = -1
)
for key := range keys {
if !Match(key, host) {
continue
}
score := len(key) - strings.Count(key, "*")
if score > bestScore || (score == bestScore && key < best) {
best, bestScore = key, score
}
}
return best, bestScore >= 0
}

// matchLabel reports whether a single hostname label matches the given
// pattern label, in which "*" matches any (possibly empty) sequence of
// characters.
func matchLabel(pattern, label string) bool {
parts := strings.Split(pattern, "*")
if len(parts) == 1 {
return pattern == label
}
first, last := parts[0], parts[len(parts)-1]
if len(label) < len(first)+len(last) || !strings.HasPrefix(label, first) || !strings.HasSuffix(label, last) {
return false
}
label = label[len(first) : len(label)-len(last)]
for _, part := range parts[1 : len(parts)-1] {
i := strings.Index(label, part)
if i < 0 {
return false
}
label = label[i+len(part):]
}
return true
}
Loading