From f6ef66c24581cc8643798836f0633162a35ba2e1 Mon Sep 17 00:00:00 2001 From: Yannik Tausch Date: Wed, 23 Sep 2026 18:38:27 +0200 Subject: [PATCH 1/2] cli/config: support wildcard patterns for registry hostnames Allow keys in the "credHelpers" and "auths" sections of the CLI config file to be wildcard patterns, such as "*.dkr.ecr.*.amazonaws.com" or "*.docker.artifactory.example.com", so that a single entry can be used for registries that encode an account, region, or project in their hostname. A "*" matches any sequence of characters within a single hostname label, and never matches a ".". The last two labels of a pattern must not contain a wildcard, so that patterns such as "*.com" cannot be used to send credentials to an overly broad set of registries. Exact matches always take precedence over patterns. If multiple patterns match, the most specific one (the one with the most non-wildcard characters) is used, and ties are broken by lexical order to make the result deterministic. Wildcard patterns in "credHelpers" are skipped by GetAllCredentials, as they are not registry hostnames that can be looked up in the helper. Signed-off-by: Yannik Tausch --- cli/config/configfile/file.go | 12 +- cli/config/configfile/file_test.go | 52 ++++++++ cli/config/credentials/file_store.go | 13 ++ cli/config/credentials/file_store_test.go | 63 +++++++++ cli/config/internal/hostmatch/hostmatch.go | 103 +++++++++++++++ .../internal/hostmatch/hostmatch_test.go | 125 ++++++++++++++++++ docs/reference/commandline/login.md | 42 ++++++ 7 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 cli/config/internal/hostmatch/hostmatch.go create mode 100644 cli/config/internal/hostmatch/hostmatch_test.go diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index e32b1e767cbb..8da185d5c605 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -15,6 +15,7 @@ import ( "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" @@ -391,12 +392,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 +423,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..a45668521c69 100644 --- a/cli/config/configfile/file_test.go +++ b/cli/config/configfile/file_test.go @@ -446,6 +446,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") 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..5fae2710c2a7 --- /dev/null +++ b/cli/config/internal/hostmatch/hostmatch.go @@ -0,0 +1,103 @@ +// 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 ( + "iter" + "strings" +) + +// IsPattern reports whether key is a valid wildcard host pattern. +// +// 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 ".". +// +// To prevent patterns from matching an overly broad set of registries, the +// last two labels (the registrable domain and top-level domain, and port, if +// any) must not contain a wildcard; "*.com" and "example.*" are not valid +// patterns. Keys containing a scheme or path are not valid patterns either. +func IsPattern(key string) bool { + if !strings.Contains(key, "*") || strings.Contains(key, "/") { + return false + } + labels := strings.Split(key, ".") + if len(labels) < 3 { + return false + } + for _, label := range labels { + if label == "" { + return false + } + } + return !strings.Contains(labels[len(labels)-2], "*") && !strings.Contains(labels[len(labels)-1], "*") +} + +// 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..71423ee1b001 --- /dev/null +++ b/cli/config/internal/hostmatch/hostmatch_test.go @@ -0,0 +1,125 @@ +// 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" + "testing" + + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestIsPattern(t *testing.T) { + tests := []struct { + key string + expected bool + }{ + {key: "*.example.com", expected: true}, + {key: "*.dkr.ecr.*.amazonaws.com", expected: true}, + {key: "*-docker.pkg.dev", expected: true}, + {key: "*.example.com:5000", expected: true}, + {key: "foo.*.example.com", expected: true}, + + // no wildcard + {key: "example.com", expected: false}, + {key: "registry.example.com", expected: false}, + {key: "https://index.docker.io/v1/", expected: false}, + + // wildcard in the last two labels + {key: "*", expected: false}, + {key: "*.com", expected: false}, + {key: "*com", expected: false}, + {key: "foo.*.com", expected: false}, + {key: "foo.example.*", expected: false}, + {key: "*.example.com:*", expected: false}, + {key: "*.example.c*m", expected: false}, + + // malformed + {key: "*..example.com", expected: false}, + {key: ".*.example.com", expected: false}, + {key: "*.example.com.", expected: false}, + {key: "https://*.example.com", expected: false}, + {key: "*.example.com/foo", expected: false}, + } + for _, tc := range tests { + t.Run(tc.key, func(t *testing.T) { + assert.Check(t, is.Equal(IsPattern(tc.key), tc.expected)) + }) + } +} + +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..3bd979aa3e70 100644 --- a/docs/reference/commandline/login.md +++ b/docs/reference/commandline/login.md @@ -168,6 +168,48 @@ 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. + +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. Patterns such as +`*.com` or `foo.*.com` are not valid, and are ignored. + +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 From af656e71e718941cb0a4bb41ffceaf68bbc4ba98 Mon Sep 17 00:00:00 2001 From: Yannik Tausch Date: Wed, 23 Sep 2026 18:59:50 +0200 Subject: [PATCH 2/2] cli/config: reject invalid registry patterns when loading config Instead of silently ignoring keys in the "auths" and "credHelpers" sections that contain a "*" wildcard but are not valid patterns (for example, "*.com", "foo.*.com", or "https://*.example.com"), return an error from ConfigFile.LoadFromReader that lists each invalid key. The docker CLI prints this error as a warning when loading the config file, as it does for other config errors; invalid patterns are never used for matching. Signed-off-by: Yannik Tausch --- cli/config/configfile/file.go | 21 ++++++- cli/config/configfile/file_test.go | 36 +++++++++++ cli/config/internal/hostmatch/hostmatch.go | 41 ++++++++----- .../internal/hostmatch/hostmatch_test.go | 59 +++++++++++-------- docs/reference/commandline/login.md | 11 +++- 5 files changed, 124 insertions(+), 44 deletions(-) diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index 8da185d5c605..2ee3f0a069ef 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -12,6 +12,7 @@ import ( "maps" "os" "path/filepath" + "slices" "strings" "github.com/docker/cli/cli/config/credentials" @@ -141,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 diff --git a/cli/config/configfile/file_test.go b/cli/config/configfile/file_test.go index a45668521c69..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" @@ -551,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/internal/hostmatch/hostmatch.go b/cli/config/internal/hostmatch/hostmatch.go index 5fae2710c2a7..8260674bc773 100644 --- a/cli/config/internal/hostmatch/hostmatch.go +++ b/cli/config/internal/hostmatch/hostmatch.go @@ -7,35 +7,46 @@ package hostmatch import ( + "fmt" "iter" + "slices" "strings" ) -// IsPattern reports whether key is a valid wildcard host pattern. +// 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 ".". // -// To prevent patterns from matching an overly broad set of registries, the -// last two labels (the registrable domain and top-level domain, and port, if -// any) must not contain a wildcard; "*.com" and "example.*" are not valid -// patterns. Keys containing a scheme or path are not valid patterns either. -func IsPattern(key string) bool { - if !strings.Contains(key, "*") || strings.Contains(key, "/") { - return false +// 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 len(labels) < 3 { - return false + if slices.Contains(labels, "") { + return fmt.Errorf("invalid registry pattern %q: contains an empty label", key) } - for _, label := range labels { - if label == "" { - return false - } + 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 !strings.Contains(labels[len(labels)-2], "*") && !strings.Contains(labels[len(labels)-1], "*") + return nil } // Match reports whether host matches pattern. It returns false if pattern diff --git a/cli/config/internal/hostmatch/hostmatch_test.go b/cli/config/internal/hostmatch/hostmatch_test.go index 71423ee1b001..564fc049fcab 100644 --- a/cli/config/internal/hostmatch/hostmatch_test.go +++ b/cli/config/internal/hostmatch/hostmatch_test.go @@ -5,47 +5,56 @@ package hostmatch import ( "slices" + "strings" "testing" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" ) -func TestIsPattern(t *testing.T) { +func TestValidate(t *testing.T) { tests := []struct { - key string - expected bool + key string + expectedErr string }{ - {key: "*.example.com", expected: true}, - {key: "*.dkr.ecr.*.amazonaws.com", expected: true}, - {key: "*-docker.pkg.dev", expected: true}, - {key: "*.example.com:5000", expected: true}, - {key: "foo.*.example.com", expected: true}, + {key: "*.example.com"}, + {key: "*.dkr.ecr.*.amazonaws.com"}, + {key: "*-docker.pkg.dev"}, + {key: "*.example.com:5000"}, + {key: "foo.*.example.com"}, - // no wildcard - {key: "example.com", expected: false}, - {key: "registry.example.com", expected: false}, - {key: "https://index.docker.io/v1/", expected: false}, + // 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: "*", expected: false}, - {key: "*.com", expected: false}, - {key: "*com", expected: false}, - {key: "foo.*.com", expected: false}, - {key: "foo.example.*", expected: false}, - {key: "*.example.com:*", expected: false}, - {key: "*.example.c*m", expected: false}, + {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", expected: false}, - {key: ".*.example.com", expected: false}, - {key: "*.example.com.", expected: false}, - {key: "https://*.example.com", expected: false}, - {key: "*.example.com/foo", expected: false}, + {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) { - assert.Check(t, is.Equal(IsPattern(tc.key), tc.expected)) + 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)) + } }) } } diff --git a/docs/reference/commandline/login.md b/docs/reference/commandline/login.md index 3bd979aa3e70..e74df3d2da50 100644 --- a/docs/reference/commandline/login.md +++ b/docs/reference/commandline/login.md @@ -196,9 +196,14 @@ hostname; it never matches a `.`. For example, `abc.*.example.com` matches the pattern: `*.example.com` does not match `foo.example.com:5000`, but `*.example.com:5000` does. -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. Patterns such as -`*.com` or `foo.*.com` are not valid, and are ignored. +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