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 <andreifdaaron@gmail.com>

* feat: remove ForceDockerClientAuth flag and use only authz policies to determine the docker specific behavior

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>

---------

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
This commit is contained in:
Andrei Aaron
2026-04-17 09:10:02 +03:00
committed by GitHub
parent d443346196
commit 7ceb01dcff
3 changed files with 137 additions and 1 deletions
+12 -1
View File
@@ -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
+16
View File
@@ -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 {
+109
View File
@@ -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)
})
})
}