1
0
mirror of https://github.com/goharbor/harbor.git synced 2026-10-24 21:59:32 +03:00

[cherry-pick] fix(policy): stop User-Agent from bypassing content-trust and vulnerability policies (GHSA-wrw5-gmvj-gf23)

(cherry picked from commit f234c0cb68)
Signed-off-by: Vadim Bauer <vb@container-registry.com>
This commit is contained in:
Vadim Bauer
2026-10-06 14:03:35 +02:00
parent 401e734296
commit 9d255ebe1a
4 changed files with 150 additions and 11 deletions

View File

@@ -83,6 +83,17 @@ func signatureChecking(ctx context.Context, r *http.Request, af lib.ArtifactInfo
return nil
}
// Opt-in, content-trust-only bootstrap: allow a push-capable signing client to pull the
// (still unsigned) subject manifest so it can create the first cosign/notation signature.
// This is the legacy User-Agent-based exemption removed from SkipPolicyChecking; because
// the User-Agent is spoofable, it is honoured only when the operator
// sets CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED=true, and it never affects the
// vulnerability-prevention policy.
if util.LegacySignerPullEnabled() && util.LegacySignerBootstrapPull(r, projectID) {
logger.Debugf("legacy signer bootstrap pull (opt-in) of artifact %s@%s, content-trust check skipped", af.Repository, af.Digest)
return nil
}
if len(art.Accessories) == 0 {
return errors.New(nil).WithCode(errors.PROJECTPOLICYVIOLATION)
}

View File

@@ -18,10 +18,12 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stretchr/testify/suite"
"github.com/goharbor/harbor/src/common/rbac"
"github.com/goharbor/harbor/src/common/security"
"github.com/goharbor/harbor/src/controller/artifact"
"github.com/goharbor/harbor/src/controller/artifact/processor/image"
@@ -187,13 +189,18 @@ func (suite *ContentTrustMiddlewareTestSuite) TestScannerPulling() {
suite.Equal(rr.Code, http.StatusOK)
}
func (suite *ContentTrustMiddlewareTestSuite) TestCosignPulling() {
// A push-capable principal must NOT skip the content-trust policy by
// spoofing "User-Agent: cosign". With the exemption removed and the opt-in hatch off, the
// unsigned image is rejected regardless of the User-Agent.
func (suite *ContentTrustMiddlewareTestSuite) TestSpoofedCosignUserAgentBlocked() {
suite.T().Setenv("CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED", "false")
mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil)
mock.OnAnything(suite.projectController, "GetByName").Return(suite.project, nil)
mock.OnAnything(suite.accessMgr, "List").Return([]accessorymodel.Accessory{}, nil)
securityCtx := &securitytesting.Context{}
mock.OnAnything(securityCtx, "Name").Return("v2token")
mock.OnAnything(securityCtx, "Can").Return(true, nil)
securityCtx.On("Can", mock.Anything, rbac.ActionScannerPull, mock.Anything).Return(false)
securityCtx.On("Can", mock.Anything, rbac.ActionPush, mock.Anything).Return(true)
mock.OnAnything(securityCtx, "IsAuthenticated").Return(true)
req := suite.makeRequest(true)
@@ -201,7 +208,68 @@ func (suite *ContentTrustMiddlewareTestSuite) TestCosignPulling() {
rr := httptest.NewRecorder()
ContentTrust()(suite.next).ServeHTTP(rr, req)
suite.Equal(rr.Code, http.StatusOK)
suite.Equal(http.StatusPreconditionFailed, rr.Code)
}
// The opt-in legacy bootstrap (CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED=true) restores the
// pull-before-first-signature exemption for content-trust, for operators who need it.
func (suite *ContentTrustMiddlewareTestSuite) TestLegacySignerPullOptIn() {
suite.T().Setenv("CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED", "true")
mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil)
mock.OnAnything(suite.projectController, "GetByName").Return(suite.project, nil)
mock.OnAnything(suite.accessMgr, "List").Return([]accessorymodel.Accessory{}, nil)
securityCtx := &securitytesting.Context{}
mock.OnAnything(securityCtx, "Name").Return("v2token")
securityCtx.On("Can", mock.Anything, rbac.ActionScannerPull, mock.Anything).Return(false)
securityCtx.On("Can", mock.Anything, rbac.ActionPush, mock.Anything).Return(true)
mock.OnAnything(securityCtx, "IsAuthenticated").Return(true)
req := suite.makeRequest(true)
req = req.WithContext(security.NewContext(req.Context(), securityCtx))
rr := httptest.NewRecorder()
ContentTrust()(suite.next).ServeHTTP(rr, req)
suite.Equal(http.StatusOK, rr.Code)
}
func (suite *ContentTrustMiddlewareTestSuite) serveLegacySignerPull(userAgent string, canPush bool) int {
mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil)
mock.OnAnything(suite.projectController, "GetByName").Return(suite.project, nil)
mock.OnAnything(suite.accessMgr, "List").Return([]accessorymodel.Accessory{}, nil)
securityCtx := &securitytesting.Context{}
mock.OnAnything(securityCtx, "Name").Return("v2token")
securityCtx.On("Can", mock.Anything, rbac.ActionScannerPull, mock.Anything).Return(false)
securityCtx.On("Can", mock.Anything, rbac.ActionPush, mock.Anything).Return(canPush)
mock.OnAnything(securityCtx, "IsAuthenticated").Return(true)
req := suite.makeRequest()
req.Header.Set("User-Agent", userAgent)
req = req.WithContext(security.NewContext(req.Context(), securityCtx))
rr := httptest.NewRecorder()
ContentTrust()(suite.next).ServeHTTP(rr, req)
return rr.Code
}
func (suite *ContentTrustMiddlewareTestSuite) TestLegacySignerPullDisabledWhenUnset() {
suite.T().Setenv("CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED", "")
os.Unsetenv("CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED")
suite.Equal(http.StatusPreconditionFailed, suite.serveLegacySignerPull("cosign/2.4.0", true))
}
func (suite *ContentTrustMiddlewareTestSuite) TestLegacySignerPullOptInNotation() {
suite.T().Setenv("CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED", "true")
suite.Equal(http.StatusOK, suite.serveLegacySignerPull("notation/1.2.0", true))
}
func (suite *ContentTrustMiddlewareTestSuite) TestLegacySignerPullOptInRequiresSignerUserAgent() {
suite.T().Setenv("CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED", "true")
suite.Equal(http.StatusPreconditionFailed, suite.serveLegacySignerPull("docker/24.0", true))
}
func (suite *ContentTrustMiddlewareTestSuite) TestLegacySignerPullOptInRequiresPush() {
suite.T().Setenv("CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED", "true")
suite.Equal(http.StatusPreconditionFailed, suite.serveLegacySignerPull("cosign/2.4.0", false))
}
// pull a public project a un-signed image when policy checker is enabled.

View File

@@ -17,6 +17,7 @@ package util
import (
"fmt"
"net/http"
"os"
"path"
"strings"
@@ -62,15 +63,18 @@ func ParseProjectName(r *http.Request) string {
func SkipPolicyChecking(r *http.Request, projectID, artID int64) (bool, error) {
secCtx, ok := security.FromContext(r.Context())
// 1, scanner pull access can bypass.
// 2, cosign/notation pull can bypass, it needs to pull the manifest before pushing the signature.
// 3, pull cosign/notation signature can bypass.
// Scanner pull is authorised by the scanner-pull RBAC action, which is carried in the
// signed bearer token's scope and cannot be forged, so it may bypass the policy.
//
// The former cosign/notation exemptions here were gated on the request User-Agent, a
// fully client-controlled string (CWE-807): any push-capable
// principal could skip both the content-trust and the vulnerability-prevention policy by
// sending "User-Agent: cosign". They have been removed. A genuinely signed artifact is
// still recognised below by its actual signature accessory, not by any client header.
// The pull-before-first-signature bootstrap for content-trust is preserved, opt-in only,
// by the content-trust middleware via LegacySignerBootstrapPull.
if ok && secCtx.Name() == "v2token" {
if secCtx.Can(r.Context(), rbac.ActionScannerPull, project.NewNamespace(projectID).Resource(rbac.ResourceRepository)) ||
(secCtx.Can(r.Context(), rbac.ActionPush, project.NewNamespace(projectID).Resource(rbac.ResourceRepository)) &&
strings.Contains(r.UserAgent(), "cosign")) ||
(secCtx.Can(r.Context(), rbac.ActionPush, project.NewNamespace(projectID).Resource(rbac.ResourceRepository)) &&
strings.Contains(r.UserAgent(), "notation")) {
if secCtx.Can(r.Context(), rbac.ActionScannerPull, project.NewNamespace(projectID).Resource(rbac.ResourceRepository)) {
return true, nil
}
}
@@ -85,3 +89,33 @@ func SkipPolicyChecking(r *http.Request, projectID, artID int64) (bool, error) {
return false, nil
}
// LegacySignerPullEnabled reports whether the operator has opted in to the legacy,
// User-Agent-based signer-pull exemption for the content-trust policy. It is off by default.
//
// The exemption exists so a signing client (cosign/notation) can pull an as-yet unsigned
// subject manifest in order to create its first signature under an enabled content-trust
// policy. Its only signal is the client-supplied User-Agent, which is spoofable (CWE-807);
// it is therefore honoured only when an operator explicitly sets
// CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED=true, and never for the vulnerability-prevention
// policy.
func LegacySignerPullEnabled() bool {
return os.Getenv("CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED") == "true"
}
// LegacySignerBootstrapPull reports whether the request looks like a push-capable signing
// client fetching a subject manifest before pushing its first signature. The decision
// includes the client-supplied User-Agent and is therefore spoofable, so callers must gate
// it behind LegacySignerPullEnabled and must never use it for the vulnerability-prevention
// policy.
func LegacySignerBootstrapPull(r *http.Request, projectID int64) bool {
secCtx, ok := security.FromContext(r.Context())
if !ok || secCtx.Name() != "v2token" {
return false
}
if !secCtx.Can(r.Context(), rbac.ActionPush, project.NewNamespace(projectID).Resource(rbac.ResourceRepository)) {
return false
}
ua := r.UserAgent()
return strings.Contains(ua, "cosign") || strings.Contains(ua, "notation")
}

View File

@@ -23,6 +23,7 @@ import (
"github.com/docker/distribution/manifest/manifestlist"
"github.com/stretchr/testify/suite"
"github.com/goharbor/harbor/src/common/rbac"
"github.com/goharbor/harbor/src/common/security"
"github.com/goharbor/harbor/src/controller/artifact"
"github.com/goharbor/harbor/src/controller/artifact/processor/image"
@@ -210,6 +211,31 @@ func (suite *MiddlewareTestSuite) TestScannerPulling() {
suite.Equal(rr.Code, http.StatusOK)
}
// A push-capable principal must NOT skip the vulnerability-prevention
// policy by spoofing "User-Agent: cosign". The scanner-pull action is the only bypass; a
// non-scanner push caller with a cosign User-Agent is still subject to the policy, so an
// unscanned image is rejected, even with the opt-in content-trust hatch enabled.
func (suite *MiddlewareTestSuite) TestSpoofedUserAgentDoesNotSkipVulnerable() {
suite.T().Setenv("CONTENT_TRUST_LEGACY_SIGNER_PULL_ENABLED", "true")
mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil)
mock.OnAnything(suite.projectController, "Get").Return(suite.project, nil)
mock.OnAnything(suite.accessMgr, "List").Return([]accessorymodel.Accessory{}, nil)
securityCtx := &securitytesting.Context{}
mock.OnAnything(securityCtx, "Name").Return("v2token")
securityCtx.On("Can", mock.Anything, rbac.ActionScannerPull, mock.Anything).Return(false)
securityCtx.On("Can", mock.Anything, rbac.ActionPush, mock.Anything).Return(true)
mock.OnAnything(suite.checker, "IsScannable").Return(true, nil)
mock.OnAnything(suite.scanController, "GetVulnerable").Return(nil, errors.NotFoundError(nil))
req := suite.makeRequest()
req.Header.Set("User-Agent", "cosign/2.4.0")
req = req.WithContext(security.NewContext(req.Context(), securityCtx))
rr := httptest.NewRecorder()
Middleware()(suite.next).ServeHTTP(rr, req)
suite.Equal(http.StatusPreconditionFailed, rr.Code)
}
func (suite *MiddlewareTestSuite) TestCheckIsScannableFailed() {
mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil)
mock.OnAnything(suite.projectController, "Get").Return(suite.project, nil)