mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 12:58:02 +08:00
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:
+12
-1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user