← Tim Haines

Vulnerabilities I’ve reported

Cross-tenant and other security bugs I found and disclosed in a production multi-tenant SaaS. All fixed upstream. The links let you verify each one.

These are from Chatwoot, an open-source customer-engagement platform that runs multi-tenant — many customer accounts on one system. I found them the way I run an engagement: reading the code and testing what one account can reach across the tenant boundary, not running a scanner over the live app. Two of the three are cross-tenant — one account reading or taking another’s data. Each was reported to Chatwoot and fixed.

Cross-tenant data access via SQL injection in the filter API

High CVSS 8.5
Class — SQL injection → cross-tenant data access Reference — CVE-2026-44706 · independently discovered and reported

Summary. Chatwoot’s conversation and contact filter APIs interpolated user-supplied filter values straight into SQL, unparameterized. An authenticated user in one account could run blind, time-based SQL injection and read data across account boundaries.

The vector. Filtering a date or number custom attribute with the is_greater_than / is_less_than operator put the values field directly into the query. A second vector did the same with attribute_key, interpolated into a JSON-path expression. Custom attributes can be created through a public endpoint, so an attacker didn’t even need a pre-existing one. Affected endpoints:

POST /api/v1/accounts/{id}/conversations/filter
POST /api/v1/accounts/{id}/contacts/filter

Impact. A cross-tenant breach: user emails, bcrypt password hashes, API tokens, conversation contents, contact PII, and integration credentials — readable across accounts. Every version from 2.2.0 was affected.

Fix (shipped in 4.11.2). Parameterized queries, plus strict attribute_key validation:

/\A[\p{L}\p{N}_.\-]+\z/

Verify → GitHub advisory GHSA-9pgm-75gg-6948 (CVE-2026-44706)

Cross-account resource transfer via a mass-assignable account_id

High
Class — Broken access control / IDOR → cross-account transfer Reference — Chatwoot advisory GHSA-xcjg-q398-x2v7 Publication pending

Summary. The Portals API permitted account_id in its strong parameters, so an authenticated admin could change which account owned a resource — transferring another account’s portals to themselves, or moving a victim’s portal away so the owner lost access. A direct multi-tenant isolation bypass.

The code. The owning-account field was mass-assignable:

# app/controllers/api/v1/accounts/portals_controller.rb
def portal_params
  params.require(:portal).permit(
    :id, :account_id, :color, :custom_domain, :name, :slug, ...)
end             # ← account_id (the tenant owner) is writable from the request body

PortalPolicy#update? only verified the caller was an admin in their current account — nothing stopped them rewriting account_id itself.

Exploit. As an admin in account 519, transfer a portal to account 37:

PATCH /api/v1/accounts/519/portals/my-portal
{ "portal": { "account_id": 37 } }

→ the portal now belongs to account 37;
  account 519 gets a 500 and can no longer reach it.

I also flagged the same mass-assignment pattern in the Automation Rules and Macros controllers — the bug class, not just the one instance.

Impact. Cross-account data breach and data loss — help-center portals hold articles, categories, and customer-facing content. Exploitable by any admin in any account.

Why a scanner walks past it. The tenant owner should come from the session, never the request body. A scanner sees an admin-authenticated PATCH succeed and moves on; catching this means reading the permitted-params list against the authorization policy and noticing the owner field is writable. The fix removed account_id from the permitted params.

Verify → fix PR #13116 (merged)

Also reported & fixed

SSRF in an upload endpoint Publication pending — any authenticated user could make the server fetch an attacker-controlled URL and exfiltrate the host’s AWS credentials. Reported to Chatwoot and fixed; advisory GHSA-qpw4-7pxw-mp9r.