Vulnerabilities I’ve reported

I found these by reading the code and testing what an account could reach. Most are authorization bugs. All are fixed upstream, and each one links to an advisory or report you can check.

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, all 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)

Privilege escalation to Organization Owner through the gem transfer flow

High CVSS 7.0
Class — Incorrect authorization (CWE-863) → organization takeover Reference — RubyGems.org advisory GHSA-wj84-52wx-4c26 · reported and credited

Summary. RubyGems.org Organizations has roles, and only an Owner is supposed to be able to grant the Owner role. The gem transfer flow didn’t enforce that. An Organization Admin could run a transfer and invite an account they control straight in as an Owner.

The gap. Two paths hand out roles. The membership path checks that whoever is granting a role is allowed to grant it. The transfer path let the initiator pick the role for the invited account and never made the same check. An admin picks Owner, the invite goes out, and the role sticks.

Impact. Organization takeover. The new Owner can remove the real owners along with their publishing access, lock out the people who actually push the gems, and get in the way of owner-level recovery. Admins could already publish and yank. This hands over the organization itself.

Why a scanner walks past it. Nothing here is malformed. Every request is authenticated and reads as a legitimate admin action on a feature built for admins. Finding it means putting the roles the transfer flow permits next to the authorization policy that governs membership, and noticing the two disagree.

Fix. RubyGems.org stopped admins selecting Owner during a transfer, validated the requested role against the existing authorization policy, and moved the check inside the transfer model and its transaction so it can’t be stepped around. It’s a hosted service, so the fix reached everyone without an upgrade.

Verify → RubyGems.org advisory GHSA-wj84-52wx-4c26

A single-gem API key turned into an all-gems key by republishing a yanked gem

High CVSS 7.1
Class — Incorrect authorization (CWE-863) → API key scope bypass Reference — RubyGems.org advisory GHSA-xx8r-939r-j6hf · reported and credited

Summary. RubyGems.org lets you restrict an API key to one gem. The key could lift that restriction itself. Push a new version of a fully-yanked gem with it, and the same key could then act on every other gem its owner owns.

The gap. A key’s gem restriction is stored as a reference to the owner’s ownership record for that gem. Pushing to a fully-yanked gem with no indexed versions resets its ownership: the old records get deleted and new ones created. That delete skipped Active Record callbacks, so the key’s scope row survived, pointing at an ownership that no longer existed. The authorization check read the missing ownership as no restriction at all, which in RubyGems terms means All Gems.

Impact. A key someone deliberately scoped to one gem, like a push key in a CI job or an OIDC role restricted to that gem, could publish the owner’s other gems. Action scopes don’t grow, so a push-only key stays push-only. But the one-gem limit is gone, and it’s gone for every key scoped through the deleted ownerships, not just the one that triggered it.

Why a scanner walks past it. Every request is a valid push with a valid key. The bug is in what a missing row means. One code path deletes without callbacks, another treats the dangling reference as unrestricted, and you only see it by following the scope from the key through the ownership and asking what happens when the ownership goes away.

Fix. Ownership removal now runs the destroy callbacks, so keys scoped through a removed ownership are revoked and their scope rows deleted. A separate task revoked keys already left with dangling scopes. Live on rubygems.org since 7 October 2026.

Verify → RubyGems.org advisory GHSA-xx8r-939r-j6hf · fix commit f9a2a39

Cross-account resource transfer via a mass-assignable account_id

Moderate CVSS 6.7
Class — Mass assignment (CWE-915) → cross-account resource transfer Reference — Chatwoot advisory GHSA-x288-jh8j-348c · reported and credited

Summary. The Portals API permitted account_id in its strong parameters, so an authenticated admin could rewrite which account owned a portal, and drop one they control in any other account. A direct 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 flagged the same writable account_id in the Automation Rules and Macros controllers.

Impact. An admin in one account can plant a portal in any other account, with a name, header, and links they choose. It lands in that tenant’s help-center admin as attacker-controlled content their own staff has no reason to doubt. Any admin in any account could do it, against any other account on the instance.

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.

Fix. account_id came out of the permitted params in all three controllers.

Verify → GitHub advisory GHSA-x288-jh8j-348c · fix PR #13116

Stored XSS on Pontoon’s public dashboards through a contributor’s email address

High $3,000 bounty
Class — Stored XSS (CWE-79) → privilege escalation to locale manager Reference — Mozilla HackerOne report #3988961 · reported and disclosed

Summary. Pontoon is Mozilla’s translation platform. Its public dashboards show who last touched each locale, and hovering that cell opens a tooltip. The tooltip was built by string concatenation, and the contributor’s name went in unencoded. A contributor with no display name is shown by their email address, and signup accepted an address with a script tag in it. Anyone could sign up and plant one.

The code. Five data-* values went into a string handed to jQuery’s .after(). Only the translation text was encoded:

// pontoon/base/static/js/table.js, before the fix
const translation = Pontoon.doNotRender($element.data('translation')),
  action = $element.data('action'),
  name = $element.data('user-name'),     // ← read raw
  link = $element.data('user-link'),
  ...
$element.after(
  '<aside class="tooltip">' + ... +
  action + ' <a href="' + link + '">' + name + '</a></p>' + ...
);                                       // ← jQuery parses the whole string as HTML

The server did escape the values into the attributes. But the browser decodes attributes when it parses the page, so .data() handed the markup back raw, and jQuery runs any <script> it finds in a fragment.

Exploit. Sign up with this address, then submit one ordinary suggestion:

"<script>alert(document.domain)</script>x"@example.org

It’s a valid quoted local part. The suggestion makes the attacker the latest activity, and the script runs for anyone who hovers the cell, signed in or not. It fired on Mozilla’s staging instance, pontoon.allizom.org, which was serving no Content-Security-Policy at the time. I traced that to a django-csp 4.0 upgrade that had silently dropped the policy.

Impact. The script runs as whoever hovered. In a translator’s session it could approve the attacker’s own suggestion. In a locale manager’s it could add the attacker to that locale’s managers group, and that membership outlives every session involved. I ran both on a local instance. Neither is a request the attacker can make for themselves.

Why a scanner walks past it. The server-side escaping is correct, and the display-name field has had a character blocklist since a 2016 XSS on this same value (bug 1258774). The email address is the other half of that value, and it never went through the blocklist. A later redesign moved rendering into a client-side sink. Finding it means following one value from signup, through the template, into the browser’s parser and back out.

Fix. Two changes closed the path within two days of the report. Mozilla removed local signup (#4482) and restored the CSP (#4493). The sink itself was fixed a month later, when the tooltip was rebuilt from DOM nodes (#4583, shipped in v2026.10.08). Self-hosted instances should be on that or later.

Verify → HackerOne report #3988961 · fix PR #4583 · CSP issue #4492

Command injection in seven release workflows via a merged commit subject

Moderate CVSS 6.3
Class — Command injection → code execution in the release pipeline Reference — measure.sh advisory GHSA-g5x7-jwm8-7qvq · reported and credited

Summary. measure’s release workflows build release notes with git-cliff, then drop that output into a run: block. GitHub Actions substitutes ${{ ... }} as raw text before bash ever parses the line, so the release notes stop being data and become shell source.

The vector. Those notes are built from commit subjects, and commit subjects come from merged pull requests. Inside double quotes, $(...) and backticks still execute. Seven workflows shared the same sink:

--notes "${{ steps.<id>.outputs.<name> }}"

.github/workflows/android-release.yml
.github/workflows/changelog.yml
.github/workflows/flutter-release.yml
.github/workflows/gradle-plugin-release.yml
.github/workflows/kmp-release.yml
.github/workflows/react-native-release.yml
.github/workflows/release.yml

A pull request titled fix: handle $(id) in the parser reads as an ordinary bug fix in review. Squash-merge it and the subject lands in git history, git-cliff picks it up, and the substitution runs it inside the release job.

Impact. Release jobs are where the signing keys and publish tokens live. Code running there can reach the credentials that sign and publish measure, measure_flutter, measure_dio, @measuresh/react-native and sh.measure:measure-kmp. A tampered build would go out as a normal release.

Fix. Keep the action output out of generated shell source. Bind it in the step’s env: block and read it back as a quoted variable. Then it’s just a string.

Verify → measure.sh advisory GHSA-g5x7-jwm8-7qvq

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.