Chartbrew: Cross-Tenant Project Takeover via Insecure Direct Object Reference in Permission Check

An authenticated user passes their own team ID in the request body and bypasses the authorization check for all project routes. The permission logic never verifies that the target project belongs to the supplied team, allowing any registered user to read, rewrite, steal or delete other tenants' projects.

Advisory ID: TP-2026-055
Product: Chartbrew (open-source business-intelligence platform for data visualization, self-hosted)
Vulnerability type: Insecure Direct Object Reference / Authorization Bypass Through User-Controlled Key (CWE-639)
CVE: CVE-2026-85712
CVSS 3.1: 8.8 (High) · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Vendor advisory: GHSA-6gv3-8wxh-6vfq
Affected versions: <= 5.2.2
Fixed in: not yet released (fix on master)
Reported: 16 July 2026

Summary

Chartbrew is a self-hosted open-source business-intelligence platform for data visualization whose /project/* routes share a single checkPermissions closure for authorization. That closure resolves the caller's team from req.params.team_id || req.body?.team_id (server/api/ProjectRoute.js:45). No /project/* route defines a :team_id URL parameter, so the value comes from the request body. An attacker sets team_id to their own team (every self-signup is teamOwner), the teamOwner/teamAdmin branch grants the action, and the code never checks that the target project belongs to that team. ProjectController.update writes the whole request body via db.Project.update(data, {where: {id}}) with no field allow-list. Result: any authenticated user reads, rewrites, re-parents (steals), publicly exposes, strips the password of, or deletes any other tenant's project.

Root cause

checkPermissions sets const teamId = req.params.team_id || req.body?.team_id (server/api/ProjectRoute.js:45), and no /project/* route defines a :team_id parameter, so the caller controls teamId through the JSON body. getTeamRole(teamId, req.user.id) returns the attacker's teamOwner role in their own team, and the teamOwner/teamAdmin branch calls next() without checking project.team_id === teamId. The sink ProjectController.update(id, data) writes the entire body through db.Project.update(data, {where: {id}}) with no field allow-list (server/controllers/ProjectController.js:100-114). Attacker-controlled columns include team_id (re-parenting equals theft), public, ghost, passwordProtected, password, and headerCode. DELETE /project/:id shares the same closure with the deleteAny action.

Proof of Concept

# Confirm attacker A cannot touch victim B's project without the body trick:
GET /project/<Bproj> (Bearer A) → 403
PUT /project/<Bproj> {"dashboardTitle":"x"} (Bearer A) → 403

# Send PUT with A's own team_id in the body:
PUT /project/<Bproj> HTTP/1.1
Authorization: Bearer <A>
{"team_id":<Ateam>,"public":true,"passwordProtected":false,"dashboardTitle":"XVERIFY"}

HTTP/1.1 200 OK
{"id":<Bproj>,"team_id":<Ateam>,"public":true, ...}

# Confirm theft:
GET /project/<Bproj> (Bearer A) → 200 (A now owns it)
GET /team (Bearer B) → project no longer listed

Impact

  • Cross-tenant read of any project (charts, datasets, queries) by re-parenting then reading.
  • Arbitrary rewrite of any project's settings, including public exposure and password removal.
  • Project theft and, via the shared DELETE route, cross-tenant deletion.
  • Lowest legitimate role on default config, single request, no user interaction.

References

Is Something Like This in Your Software?

Our team found this vulnerability in the course of its work. Have your applications tested by the same specialists, with a penetration test from turingpoint.