VYPR
High severity7.1GHSA Advisory· Published Jun 11, 2026

DevGuard has improper authorization on public assets

CVE-2026-48089

Description

In DevGuard, any authenticated user can modify VEX rules, triage decisions, and other sensitive data on public assets due to missing authorization.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

In DevGuard, any authenticated user can modify VEX rules, triage decisions, and other sensitive data on public assets due to missing authorization.

Vulnerability

In DevGuard API instances running versions prior to v1.4.2, the authorization middleware incorrectly grants write access to all authenticated users on public assets. The flaw affects all vulnerability-triage write endpoints under a public asset, including: VEX rule create/update/reapply/delete, dependency-vuln event creation (accept/reject/mitigate decisions), batch event creation, vuln sync, mitigation, license risk creation, external reference writes, artifact creation, and license refresh. Attackers need a valid account on the instance but do not require membership in the victim organization, project, or asset [1][2][3].

Exploitation

An attacker with any valid authenticated session on the DevGuard instance can send crafted HTTP requests to the affected write endpoints of any public asset. No prior knowledge of the victim organization is needed; the public-read exemption in the access-control middleware bypasses membership checks. The attacker can create, update, reapply, or delete VEX rules, as well as alter dependency-vuln events, license risks, external references, and artifacts [1][2][3].

Impact

Successful exploitation compromises the integrity of the vulnerability picture for public assets. An attacker can mark CVEs as false-positive, silence vulnerabilities, attach misleading justifications, or delete legitimate triage rules. Because public assets are consumed by third parties (downstream users, supply-chain consumers) via published vex.json and sbom.json, the blast radius extends to anyone relying on that data. Private assets are not affected [2][3].

Mitigation

The fix is included in DevGuard version v1.4.2, which adds a DisallowPublicRequests middleware and corrects the RBAC checks on write endpoints. Users should upgrade as soon as possible. As a workaround, administrators can change affected assets from public to private in the asset settings, which removes the public-read exemption and restores proper authorization [1][2][3].

AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
1be88ec1309a

adds DisablePublicRequest middleware, adds router_test to test if middleware is applied at the route

https://github.com/l3montree-dev/devguardTim BastinMay 20, 2026via ghsa-ref
10 files changed · +442 21
  • middlewares/access_control_middlewares.go+10 0 modified
    @@ -95,6 +95,16 @@ func NeededScope(NeededScopes []string) shared.MiddlewareFunc {
     	}
     }
     
    +func DisallowPublicRequests(next echo.HandlerFunc) echo.HandlerFunc {
    +	return func(ctx shared.Context) error {
    +		if shared.IsPublicRequest(ctx) {
    +			slog.Warn("access denied for public request in DisallowPublicRequests middleware")
    +			return echo.NewHTTPError(404, "could not find resource")
    +		}
    +		return next(ctx)
    +	}
    +}
    +
     func AssetAccessControlFactory(assetRepository shared.AssetRepository) shared.RBACMiddleware {
     	return func(obj shared.Object, act shared.Action) shared.MiddlewareFunc {
     		return func(next echo.HandlerFunc) echo.HandlerFunc {
    
  • router/artifact_router.go+4 2 modified
    @@ -30,8 +30,10 @@ func NewArtifactRouter(
     	assetVersionGroup AssetVersionRouter,
     	artifactController *controllers.ArtifactController,
     	artifactRepository shared.ArtifactRepository,
    +	assetRepository shared.AssetRepository,
     ) ArtifactRouter {
     	artifactRouter := assetVersionGroup.Group.Group("/artifacts/:artifactName", middlewares.ArtifactMiddleware(artifactRepository))
    +	assetScopedRBAC := middlewares.AssetAccessControlFactory(assetRepository)
     
     	artifactRouter.GET("/sbom.json/", artifactController.SBOMJSON)
     	artifactRouter.GET("/sbom.xml/", artifactController.SBOMXML)
    @@ -41,8 +43,8 @@ func NewArtifactRouter(
     	artifactRouter.GET("/sbom.pdf/", artifactController.BuildPDFFromSBOM)
     	artifactRouter.GET("/vulnerability-report.pdf/", artifactController.BuildVulnerabilityReportPDF)
     
    -	artifactRouter.DELETE("/", artifactController.DeleteArtifact, middlewares.NeededScope([]string{"manage"}))
    -	artifactRouter.PUT("/", artifactController.UpdateArtifact, middlewares.NeededScope([]string{"manage"}))
    +	artifactRouter.DELETE("/", artifactController.DeleteArtifact, middlewares.NeededScope([]string{"manage"}), assetScopedRBAC(shared.ObjectAsset, shared.ActionUpdate))
    +	artifactRouter.PUT("/", artifactController.UpdateArtifact, middlewares.NeededScope([]string{"manage"}), assetScopedRBAC(shared.ObjectAsset, shared.ActionUpdate))
     
     	return ArtifactRouter{Group: artifactRouter}
     }
    
  • router/asset_router.go+1 0 modified
    @@ -67,6 +67,7 @@ func NewAssetRouter(
     	assetRouter.POST("/in-toto/", intotoController.Create, middlewares.NeededScope([]string{"scan"}), assetScopedRBAC(shared.ObjectAsset, shared.ActionUpdate))
     
     	assetUpdateAccessControlRequired := assetRouter.Group("", middlewares.NeededScope([]string{"manage"}), assetScopedRBAC(shared.ObjectAsset, shared.ActionUpdate))
    +
     	assetUpdateAccessControlRequired.POST("/sbom-file/", scanController.ScanSbomFile)
     	assetUpdateAccessControlRequired.POST("/integrations/gitlab/autosetup/", integrationController.AutoSetup)
     	assetUpdateAccessControlRequired.POST("/members/", assetController.InviteMembers)
    
  • router/asset_version_router.go+2 2 modified
    @@ -67,9 +67,9 @@ func NewAssetVersionRouter(
     	assetVersionRouter.GET("/artifacts/", assetVersionController.ListArtifacts)
     	assetVersionRouter.GET("/artifact-root-nodes/", assetVersionController.ReadRootNodes)
     
    -	assetVersionRouter.POST("/artifacts/", artifactController.Create, middlewares.NeededScope([]string{"manage"}))
    +	assetVersionRouter.POST("/artifacts/", artifactController.Create, middlewares.NeededScope([]string{"manage"}), assetScopedRBAC(shared.ObjectAsset, shared.ActionUpdate))
     
    -	assetVersionRouter.POST("/components/licenses/refresh/", assetVersionController.RefetchLicenses, middlewares.NeededScope([]string{"manage"}))
    +	assetVersionRouter.POST("/components/licenses/refresh/", assetVersionController.RefetchLicenses, middlewares.NeededScope([]string{"manage"}), middlewares.DisallowPublicRequests)
     	assetVersionRouter.DELETE("/", assetVersionController.Delete, middlewares.NeededScope([]string{"manage"}), assetScopedRBAC(shared.ObjectAsset, shared.ActionUpdate))
     	assetVersionRouter.POST("/make-default/", assetVersionController.MakeDefault, middlewares.NeededScope([]string{"manage"}), assetScopedRBAC(shared.ObjectAsset, shared.ActionUpdate))
     	assetVersionRouter.DELETE("/events/:eventID/", vulnEventController.DeleteEventByID, middlewares.EventMiddleware(vulnEventRepository), middlewares.NeededScope([]string{"manage"}), assetScopedRBAC(shared.ObjectAsset, shared.ActionUpdate))
    
  • router/dependency_vuln_router.go+5 4 modified
    @@ -29,17 +29,18 @@ func NewDependencyVulnRouter(
     	assetVersionGroup AssetVersionRouter,
     	dependencyVulnController *controllers.DependencyVulnController,
     	vulnEventController *controllers.VulnEventController,
    +
     ) DependencyVulnRouter {
     	dependencyVulnRouter := assetVersionGroup.Group.Group("/dependency-vulns")
     	dependencyVulnRouter.GET("/", dependencyVulnController.ListPaged)
     	dependencyVulnRouter.GET("/:dependencyVulnID/", dependencyVulnController.Read)
     	dependencyVulnRouter.GET("/:dependencyVulnID/events/", vulnEventController.ReadAssetEventsByVulnID)
     	dependencyVulnRouter.GET("/:dependencyVulnID/hints/", dependencyVulnController.Hints)
     
    -	dependencyVulnRouter.POST("/sync/", dependencyVulnController.SyncDependencyVulns, middlewares.NeededScope([]string{"manage"}))
    -	dependencyVulnRouter.POST("/batch/", dependencyVulnController.BatchCreateEvent, middlewares.NeededScope([]string{"manage"}))
    -	dependencyVulnRouter.POST("/:dependencyVulnID/", dependencyVulnController.CreateEvent, middlewares.NeededScope([]string{"manage"}))
    -	dependencyVulnRouter.POST("/:dependencyVulnID/mitigate/", dependencyVulnController.Mitigate, middlewares.NeededScope([]string{"manage"}))
    +	dependencyVulnRouter.POST("/sync/", dependencyVulnController.SyncDependencyVulns, middlewares.NeededScope([]string{"manage"}), middlewares.DisallowPublicRequests)
    +	dependencyVulnRouter.POST("/batch/", dependencyVulnController.BatchCreateEvent, middlewares.NeededScope([]string{"manage"}), middlewares.DisallowPublicRequests)
    +	dependencyVulnRouter.POST("/:dependencyVulnID/", dependencyVulnController.CreateEvent, middlewares.NeededScope([]string{"manage"}), middlewares.DisallowPublicRequests)
    +	dependencyVulnRouter.POST("/:dependencyVulnID/mitigate/", dependencyVulnController.Mitigate, middlewares.NeededScope([]string{"manage"}), middlewares.DisallowPublicRequests)
     
     	return DependencyVulnRouter{Group: dependencyVulnRouter}
     }
    
  • router/external_reference_router.go+6 3 modified
    @@ -18,6 +18,7 @@ package router
     import (
     	"github.com/l3montree-dev/devguard/controllers"
     	"github.com/l3montree-dev/devguard/middlewares"
    +	"github.com/l3montree-dev/devguard/shared"
     	"github.com/labstack/echo/v4"
     )
     
    @@ -28,17 +29,19 @@ type ExternalReferenceRouter struct {
     func NewExternalReferenceRouter(
     	assetVersionRouter AssetVersionRouter,
     	externalReferenceController *controllers.ExternalReferenceController,
    +	assetRepository shared.AssetRepository,
     ) ExternalReferenceRouter {
    +	assetScopedRBAC := middlewares.AssetAccessControlFactory(assetRepository)
     	// External references are scoped to asset versions
     	// Read access - anyone who can read the asset version can list references
     	refGroup := assetVersionRouter.Group.Group("/external-references")
     	refGroup.GET("/", externalReferenceController.List) // List all references for asset version
     
     	// Write access - requires asset update permission
     	refWriteGroup := refGroup.Group("", middlewares.NeededScope([]string{"manage"}))
    -	refWriteGroup.POST("/", externalReferenceController.Create)       // Create reference
    -	refWriteGroup.POST("/sync/", externalReferenceController.Sync)    // Sync external sources
    -	refWriteGroup.DELETE("/:id/", externalReferenceController.Delete) // Delete reference
    +	refWriteGroup.POST("/", externalReferenceController.Create, assetScopedRBAC(shared.ObjectAsset, shared.ActionUpdate))       // Create reference
    +	refWriteGroup.POST("/sync/", externalReferenceController.Sync, assetScopedRBAC(shared.ObjectAsset, shared.ActionUpdate))    // Sync external sources
    +	refWriteGroup.DELETE("/:id/", externalReferenceController.Delete, assetScopedRBAC(shared.ObjectAsset, shared.ActionUpdate)) // Delete reference
     
     	return ExternalReferenceRouter{Group: refGroup}
     }
    
  • router/first_party_vuln_router.go+2 2 modified
    @@ -35,8 +35,8 @@ func NewFirstPartyVulnRouter(
     	firstPartyVulnRouter.GET("/:firstPartyVulnID/", firstPartyVulnController.Read)
     	firstPartyVulnRouter.GET("/:firstPartyVulnID/events/", vulnEventController.ReadAssetEventsByVulnID)
     
    -	firstPartyVulnRouter.POST("/:firstPartyVulnID/", firstPartyVulnController.CreateEvent, middlewares.NeededScope([]string{"manage"}))
    -	firstPartyVulnRouter.POST("/:firstPartyVulnID/mitigate/", firstPartyVulnController.Mitigate, middlewares.NeededScope([]string{"manage"}))
    +	firstPartyVulnRouter.POST("/:firstPartyVulnID/", firstPartyVulnController.CreateEvent, middlewares.NeededScope([]string{"manage"}), middlewares.DisallowPublicRequests)
    +	firstPartyVulnRouter.POST("/:firstPartyVulnID/mitigate/", firstPartyVulnController.Mitigate, middlewares.NeededScope([]string{"manage"}), middlewares.DisallowPublicRequests)
     
     	return FirstPartyVulnRouter{Group: firstPartyVulnRouter}
     }
    
  • router/license_risk_router.go+4 4 modified
    @@ -32,10 +32,10 @@ func NewLicenseRiskRouter(
     	licenseRiskRouter := assetVersionGroup.Group.Group("/license-risks")
     	licenseRiskRouter.GET("/", licenseRiskController.ListPaged)
     	licenseRiskRouter.GET("/:licenseRiskID/", licenseRiskController.Read)
    -	licenseRiskRouter.POST("/", licenseRiskController.Create, middlewares.NeededScope([]string{"manage"}))
    -	licenseRiskRouter.POST("/:licenseRiskID/", licenseRiskController.CreateEvent, middlewares.NeededScope([]string{"manage"}))
    -	licenseRiskRouter.POST("/:licenseRiskID/mitigate/", licenseRiskController.Mitigate, middlewares.NeededScope([]string{"manage"}))
    -	licenseRiskRouter.POST("/:licenseRiskID/final-license-decision/", licenseRiskController.MakeFinalLicenseDecision, middlewares.NeededScope([]string{"manage"}))
    +	licenseRiskRouter.POST("/", licenseRiskController.Create, middlewares.NeededScope([]string{"manage"}), middlewares.DisallowPublicRequests)
    +	licenseRiskRouter.POST("/:licenseRiskID/", licenseRiskController.CreateEvent, middlewares.NeededScope([]string{"manage"}), middlewares.DisallowPublicRequests)
    +	licenseRiskRouter.POST("/:licenseRiskID/mitigate/", licenseRiskController.Mitigate, middlewares.NeededScope([]string{"manage"}), middlewares.DisallowPublicRequests)
    +	licenseRiskRouter.POST("/:licenseRiskID/final-license-decision/", licenseRiskController.MakeFinalLicenseDecision, middlewares.NeededScope([]string{"manage"}), middlewares.DisallowPublicRequests)
     
     	return LicenseRiskRouter{Group: licenseRiskRouter}
     }
    
  • router/router_test.go+404 0 added
    @@ -0,0 +1,404 @@
    +// Copyright (C) 2026 l3montree GmbH
    +//
    +// This program is free software: you can redistribute it and/or modify
    +// it under the terms of the GNU Affero General Public License as
    +// published by the Free Software Foundation, either version 3 of the
    +// License, or (at your option) any later version.
    +//
    +// This program is distributed in the hope that it will be useful,
    +// but WITHOUT ANY WARRANTY; without even the implied warranty of
    +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    +// GNU Affero General Public License for more details.
    +//
    +// You should have received a copy of the GNU Affero General Public License
    +// along with this program.  If not, see <https://www.gnu.org/licenses/>.
    +
    +package router
    +
    +// TestNonGetRoutesBlockPublicRequests enforces the invariant:
    +//
    +//	Every non-GET route that lives under /organizations must block a
    +//	fully-authenticated user that has NO membership in a public resource.
    +//
    +// Threat model: when an org/project/asset is marked public, the group-level
    +// RBAC middleware sets IsPublicRequest=true and continues processing. A
    +// non-GET route that is missing DisallowPublicRequests or a write-action RBAC
    +// check will pass the middleware chain and reach its handler. Because the
    +// handler is a zero-value stub it panics → 500.  Any properly protected route
    +// returns 4xx before the handler and the test passes.
    +//
    +// TestReadOnlyMemberCannotWriteToProtectedRoutes enforces the invariant:
    +//
    +//	Every non-GET route that requires explicit write permission must block a
    +//	read-only member (has ActionRead access but not ActionUpdate/ActionDelete).
    +//
    +// Routes listed in memberOnlyPaths intentionally use DisallowPublicRequests
    +// instead of write-level RBAC: any authenticated member (not just writers)
    +// is allowed. Those are skipped from the second test.
    +//
    +// HOW TO ADD NEW ROUTERS
    +// If you add a new router to RouterModule (providers.go) and it registers
    +// non-GET routes, register it in buildSecurityTestServer below and ensure it
    +// is called so the routes appear in e.Routes().
    +//
    +// HOW TO ADD AN INTENTIONALLY PUBLIC NON-GET ROUTE
    +// Add its exact path template to intentionallyPublicPaths below and explain
    +// why it is safe without authentication.
    +//
    +// HOW TO ADD A MEMBER-ONLY WRITE ROUTE (DisallowPublicRequests, no write RBAC)
    +// Add "METHOD /path/template/" to memberOnlyPaths and explain why any member
    +// (not just a writer) should be allowed.
    +
    +import (
    +	"fmt"
    +	"net/http"
    +	"net/http/httptest"
    +	"regexp"
    +	"strings"
    +	"testing"
    +
    +	"github.com/google/uuid"
    +	"github.com/l3montree-dev/devguard/controllers"
    +	"github.com/l3montree-dev/devguard/controllers/dependencyfirewall"
    +	"github.com/l3montree-dev/devguard/database/models"
    +	"github.com/l3montree-dev/devguard/integrations/gitlabint"
    +	"github.com/l3montree-dev/devguard/middlewares"
    +	"github.com/l3montree-dev/devguard/mocks"
    +	"github.com/l3montree-dev/devguard/shared"
    +	"github.com/labstack/echo/v4"
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/mock"
    +)
    +
    +// intentionallyPublicPaths lists non-GET route path templates that are
    +// explicitly designed to be callable without membership in the resource.
    +// Each entry must include a comment explaining why it is safe.
    +var intentionallyPublicPaths = map[string]bool{
    +	// none yet
    +}
    +
    +// memberOnlyPaths lists non-GET routes that are intentionally accessible to
    +// any authenticated member (not restricted to writers). They use
    +// DisallowPublicRequests instead of write-level RBAC because the business rule
    +// is: "if you can read the resource you can also perform this action."
    +// Key format: "METHOD /full/echo/path/template/"
    +var memberOnlyPaths = map[string]bool{
    +	// Vuln triage actions — any member who can read the asset version may triage findings.
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/dependency-vulns/sync/":                            true,
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/dependency-vulns/batch/":                           true,
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/dependency-vulns/:dependencyVulnID/":               true,
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/dependency-vulns/:dependencyVulnID/mitigate/":      true,
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/first-party-vulns/:firstPartyVulnID/":              true,
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/first-party-vulns/:firstPartyVulnID/mitigate/":     true,
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/license-risks/":                                    true,
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/license-risks/:licenseRiskID/":                     true,
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/license-risks/:licenseRiskID/mitigate/":            true,
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/license-risks/:licenseRiskID/final-license-decision/": true,
    +	// VEX rules — any member may create/edit/delete VEX rules (membership = passed read RBAC).
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/vex-rules/":                 true,
    +	"PUT /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/vex-rules/:ruleId/":          true,
    +	"POST /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/vex-rules/:ruleId/reapply/": true,
    +	"DELETE /api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/vex-rules/:ruleId/":       true,
    +}
    +
    +var echoParamRe = regexp.MustCompile(`:[^/]+`)
    +
    +// fillParams replaces every Echo path parameter with a stable dummy value so
    +// the router can match the route without a real database record.
    +func fillParams(path string) string {
    +	return echoParamRe.ReplaceAllString(path, "test-id")
    +}
    +
    +func buildSecurityTestServer(t *testing.T, ac *mocks.AccessControl) *echo.Echo {
    +	t.Helper()
    +
    +	// middlewares.Server() sets the package-level middlewares.E used by
    +	// GoroutineSafeContext inside ExternalEntityProviderOrgSyncMiddleware.
    +	e := middlewares.Server()
    +	// Surface panics as 500 so the assertion can catch unprotected handlers.
    +	e.HTTPErrorHandler = func(err error, c echo.Context) {
    +		code := http.StatusInternalServerError
    +		if he, ok := err.(*echo.HTTPError); ok {
    +			code = he.Code
    +		}
    +		_ = c.NoContent(code)
    +	}
    +
    +	orgID := uuid.New()
    +	projectID := uuid.New()
    +	assetID := uuid.New()
    +
    +	//  PAT service / verifier
    +	// Any request is treated as coming from "test-user" with manage+scan scopes
    +	// so NeededScope middleware always passes.  The real protection must then
    +	// come from DisallowPublicRequests or a write-action RBAC check.
    +	patService := &mocks.PersonalAccessTokenService{}
    +	patService.On("VerifyRequestSignature", mock.Anything, mock.Anything).
    +		Maybe().Return("test-user", "manage scan", nil)
    +
    +	rbacProvider := &mocks.RBACProvider{}
    +	rbacProvider.On("GetDomainRBAC", mock.Anything).Maybe().Return(ac)
    +
    +	//  Domain objects (all public so group RBAC sets IsPublicRequest)
    +	org := &models.Org{Model: models.Model{ID: orgID}, Slug: "test-org", IsPublic: true}
    +	orgService := &mocks.OrgService{}
    +	orgService.On("ReadBySlug", mock.Anything, mock.Anything).Maybe().Return(org, nil)
    +
    +	proj := models.Project{Model: models.Model{ID: projectID}, Slug: "test-project", IsPublic: true}
    +	projectRepo := &mocks.ProjectRepository{}
    +	projectRepo.On("ReadBySlug", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(proj, nil)
    +
    +	asset := models.Asset{Model: models.Model{ID: assetID}, Slug: "test-asset", IsPublic: true}
    +	assetRepo := &mocks.AssetRepository{}
    +	assetRepo.On("ReadBySlug", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(asset, nil)
    +
    +	// AssetVersion: always not found → AssetVersionMiddleware returns a 404
    +	// which is still < 500, so sub-routes that rely on it are implicitly safe.
    +	assetVersionRepo := &mocks.AssetVersionRepository{}
    +	assetVersionRepo.On("ReadBySlug", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
    +		Maybe().Return(models.AssetVersion{}, fmt.Errorf("not found"))
    +
    +	// VulnEvent: no access → EventMiddleware returns 403
    +	vulnEventRepo := &mocks.VulnEventRepository{}
    +	vulnEventRepo.On("HasAccessToEvent", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
    +		Maybe().Return(false, nil)
    +
    +	// Artifact: not found → ArtifactMiddleware returns 404
    +	artifactRepo := &mocks.ArtifactRepository{}
    +	artifactRepo.On("ReadArtifact", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
    +		Maybe().Return(models.Artifact{}, fmt.Errorf("not found"))
    +
    +	// ExternalEntityProviderService: methods used as handlers (GET only) or
    +	// fire-and-forget goroutines; just needs to not crash on method-value take.
    +	extEntityService := &mocks.ExternalEntityProviderService{}
    +	extEntityService.On("SyncOrgs", mock.Anything).Maybe().Return([]*models.Org{}, nil)
    +	extEntityService.On("TriggerOrgSync", mock.Anything).Maybe().Return(nil)
    +	extEntityService.On("TriggerSync", mock.Anything).Maybe().Return(nil)
    +
    +	// ConfigService: return error so InstanceSettings middleware passes through
    +	configService := &mocks.ConfigService{}
    +	configService.On("GetInstanceSettings", mock.Anything).
    +		Maybe().Return(models.Config{}, fmt.Errorf("not found"))
    +
    +	// PublicClient: return error → SessionMiddleware falls through to PAT verifier
    +	publicClient := &mocks.PublicClient{}
    +	publicClient.On("GetIdentityFromCookie", mock.Anything, mock.Anything).
    +		Maybe().Return(nil, fmt.Errorf("no cookie"))
    +
    +	//  Build the full router hierarchy using the REAL constructors
    +	// This is the point: if someone adds a route without protection and also
    +	// registers it here (or in RouterModule), the test will catch it.
    +	//
    +	// Use new(T) instead of nil for controllers: some methods use value
    +	// receivers, and taking a method value from a nil pointer panics.
    +	apiV1 := APIV1Router{Group: e.Group("/api/v1")}
    +
    +	sessionRouter := NewSessionRouter(
    +		apiV1,
    +		publicClient,
    +		patService,
    +		extEntityService,
    +		new(controllers.IntegrationController),
    +		new(controllers.OrgController),
    +		new(controllers.ScanController),
    +		new(controllers.AttestationController),
    +		new(controllers.PatController),
    +		assetRepo,
    +		projectRepo,
    +		rbacProvider,
    +		orgService,
    +		map[string]*gitlabint.GitlabOauth2Config{},
    +		assetVersionRepo,
    +	)
    +
    +	orgRouter := NewOrgRouter(
    +		sessionRouter,
    +		configService,
    +		new(controllers.OrgController),
    +		new(controllers.ProjectController),
    +		new(dependencyfirewall.DependencyProxyController),
    +		new(controllers.DependencyVulnController),
    +		new(controllers.FirstPartyVulnController),
    +		new(controllers.PolicyController),
    +		new(controllers.IntegrationController),
    +		new(controllers.WebhookController),
    +		extEntityService,
    +		orgService,
    +		map[string]*gitlabint.GitlabOauth2Config{},
    +		rbacProvider,
    +		new(controllers.StatisticsController),
    +	)
    +
    +	projectRouter := NewProjectRouter(
    +		orgRouter,
    +		new(controllers.ProjectController),
    +		new(controllers.AssetController),
    +		new(dependencyfirewall.DependencyProxyController),
    +		new(controllers.DependencyVulnController),
    +		new(controllers.PolicyController),
    +		new(controllers.ReleaseController),
    +		new(controllers.StatisticsController),
    +		new(controllers.WebhookController),
    +		projectRepo,
    +		new(controllers.ComponentController),
    +	)
    +
    +	assetRouter := NewAssetRouter(
    +		projectRouter,
    +		new(controllers.AssetController),
    +		new(dependencyfirewall.DependencyProxyController),
    +		new(controllers.AssetVersionController),
    +		new(controllers.ComplianceController),
    +		new(controllers.StatisticsController),
    +		new(controllers.ComponentController),
    +		new(controllers.InToToController),
    +		new(controllers.IntegrationController),
    +		new(controllers.ScanController),
    +		assetRepo,
    +	)
    +
    +	assetVersionRouter := NewAssetVersionRouter(
    +		assetRouter,
    +		new(controllers.AssetVersionController),
    +		new(controllers.FirstPartyVulnController),
    +		new(controllers.ComplianceController),
    +		new(controllers.ComponentController),
    +		new(controllers.StatisticsController),
    +		new(controllers.AttestationController),
    +		new(controllers.InToToController),
    +		new(controllers.VulnEventController),
    +		new(controllers.ArtifactController),
    +		new(controllers.ExternalReferenceController),
    +		assetVersionRepo,
    +		assetRepo,
    +		vulnEventRepo,
    +	)
    +
    +	// Sub-routers under /refs/:assetVersionSlug — all non-GET routes in these
    +	// must be protected.  Add new routers from RouterModule (providers.go) here.
    +	NewDependencyVulnRouter(assetVersionRouter, new(controllers.DependencyVulnController), new(controllers.VulnEventController))
    +	NewFirstPartyVulnRouter(assetVersionRouter, new(controllers.FirstPartyVulnController), new(controllers.VulnEventController))
    +	NewLicenseRiskRouter(assetVersionRouter, new(controllers.LicenseRiskController))
    +	NewVEXRuleRouter(assetVersionRouter, new(controllers.VEXRuleController))
    +	NewArtifactRouter(assetVersionRouter, new(controllers.ArtifactController), artifactRepo, assetRepo)
    +	NewExternalReferenceRouter(assetVersionRouter, new(controllers.ExternalReferenceController), assetRepo)
    +
    +	return e
    +}
    +
    +// publicVisitorAC returns an AccessControl mock where the user has no access
    +// to anything. Group-level read RBAC on a public resource will set
    +// IsPublicRequest=true, so DisallowPublicRequests blocks the request.
    +func publicVisitorAC() *mocks.AccessControl {
    +	ac := &mocks.AccessControl{}
    +	ac.On("HasAccess", mock.Anything, mock.Anything).Maybe().Return(false, nil)
    +	ac.On("IsAllowed", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(false, nil)
    +	ac.On("IsAllowedInProject", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(false, nil)
    +	ac.On("IsAllowedInAsset", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(false, nil)
    +	return ac
    +}
    +
    +// readOnlyMemberAC returns an AccessControl mock where the user has ActionRead
    +// access everywhere but not ActionUpdate or ActionDelete. Group-level read RBAC
    +// will pass (IsPublicRequest stays false), but write-level RBAC checks deny.
    +func readOnlyMemberAC() *mocks.AccessControl {
    +	ac := &mocks.AccessControl{}
    +	ac.On("HasAccess", mock.Anything, mock.Anything).Maybe().Return(true, nil)
    +
    +	// Org-level: allow read, deny everything else.
    +	ac.On("IsAllowed", mock.Anything, mock.Anything, mock.Anything, shared.ActionRead).Maybe().Return(true, nil)
    +	ac.On("IsAllowed", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(false, nil)
    +
    +	// Project-level: allow read, deny write.
    +	ac.On("IsAllowedInProject", mock.Anything, mock.Anything, mock.Anything, shared.ActionRead, mock.Anything).Maybe().Return(true, nil)
    +	ac.On("IsAllowedInProject", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(false, nil)
    +
    +	// Asset-level: allow read, deny write/delete.
    +	ac.On("IsAllowedInAsset", mock.Anything, mock.Anything, mock.Anything, shared.ActionRead, mock.Anything).Maybe().Return(true, nil)
    +	ac.On("IsAllowedInAsset", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(false, nil)
    +
    +	return ac
    +}
    +
    +// TestNonGetRoutesBlockPublicRequests enforces that every non-GET route under
    +// /organizations blocks a public visitor (no membership at all).
    +func TestNonGetRoutesBlockPublicRequests(t *testing.T) {
    +	e := buildSecurityTestServer(t, publicVisitorAC())
    +
    +	for _, route := range e.Routes() {
    +		method := route.Method
    +		path := route.Path
    +
    +		if method == http.MethodGet || method == "echo_route_not_found" {
    +			continue
    +		}
    +		// Only test org-scoped routes (those with an :organization path parameter).
    +		// Routes like POST /api/v1/organizations/ (create org) sit above any
    +		// org RBAC, so IsPublicRequest can never be set for them — the
    +		// public-resource attack model does not apply there.
    +		if !strings.Contains(path, ":organization") {
    +			continue
    +		}
    +		if intentionallyPublicPaths[path] {
    +			continue
    +		}
    +
    +		filledPath := fillParams(path)
    +
    +		t.Run(method+" "+route.Path, func(t *testing.T) {
    +			req := httptest.NewRequest(method, filledPath, nil)
    +			rec := httptest.NewRecorder()
    +			e.ServeHTTP(rec, req)
    +
    +			assert.Less(t, rec.Code, 500,
    +				"route %s %s reached its handler without write-level authorization "+
    +					"(status %d); add DisallowPublicRequests or a write-action RBAC middleware",
    +				method, route.Path, rec.Code,
    +			)
    +		})
    +	}
    +}
    +
    +// TestReadOnlyMemberCannotWriteToProtectedRoutes enforces that every non-GET
    +// route requiring explicit write permission still blocks a read-only member
    +// (has ActionRead access, but not ActionUpdate or ActionDelete).
    +//
    +// Routes listed in memberOnlyPaths are skipped: those routes intentionally
    +// grant access to any authenticated member via DisallowPublicRequests.
    +func TestReadOnlyMemberCannotWriteToProtectedRoutes(t *testing.T) {
    +	e := buildSecurityTestServer(t, readOnlyMemberAC())
    +
    +	for _, route := range e.Routes() {
    +		method := route.Method
    +		path := route.Path
    +
    +		if method == http.MethodGet || method == "echo_route_not_found" {
    +			continue
    +		}
    +		if !strings.Contains(path, ":organization") {
    +			continue
    +		}
    +		if intentionallyPublicPaths[path] {
    +			continue
    +		}
    +		// Member-only routes are intentionally accessible to read-only members;
    +		// they are tested for public-visitor blocking in the first test.
    +		if memberOnlyPaths[method+" "+path] {
    +			continue
    +		}
    +
    +		filledPath := fillParams(path)
    +
    +		t.Run(method+" "+route.Path, func(t *testing.T) {
    +			req := httptest.NewRequest(method, filledPath, nil)
    +			rec := httptest.NewRecorder()
    +			e.ServeHTTP(rec, req)
    +
    +			assert.Less(t, rec.Code, 500,
    +				"route %s %s reached its handler for a read-only member "+
    +					"(status %d); add a write-action RBAC middleware (e.g. assetScopedRBAC ActionUpdate)",
    +				method, route.Path, rec.Code,
    +			)
    +		})
    +	}
    +}
    
  • router/vex_rule_router.go+4 4 modified
    @@ -37,10 +37,10 @@ func NewVEXRuleRouter(
     
     	// Write access - requires asset update permission
     	ruleWriteGroup := ruleGroup.Group("", middlewares.NeededScope([]string{"manage"}))
    -	ruleWriteGroup.POST("/", vexRuleController.Create)                  // Create rule
    -	ruleWriteGroup.PUT("/:ruleId/", vexRuleController.Update)           // Update rule by ID
    -	ruleWriteGroup.POST("/:ruleId/reapply/", vexRuleController.Reapply) // Reapply rule to existing vulns
    -	ruleWriteGroup.DELETE("/:ruleId/", vexRuleController.Delete)        // Delete rule by ID
    +	ruleWriteGroup.POST("/", vexRuleController.Create, middlewares.DisallowPublicRequests)                  // Create rule
    +	ruleWriteGroup.PUT("/:ruleId/", vexRuleController.Update, middlewares.DisallowPublicRequests)           // Update rule by ID
    +	ruleWriteGroup.POST("/:ruleId/reapply/", vexRuleController.Reapply, middlewares.DisallowPublicRequests) // Reapply rule to existing vulns
    +	ruleWriteGroup.DELETE("/:ruleId/", vexRuleController.Delete, middlewares.DisallowPublicRequests)        // Delete rule by ID
     
     	return VEXRuleRouter{Group: ruleGroup}
     }
    

Vulnerability mechanics

Root cause

"Missing authorization check on write endpoints for public assets allows any authenticated user to perform triage and management actions without belonging to the target organization."

Attack vector

An attacker with a valid DevGuard account but no membership in the victim organization, project, or asset can send POST/PUT/DELETE requests to any of the unprotected write endpoints under a public asset. Because the group-level RBAC middleware sets `IsPublicRequest=true` for public resources and the handler only verified the token scope (`manage`), the request passes through to the controller. The attacker can create, update, reapply, or delete VEX rules, create vulnerability events, modify license risks, write external references, and create or update artifacts — all without belonging to the target organization [CWE-285] [CWE-863].

Affected code

The vulnerability resides in the router layer of DevGuard. Non-GET endpoints under `/api/v1/organizations/:organization/projects/:project/assets/:assetSlug/refs/:assetVersionSlug/` — including VEX rule CRUD, dependency-vuln event creation, license risk creation, external reference writes, and artifact creation — were protected only by a `NeededScope` middleware but lacked a check that prevents public (unauthenticated-in-org) users from performing write operations. The patch adds a `DisallowPublicRequests` middleware and, in some routers, an `assetScopedRBAC` middleware to these routes.

What the fix does

The patch introduces a `DisallowPublicRequests` middleware that checks `shared.IsPublicRequest(ctx)` and returns a 404 error if the request originates from a public (non-member) user. This middleware is appended to every non-GET route in the dependency-vuln, first-party-vuln, license-risk, VEX-rule, and artifact routers. Additionally, the external-reference and artifact routers now inject an `assetScopedRBAC` middleware that verifies the caller has `ActionUpdate` permission on the asset. A comprehensive router test (`router_test.go`) was added to enforce that all non-GET routes under `/organizations` block public visitors and that write-protected routes also block read-only members.

Preconditions

  • configThe target asset must be marked as 'public' in DevGuard settings.
  • authThe attacker must possess a valid authenticated account on the DevGuard instance.
  • authThe attacker must have no membership or role in the victim organization, project, or asset.
  • networkThe attacker must be able to send HTTP requests to the DevGuard API.

Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.