From 0eafaeb04384d605a98bbd5c65103960e7385202 Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 25 Apr 2026 07:58:22 +0200 Subject: [PATCH] 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/" rather than the "Docker-Client/" 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 --- pkg/api/authn.go | 7 ++++++- pkg/api/controller_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/pkg/api/authn.go b/pkg/api/authn.go index f5c7f5ed..3b00fa75 100644 --- a/pkg/api/authn.go +++ b/pkg/api/authn.go @@ -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/" 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() diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index 217f1ca4..2fb0ee21 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -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) }) }) }