fix(api): recognize Docker Compose/Buildx User-Agent in v2 challenge workaround (#3992)

Docker Compose and Buildx proxy through the Docker daemon, which sends
a User-Agent starting with "docker/<version>" rather than the
"Docker-Client/<version>" string sent by direct Docker CLI pulls.
This caused compose/buildx pulls to skip the 401 challenge on
registries with mixed anonymous/authenticated access policies,
resulting in 'unauthorized' errors.

Add strings.HasPrefix(ua, "docker/") alongside the existing
Docker-Client check so daemon-proxied requests from any upstream
tool (compose, buildx, etc.) are handled correctly.

Fixes #3991
This commit is contained in:
Marco
2026-04-25 07:58:22 +02:00
committed by GitHub
parent 97b65b5b39
commit 0eafaeb043
2 changed files with 44 additions and 1 deletions
+6 -1
View File
@@ -416,7 +416,12 @@ 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")
// Match Docker daemon-proxied requests regardless of the upstream client tool.
// The Docker daemon always prefixes its UA with "docker/<version>" when proxying,
// while the upstream tool (docker CLI, compose, buildx, etc.) appears inside
// "UpstreamClient(...)". Direct Docker CLI requests use "Docker-Client/...".
ua := request.Header.Get("User-Agent")
isDockerClient := strings.Contains(ua, "Docker-Client") || strings.HasPrefix(ua, "docker/")
// Get auth config safely
authConfig := ctlr.Config.CopyAuthConfig()
+38
View File
@@ -14261,6 +14261,36 @@ func TestDockerClientV2ChallengeWorkaround(t *testing.T) {
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// Docker Compose client without credentials should get 401
// (daemon-proxied with compose upstream client, UA starts with "docker/")
composeUA := "docker/29.4.0 go/go1.24.2 UpstreamClient(compose/v5.1.2)"
resp, err = resty.R().
SetHeader("User-Agent", composeUA).
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 Compose client with valid credentials should get 200
resp, err = resty.R().
SetHeader("User-Agent", composeUA).
SetBasicAuth(htpasswdUsername, htpasswdPassword).
Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// Docker Buildx client without credentials should get 401
// (daemon-proxied with buildx upstream client)
resp, err = resty.R().
SetHeader("User-Agent", "docker/29.4.0 go/go1.24.2 UpstreamClient(buildx/v0.21.2)").
Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
So(resp.Header().Get("WWW-Authenticate"), ShouldContainSubstring, "Basic realm=")
// 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)").
@@ -14309,6 +14339,14 @@ func TestDockerClientV2ChallengeWorkaround(t *testing.T) {
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
// Docker Compose client without credentials should get 200 (no mixed policies)
resp, err = resty.R().
SetHeader("User-Agent", "docker/29.4.0 go/go1.24.2 UpstreamClient(compose/v5.1.2)").
Get(baseURL + "/v2/")
So(err, ShouldBeNil)
So(resp, ShouldNotBeNil)
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
})
})
}