From 7ceb01dcffe0f82467776fcb78abc3df5450a101 Mon Sep 17 00:00:00 2001 From: Andrei Aaron Date: Fri, 17 Apr 2026 09:10:02 +0300 Subject: [PATCH] fix(auth): add workaround for Docker client auth with mixed anonymous policies (#3868) * fix(auth): add workaround for Docker client auth with mixed anonymous policies Docker client fails to authenticate to protected repositories when basic auth (htpasswd/LDAP) is used with mixed access policies (some repos anonymous, some requiring auth). This happens because Docker determines whether to send credentials based on the /v2/ response - if it returns 200, Docker assumes no auth is needed anywhere. Add `forceDockerClientAuth` config option that, when enabled, forces 401 on /v2/ for Docker clients, triggering Docker's authentication flow. This workaround only affects Docker clients (detected via User-Agent). Podman and other OCI-compliant clients are unaffected. Refs: https://github.com/opencontainers/wg-auth/blob/main/docs/implementations/moby.md Signed-off-by: Andrei Aaron * feat: remove ForceDockerClientAuth flag and use only authz policies to determine the docker specific behavior Signed-off-by: Andrei Aaron --------- Signed-off-by: Andrei Aaron --- pkg/api/authn.go | 13 ++++- pkg/api/config/config.go | 16 ++++++ pkg/api/controller_test.go | 109 +++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/pkg/api/authn.go b/pkg/api/authn.go index be77a73d..f5c7f5ed 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -415,6 +415,8 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun } isMgmtRequested := request.RequestURI == constants.FullMgmt + isV2Requested := strings.TrimSuffix(request.URL.Path, "/") == constants.RoutePrefix + isDockerClient := strings.Contains(request.Header.Get("User-Agent"), "Docker-Client") // Get auth config safely authConfig := ctlr.Config.CopyAuthConfig() @@ -466,7 +468,16 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun // If no credentials provided - check for anonymous / mgmt requests case allowAnonymous || isMgmtRequested: - authenticated = true + // Docker workaround: force 401 on /v2/ when anonymous policies coexist with + // authenticated-only policies. Otherwise Docker treats 200 on /v2/ as "no auth" + // and will not send stored credentials for protected repositories. + // See: https://github.com/opencontainers/wg-auth/blob/main/docs/implementations/moby.md + hasMixedPolicy := accessControlConfig.HasMixedAnonymousAndAuthenticatedPolicies() + if isDockerClient && isV2Requested && hasMixedPolicy && authConfig.CanAuthenticateWithBasicCredentials() { + authenticated = false + } else { + authenticated = true + } } // If error occurred during authn process - return 500 error diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index e0abe4e2..f74cfab5 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -186,6 +186,15 @@ func (a *AuthConfig) IsBasicAuthnEnabled() bool { return a.IsHtpasswdAuthEnabled() || a.IsLdapAuthEnabled() || a.IsOpenIDAuthEnabled() || a.IsAPIKeyEnabled() } +// CanAuthenticateWithBasicCredentials reports whether the server can authenticate a client +// using HTTP Basic credentials (username/password) in the Authorization header. +// +// This is intentionally narrower than IsBasicAuthnEnabled(): OpenID is not a Basic +// credential flow, while htpasswd/LDAP/API keys are. +func (a *AuthConfig) CanAuthenticateWithBasicCredentials() bool { + return a.IsHtpasswdAuthEnabled() || a.IsLdapAuthEnabled() || a.IsAPIKeyEnabled() +} + // GetFailDelay returns the configured fail delay for authentication attempts. func (a *AuthConfig) GetFailDelay() int { if a == nil { @@ -508,6 +517,13 @@ func (config *AccessControlConfig) AnonymousPolicyExists() bool { return false } +// HasMixedAnonymousAndAuthenticatedPolicies reports whether the access control configuration contains +// at least one anonymous repository policy AND at least one authenticated-only policy +// (default/admin/user-specific). +func (config *AccessControlConfig) HasMixedAnonymousAndAuthenticatedPolicies() bool { + return config != nil && config.AnonymousPolicyExists() && !config.ContainsOnlyAnonymousPolicy() +} + // ContainsOnlyAnonymousPolicy checks if the access control configuration contains only anonymous policies. func (config *AccessControlConfig) ContainsOnlyAnonymousPolicy() bool { if config == nil { diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index abd997b8..527cd1bd 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -14202,3 +14202,112 @@ func TestDynamicTLSCertificateReloading(t *testing.T) { }) }) } + +func TestDockerClientV2ChallengeWorkaround(t *testing.T) { + Convey("Test Docker client /v2/ challenge workaround", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + htpasswdUsername, seedUser := test.GenerateRandomString() + htpasswdPassword, seedPass := test.GenerateRandomString() + htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString(htpasswdUsername, htpasswdPassword)) + + Convey("With mixed anonymous and authenticated repository policies", func() { + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + "public/**": config.PolicyGroup{ + AnonymousPolicy: []string{"read"}, + }, + "private/**": config.PolicyGroup{ + Policies: []config.Policy{ + {Actions: []string{"read", "write"}, Users: []string{htpasswdUsername}}, + }, + }, + }, + } + + dir := t.TempDir() + ctlr := makeController(conf, dir) + ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass). + Msg("random seed for username & password") + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + + defer cm.StopServer() + + // Docker client without credentials should get 401 + resp, err := resty.R(). + SetHeader("User-Agent", "docker/26.1.3 go/go1.22.2 UpstreamClient(Docker-Client/26.1.3 (linux))"). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + So(resp.Header().Get("WWW-Authenticate"), ShouldContainSubstring, "Basic realm=") + + // Docker client with valid credentials should get 200 + resp, err = resty.R(). + SetHeader("User-Agent", "docker/26.1.3 go/go1.22.2 UpstreamClient(Docker-Client/26.1.3 (linux))"). + SetBasicAuth(htpasswdUsername, htpasswdPassword). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // Podman client without credentials should get 200 (unaffected by workaround) + resp, err = resty.R(). + SetHeader("User-Agent", "containers/5.33.0 (github.com/containers/image)"). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + + // Generic client without credentials should get 200 (unaffected) + resp, err = resty.R(). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) + + Convey("With only anonymous repository policies", func() { + conf := config.New() + conf.HTTP.Port = port + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + conf.HTTP.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + "**": config.PolicyGroup{ + AnonymousPolicy: []string{"read"}, + }, + }, + } + + dir := t.TempDir() + ctlr := makeController(conf, dir) + ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass). + Msg("random seed for username & password") + cm := test.NewControllerManager(ctlr) + cm.StartAndWait(port) + + defer cm.StopServer() + + // Docker client without credentials should get 200 (no mixed policies) + resp, err := resty.R(). + SetHeader("User-Agent", "docker/26.1.3 go/go1.22.2 UpstreamClient(Docker-Client/26.1.3 (linux))"). + Get(baseURL + "/v2/") + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusOK) + }) + }) +}