diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index e32b1e767cbb..2ee3f0a069ef 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -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" @@ -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 @@ -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 } @@ -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). diff --git a/cli/config/configfile/file_test.go b/cli/config/configfile/file_test.go index 92df02c74352..6b8b304ae58b 100644 --- a/cli/config/configfile/file_test.go +++ b/cli/config/configfile/file_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "os" + "strings" "testing" "github.com/docker/cli/cli/config/credentials" @@ -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") @@ -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{} diff --git a/cli/config/credentials/file_store.go b/cli/config/credentials/file_store.go index d4037b7a488c..4568535d999e 100644 --- a/cli/config/credentials/file_store.go +++ b/cli/config/credentials/file_store.go @@ -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" ) @@ -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 diff --git a/cli/config/credentials/file_store_test.go b/cli/config/credentials/file_store_test.go index d4c8375ea25e..16246e81a9f3 100644 --- a/cli/config/credentials/file_store_test.go +++ b/cli/config/credentials/file_store_test.go @@ -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" diff --git a/cli/config/internal/hostmatch/hostmatch.go b/cli/config/internal/hostmatch/hostmatch.go new file mode 100644 index 000000000000..8260674bc773 --- /dev/null +++ b/cli/config/internal/hostmatch/hostmatch.go @@ -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 +} diff --git a/cli/config/internal/hostmatch/hostmatch_test.go b/cli/config/internal/hostmatch/hostmatch_test.go new file mode 100644 index 000000000000..564fc049fcab --- /dev/null +++ b/cli/config/internal/hostmatch/hostmatch_test.go @@ -0,0 +1,134 @@ +// 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 + +import ( + "slices" + "strings" + "testing" + + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestValidate(t *testing.T) { + tests := []struct { + key string + expectedErr string + }{ + {key: "*.example.com"}, + {key: "*.dkr.ecr.*.amazonaws.com"}, + {key: "*-docker.pkg.dev"}, + {key: "*.example.com:5000"}, + {key: "foo.*.example.com"}, + + // no wildcard; not validated + {key: "example.com"}, + {key: "registry.example.com"}, + {key: "https://index.docker.io/v1/"}, + {key: "localhost"}, + + // wildcard in the last two labels + {key: "*", expectedErr: "wildcards are not allowed in the last two labels"}, + {key: "*.com", expectedErr: "wildcards are not allowed in the last two labels"}, + {key: "*com", expectedErr: "wildcards are not allowed in the last two labels"}, + {key: "foo.*.com", expectedErr: "wildcards are not allowed in the last two labels"}, + {key: "foo.example.*", expectedErr: "wildcards are not allowed in the last two labels"}, + {key: "*.example.com:*", expectedErr: "wildcards are not allowed in the last two labels"}, + {key: "*.example.c*m", expectedErr: "wildcards are not allowed in the last two labels"}, + + // malformed + {key: "*..example.com", expectedErr: "contains an empty label"}, + {key: ".*.example.com", expectedErr: "contains an empty label"}, + {key: "*.example.com.", expectedErr: "contains an empty label"}, + {key: "https://*.example.com", expectedErr: "without scheme or path"}, + {key: "*.example.com/foo", expectedErr: "without scheme or path"}, + } + for _, tc := range tests { + t.Run(tc.key, func(t *testing.T) { + err := Validate(tc.key) + if tc.expectedErr == "" { + assert.NilError(t, err) + assert.Check(t, is.Equal(IsPattern(tc.key), strings.Contains(tc.key, "*"))) + } else { + assert.Check(t, is.ErrorContains(err, tc.expectedErr)) + assert.Check(t, !IsPattern(tc.key)) + } + }) + } +} + +func TestMatch(t *testing.T) { + tests := []struct { + pattern string + host string + expected bool + }{ + {pattern: "*.example.com", host: "foo.example.com", expected: true}, + {pattern: "*.example.com", host: "example.com", expected: false}, + {pattern: "*.example.com", host: "foo.bar.example.com", expected: false}, + {pattern: "*.example.com", host: "foo.example.org", expected: false}, + {pattern: "*.example.com", host: "foo.example.com:5000", expected: false}, + {pattern: "*.example.com", host: ".example.com", expected: false}, + {pattern: "*.example.com", host: "https://foo.example.com", expected: false}, + {pattern: "*.example.com", host: "foo.example.com/v2/", expected: false}, + {pattern: "*.example.com:5000", host: "foo.example.com:5000", expected: true}, + {pattern: "*.example.com:5000", host: "foo.example.com", expected: false}, + {pattern: "abc.*.def.example.com", host: "abc.sdf.def.example.com", expected: true}, + {pattern: "abc.*.def.example.com", host: "abc.sdf.sdf.def.example.com", expected: false}, + {pattern: "*.dkr.ecr.*.amazonaws.com", host: "123456789012.dkr.ecr.us-east-1.amazonaws.com", expected: true}, + {pattern: "*.dkr.ecr.*.amazonaws.com", host: "123456789012.dkr.ecr.amazonaws.com", expected: false}, + {pattern: "*-docker.pkg.dev", host: "us-docker.pkg.dev", expected: true}, + {pattern: "*-docker.pkg.dev", host: "docker.pkg.dev", expected: false}, + {pattern: "*-docker.pkg.dev", host: "-docker.pkg.dev", expected: true}, + {pattern: "a*b*c.example.com", host: "abc.example.com", expected: true}, + {pattern: "a*b*c.example.com", host: "axxbyyc.example.com", expected: true}, + {pattern: "a*b*c.example.com", host: "axxcyyb.example.com", expected: false}, + {pattern: "ab*ba.example.com", host: "aba.example.com", expected: false}, + + // invalid patterns never match + {pattern: "*.com", host: "example.com", expected: false}, + {pattern: "foo.*.com", host: "foo.example.com", expected: false}, + {pattern: "foo.example.com", host: "foo.example.com", expected: false}, + } + for _, tc := range tests { + t.Run(tc.pattern+"="+tc.host, func(t *testing.T) { + assert.Check(t, is.Equal(Match(tc.pattern, tc.host), tc.expected)) + }) + } +} + +func TestBest(t *testing.T) { + keys := []string{ + "example.com", + "*.com", + "*.example.com", + "*.docker.example.com", + "*.dock*.example.com", + "*.*.example.com", + "*.docker.exa*.com", // invalid: wildcard in the last two labels + "b*.foo.example.com", + "*a.foo.example.com", + } + tests := []struct { + host string + expected string + }{ + {host: "foo.docker.example.com", expected: "*.docker.example.com"}, + {host: "foo.dockyard.example.com", expected: "*.dock*.example.com"}, + {host: "foo.other.example.com", expected: "*.*.example.com"}, + {host: "foo.docker.examine.com", expected: ""}, + {host: "foo.example.com", expected: "*.example.com"}, + {host: "ba.foo.example.com", expected: "*a.foo.example.com"}, + {host: "example.com", expected: ""}, + {host: "foo.example.org", expected: ""}, + } + for _, tc := range tests { + t.Run(tc.host, func(t *testing.T) { + actual, ok := Best(slices.Values(keys), tc.host) + assert.Check(t, is.Equal(ok, tc.expected != "")) + assert.Check(t, is.Equal(actual, tc.expected)) + }) + } +} diff --git a/docs/reference/commandline/login.md b/docs/reference/commandline/login.md index a4e443bcaabd..e74df3d2da50 100644 --- a/docs/reference/commandline/login.md +++ b/docs/reference/commandline/login.md @@ -168,6 +168,53 @@ registry domain, and values specify the suffix of the program to use } ``` +### Wildcard registry patterns + +Keys in the `credHelpers` and `auths` sections of the configuration file can be +wildcard patterns, so that a single entry applies to many registries. This is +useful for registries that encode an account, region, or project in the +hostname: + +```json +{ + "credHelpers": { + "*.dkr.ecr.*.amazonaws.com": "ecr-login", + "*-docker.pkg.dev": "gcloud" + }, + "auths": { + "*.docker.artifactory.example.com": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=" + } + } +} +``` + +A `*` matches any sequence of characters within a single label of the +hostname; it never matches a `.`. For example, `abc.*.example.com` matches +`abc.foo.example.com`, but not `abc.foo.bar.example.com`, and +`*.example.com` does not match `example.com`. A port, if any, must be part of +the pattern: `*.example.com` does not match `foo.example.com:5000`, but +`*.example.com:5000` does. + +A pattern must be a hostname, optionally including a port, without a scheme +(`https://`) or path. To prevent credentials from being sent to an overly broad +set of registries, the last two labels of a pattern must not contain a +wildcard. Invalid patterns, such as `*.com`, `foo.*.com`, or +`https://*.example.com`, are rejected with an error when the configuration +file is loaded, and are not used. This check does not know about multi-label +public suffixes such as `co.uk`, so make sure that your patterns only match +registries you trust with your credentials. + +An entry for the exact registry hostname always takes precedence over a +wildcard pattern. If multiple patterns match, the most specific one (the +pattern with the most non-wildcard characters) is used. Wildcard patterns in +`credHelpers` take precedence over the `credsStore`. + +`docker login` stores credentials for the exact registry you log in to. To use +a single set of credentials stored in the `auths` section for multiple +registries, log in to one of the registries, and rename its entry in the +configuration file to a wildcard pattern. + ## Examples ### Authenticate to Docker Hub with web-based login