From 297640764bb5908347414ba54ed0c48461039cd1 Mon Sep 17 00:00:00 2001 From: HarnageaGabriel Date: Wed, 12 Aug 2026 14:04:53 +0200 Subject: [PATCH] registry: normalize server address to lowercase for credential lookup Login credentials fail when the registry hostname's case at `docker login` differs from the case used in an image reference at push/pull time (e.g. `docker login Registry.Example.com` then `docker push Registry.Example.com/img`). distribution/reference does not lowercase the domain component of a reference, so credentials ended up stored and looked up under different keys depending on casing. Normalize the lookup key to lowercase in the config file's canonical credential-lookup key (getAuthConfigKey), covering all callers of GetAuthConfig/GetCredentialsStore. Also lowercase the server address at the credential-store write path in `docker login`, since the file store's Store/Erase use an exact-match key and are not routed through the lookup normalization. `docker logout` hits the same Store/Erase path directly, so it needs the same treatment. Rather than replacing the looked-up address with its lowercase form (which would stop `docker logout` from finding credentials stored under their original case, e.g. by a version of the CLI predating this normalization), try the lowercase variants in addition to the original-case ones. Signed-off-by: HarnageaGabriel --- cli/command/registry/login.go | 1 + cli/command/registry/login_test.go | 22 +++++++++++++ cli/command/registry/logout.go | 5 +++ cli/command/registry/logout_test.go | 48 +++++++++++++++++++++++++++++ cli/command/registry_test.go | 7 ++++- cli/config/configfile/file.go | 1 + cli/config/configfile/file_test.go | 14 +++++++++ 7 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 cli/command/registry/logout_test.go diff --git a/cli/command/registry/login.go b/cli/command/registry/login.go index 471e73f8238e..47ff8476f8cd 100644 --- a/cli/command/registry/login.go +++ b/cli/command/registry/login.go @@ -320,6 +320,7 @@ func loginWithDeviceCodeFlow(ctx context.Context, dockerCLI command.Cli) (msg st } func storeCredentials(cfg *configfile.ConfigFile, authConfig registrytypes.AuthConfig) error { + authConfig.ServerAddress = strings.ToLower(authConfig.ServerAddress) creds := cfg.GetCredentialsStore(authConfig.ServerAddress) if err := creds.Store(configtypes.AuthConfig{ Username: authConfig.Username, diff --git a/cli/command/registry/login_test.go b/cli/command/registry/login_test.go index 32d0e6f8cf7c..ab45711bcd6a 100644 --- a/cli/command/registry/login_test.go +++ b/cli/command/registry/login_test.go @@ -149,6 +149,28 @@ func TestRunLogin(t *testing.T) { }, }, }, + { + doc: "mixed-case server address updates lowercase credential key", + priorCredentials: map[string]configtypes.AuthConfig{ + "myregistry.example.com": { + Username: "my-username", + Password: "old-password", + ServerAddress: "myregistry.example.com", + }, + }, + input: loginOptions{ + serverAddress: "MyRegistry.Example.com", + user: "my-username", + password: "new-password", + }, + expectedCredentials: map[string]configtypes.AuthConfig{ + "myregistry.example.com": { + Username: "my-username", + Password: "new-password", + ServerAddress: "myregistry.example.com", + }, + }, + }, { doc: "unknown user w/ prior credentials", priorCredentials: map[string]configtypes.AuthConfig{ diff --git a/cli/command/registry/logout.go b/cli/command/registry/logout.go index 16218f67d9a7..0a04c8589380 100644 --- a/cli/command/registry/logout.go +++ b/cli/command/registry/logout.go @@ -3,6 +3,7 @@ package registry import ( "context" "fmt" + "strings" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" @@ -60,6 +61,10 @@ func runLogout(ctx context.Context, dockerCLI command.Cli, serverAddress string) // the tries below are kept for backward compatibility where a user could have // saved the registry in one of the following format. regsToLogout = append(regsToLogout, hostnameAddress, "http://"+hostnameAddress, "https://"+hostnameAddress) + // Also try lowercase variants for backward compatibility with case-insensitive + // login normalization in getAuthConfigKey and storeCredentials. + lowercaseAddress := strings.ToLower(hostnameAddress) + regsToLogout = append(regsToLogout, lowercaseAddress, "http://"+lowercaseAddress, "https://"+lowercaseAddress) } if isDefaultRegistry { diff --git a/cli/command/registry/logout_test.go b/cli/command/registry/logout_test.go new file mode 100644 index 000000000000..a073037ace8d --- /dev/null +++ b/cli/command/registry/logout_test.go @@ -0,0 +1,48 @@ +package registry + +import ( + "context" + "path/filepath" + "testing" + + "github.com/docker/cli/cli/config/configfile" + configtypes "github.com/docker/cli/cli/config/types" + "github.com/docker/cli/internal/test" + "gotest.tools/v3/assert" +) + +func TestRunLogoutMixedCaseServerAddress(t *testing.T) { + cfg := configfile.New(filepath.Join(t.TempDir(), "config.json")) + cli := test.NewFakeCli(nil) + cli.SetConfigFile(cfg) + + const serverAddress = "myregistry.example.com" + assert.NilError(t, cfg.GetCredentialsStore(serverAddress).Store(configtypes.AuthConfig{ + Username: "my-username", + Password: "my-password", + ServerAddress: serverAddress, + })) + + assert.NilError(t, runLogout(context.Background(), cli, "MyRegistry.Example.com")) + credentials, err := cfg.GetAllCredentials() + assert.NilError(t, err) + assert.DeepEqual(t, credentials, map[string]configtypes.AuthConfig{}) +} + +func TestRunLogoutUpperCaseServerAddress(t *testing.T) { + cfg := configfile.New(filepath.Join(t.TempDir(), "config.json")) + cli := test.NewFakeCli(nil) + cli.SetConfigFile(cfg) + + const serverAddress = "MYREGISTRY.EXAMPLE.COM" + assert.NilError(t, cfg.GetCredentialsStore(serverAddress).Store(configtypes.AuthConfig{ + Username: "my-username", + Password: "my-password", + ServerAddress: serverAddress, + })) + + assert.NilError(t, runLogout(context.Background(), cli, serverAddress)) + credentials, err := cfg.GetAllCredentials() + assert.NilError(t, err) + assert.DeepEqual(t, credentials, map[string]configtypes.AuthConfig{}) +} diff --git a/cli/command/registry_test.go b/cli/command/registry_test.go index 751711f36391..8b9a7b5a4556 100644 --- a/cli/command/registry_test.go +++ b/cli/command/registry_test.go @@ -109,7 +109,8 @@ func TestRetrieveAuthTokenFromImage(t *testing.T) { "localhost": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="}, "localhost:5000": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="}, "registry-1.docker.io": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="}, - "registry.hub.docker.com": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="} + "registry.hub.docker.com": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="}, + "registry.example.com": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="} } }` cfg := configfile.ConfigFile{} @@ -157,6 +158,10 @@ func TestRetrieveAuthTokenFromImage(t *testing.T) { prefix: "registry.hub.docker.com", expectedAuthCfg: registry.AuthConfig{Username: "username", Password: "password", ServerAddress: "registry.hub.docker.com"}, }, + { + prefix: "Registry.Example.com", + expectedAuthCfg: registry.AuthConfig{Username: "username", Password: "password", ServerAddress: "registry.example.com"}, + }, { prefix: "[::1]", expectedAddress: "[::1]", diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index 26e148f05987..f109d656bbd4 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -42,6 +42,7 @@ const authConfigKey = "https://index.docker.io/v1/" // // [registry.GetAuthConfigKey]: https://pkg.go.dev/github.com/docker/docker@v28.5.1+incompatible/registry#GetAuthConfigKey func getAuthConfigKey(domainName string) string { + domainName = strings.ToLower(domainName) if domainName == "docker.io" || domainName == "index.docker.io" { return authConfigKey } diff --git a/cli/config/configfile/file_test.go b/cli/config/configfile/file_test.go index 92df02c74352..e4ca16004a82 100644 --- a/cli/config/configfile/file_test.go +++ b/cli/config/configfile/file_test.go @@ -15,6 +15,20 @@ import ( "gotest.tools/v3/golden" ) +func TestGetAuthConfigKey(t *testing.T) { + tests := map[string]string{ + "MyRegistry.Example.com": "myregistry.example.com", + "DOCKER.IO": authConfigKey, + "Index.Docker.IO": authConfigKey, + } + + for domainName, expected := range tests { + t.Run(domainName, func(t *testing.T) { + assert.Equal(t, getAuthConfigKey(domainName), expected) + }) + } +} + func TestEncodeAuth(t *testing.T) { newAuthConfig := &types.AuthConfig{Username: "ken", Password: "test"} authStr := encodeAuth(newAuthConfig)