Skip to content

Commit 0eafaeb

Browse files
authored
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
1 parent 97b65b5 commit 0eafaeb

2 files changed

Lines changed: 44 additions & 1 deletion

File tree

pkg/api/authn.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,12 @@ func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFun
416416

417417
isMgmtRequested := request.RequestURI == constants.FullMgmt
418418
isV2Requested := strings.TrimSuffix(request.URL.Path, "/") == constants.RoutePrefix
419-
isDockerClient := strings.Contains(request.Header.Get("User-Agent"), "Docker-Client")
419+
// Match Docker daemon-proxied requests regardless of the upstream client tool.
420+
// The Docker daemon always prefixes its UA with "docker/<version>" when proxying,
421+
// while the upstream tool (docker CLI, compose, buildx, etc.) appears inside
422+
// "UpstreamClient(...)". Direct Docker CLI requests use "Docker-Client/...".
423+
ua := request.Header.Get("User-Agent")
424+
isDockerClient := strings.Contains(ua, "Docker-Client") || strings.HasPrefix(ua, "docker/")
420425

421426
// Get auth config safely
422427
authConfig := ctlr.Config.CopyAuthConfig()

pkg/api/controller_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14261,6 +14261,36 @@ func TestDockerClientV2ChallengeWorkaround(t *testing.T) {
1426114261
So(resp, ShouldNotBeNil)
1426214262
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
1426314263

14264+
// Docker Compose client without credentials should get 401
14265+
// (daemon-proxied with compose upstream client, UA starts with "docker/")
14266+
composeUA := "docker/29.4.0 go/go1.24.2 UpstreamClient(compose/v5.1.2)"
14267+
resp, err = resty.R().
14268+
SetHeader("User-Agent", composeUA).
14269+
Get(baseURL + "/v2/")
14270+
So(err, ShouldBeNil)
14271+
So(resp, ShouldNotBeNil)
14272+
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
14273+
So(resp.Header().Get("WWW-Authenticate"), ShouldContainSubstring, "Basic realm=")
14274+
14275+
// Docker Compose client with valid credentials should get 200
14276+
resp, err = resty.R().
14277+
SetHeader("User-Agent", composeUA).
14278+
SetBasicAuth(htpasswdUsername, htpasswdPassword).
14279+
Get(baseURL + "/v2/")
14280+
So(err, ShouldBeNil)
14281+
So(resp, ShouldNotBeNil)
14282+
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
14283+
14284+
// Docker Buildx client without credentials should get 401
14285+
// (daemon-proxied with buildx upstream client)
14286+
resp, err = resty.R().
14287+
SetHeader("User-Agent", "docker/29.4.0 go/go1.24.2 UpstreamClient(buildx/v0.21.2)").
14288+
Get(baseURL + "/v2/")
14289+
So(err, ShouldBeNil)
14290+
So(resp, ShouldNotBeNil)
14291+
So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized)
14292+
So(resp.Header().Get("WWW-Authenticate"), ShouldContainSubstring, "Basic realm=")
14293+
1426414294
// Podman client without credentials should get 200 (unaffected by workaround)
1426514295
resp, err = resty.R().
1426614296
SetHeader("User-Agent", "containers/5.33.0 (github.com/containers/image)").
@@ -14309,6 +14339,14 @@ func TestDockerClientV2ChallengeWorkaround(t *testing.T) {
1430914339
So(err, ShouldBeNil)
1431014340
So(resp, ShouldNotBeNil)
1431114341
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
14342+
14343+
// Docker Compose client without credentials should get 200 (no mixed policies)
14344+
resp, err = resty.R().
14345+
SetHeader("User-Agent", "docker/29.4.0 go/go1.24.2 UpstreamClient(compose/v5.1.2)").
14346+
Get(baseURL + "/v2/")
14347+
So(err, ShouldBeNil)
14348+
So(resp, ShouldNotBeNil)
14349+
So(resp.StatusCode(), ShouldEqual, http.StatusOK)
1431214350
})
1431314351
})
1431414352
}

0 commit comments

Comments
 (0)