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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user