Vulnerabilities

The Filter That Kept Its Account ID and Still Reached Beyond the Tenant

Chatwoot 2.2.0 through releases before 4.11.2 are affected by CVE-2026-44706 because authenticated filter values and stored custom-attribute keys gained PostgreSQL syntax in FilterService; deploy current official 4.16.1 to web and workers, inventory legacy keys and export jobs, and close after synthetic two-account tests pass under the real database role, with 4.11.2 kept only as the historical first-fixed floor.

A hand-drawn investigation desk follows three tenant folders through a filter service toward a database cabinet, where a rust-red query fragment slips beyond the intended drawer.
In this article

Research basisReview of the public advisory, parent and fixed source revisions, three execution paths, PostgreSQL tenant reach, and defensive acceptance design for owned isolated environments

SourceChatwoot Security Advisory / GitHub Security Lab / fixed source review / SOSEC analysis

Chatwoot published GHSA-9pgm-75gg-6948 on May 5, 2026. The advisory covers version 2.2.0 and later releases before 4.11.2, rates the issue High, and requires a low-privilege authenticated user. Version 4.11.2 is the minimum fixed release. The advisory also says that no reliable long-term workaround exists. As of July 26, 2026, the project's current official release is 4.16.1. A production change uses the current release rechecked at publication time; 4.11.2 remains the exposure and first-fixed boundary. After upgrading, local evidence determines whether a request reached the path, a query executed, and data or credentials require response.

The failure occurred after authentication and account selection. A comparison value changed from Ruby data into a quoted SQL fragment inside the old lt_gt_filter_values(). A custom-attribute key could enter storage first and later be interpolated into a JSON extraction expression. The account predicate could remain visible in the query text while injected grammar changed its Boolean structure or evaluated a subquery with the shared PostgreSQL role's authority. The route's account_id could not constrain grammar that reached the database parser later.

GitHub Security Lab pinned its analysis to v4.11.0 and traced three execution paths: conversation filtering, contact filtering, and contact export. The public record establishes the vulnerability and source impact. It contains no evidence that a named deployment was exploited. Local incident grades therefore progress through running artifact, authenticated request, queued job, database session, and observed consequence.

1 The change ticket moves every inventoried process to current 4.16.1

The repair target includes every object able to load the filter service: web instances, contact-export workers, scheduled or retried jobs, console images, regional replicas, and downstream forks. A version shown by the web interface identifies the responding instance. A stale worker that can still claim Account::ContactsExportJob work can preserve the vulnerable constructor after the visible fleet has moved.

1.2 Six terms locate the account predicate inside the full query boundary

A tenant is the set of Chatwoot data and permissions identified by an account. The outer query is the ActiveRecord relation that begins from the current account's conversations or contacts. The inner expression is the predicate built from field, operator, and value; it controls which outer rows match and can contain subqueries. These three terms describe application query structure.

The shared database role is the PostgreSQL principal used by the web or worker process. A bind parameter keeps a value separate from the SQL template and lets the database adapter carry a typed object. RLS is PostgreSQL row-level security; it independently constrains rows only when enabled on the relevant table, applied to the real role, and supplied with reliable tenant context. These three terms describe how the database parses and executes the structure.

The old implementation crossed the boundary between those groups. A request value or stored key helped author the inner expression. The account ID could remain in the outer text while a new OR shape widened results or a scalar subquery read another object under the shared role. The repaired design returns scalars to binds, restricts keys to a product vocabulary, and handles them in their final SQL context.

An engineer guides an account key through operator selection, type measurement, sealed binding, key checking, an account-scoped rail, a database shield, and a locked evidence ledger.
Figure 1. The account chooses the outer business relation. Operator selection, coercion, binds, and key grammar govern the inner expression. The real role and RLS determine what that expression can ultimately observe.

2 Three execution paths and one stored input converge on the same constructor

2.1 Synchronous filters, queued exports, and definitions run on different clocks

Conversation filtering passes current user, current account, and filter parameters to Conversations::FilterService at conversations_controller.rb lines 50-58. Contact filtering enters Contacts::FilterService at contacts_controller.rb lines 62-71. For these synchronous routes, the HTTP request, application execution, and SQL can usually share one trace.

Contact export only places parameters and identity into Account::ContactsExportJob at lines 45-49 of the same controller. The job calls the filter service later at contacts_export_job.rb lines 27-35. An HTTP success can establish enqueueing while another process executes the query later. The case must join request ID, job ID, enqueue and retry history, worker artifact, database session, and export result.

A custom-attribute definition enters account storage at custom_attribute_definitions_controller.rb lines 10-14. Creation itself does not run the vulnerable query. The key reaches SQL only when a later filter references it. Current definitions, change and deletion history, saved filters, imports, and recovery paths therefore belong in the search.

2.2 Comparison values and custom keys crossed two syntax boundaries

The first boundary involved date and numeric comparison. The old lt_gt_filter_values() read values[0], then concatenated operator, quote, external value, and PostgreSQL cast. A cast written inside SQL cannot protect parsing. PostgreSQL first determines quotation and expression structure, then performs the date or numeric conversion. Other safe binds in where(query_string, filter_values) do not return a scalar already embedded in query_string to data status.

The second boundary involved a JSON property key. The old build_custom_attr_query() and not_in_custom_attr_query() placed @attribute_key inside the operand of ->>. That key could have entered storage long before the filter request. The immediate comparison scalar and delayed key need different historical joins, but both acquired structure at one database execution point.

The repair assigns a matching control to each position. Dates and numbers pass through Date.iso8601 and BigDecimal. Type failures become CustomExceptions::CustomFilter::InvalidValue in Ruby. Accepted objects enter a value_N bind, leaving only the program-selected operator and placeholder in the structural string. Custom keys first pass a product grammar of letters, numbers, underscore, period, and hyphen, then receive context-aware handling in positive, negative, and additional-attribute construction.

A smooth value is stamped into a jagged query gear while a stored custom key leaves a drawer and enters the same database mechanism through a second route.
Figure 2. An immediate comparison value and a delayed stored key arrive on different clocks. Their dangerous state is the same: an external object gains SQL structure during query construction.

3 The account predicate can remain in text while Boolean structure and database authority widen reach

3.1 Draw the outer row scope and inner expression separately

GitHub Security Lab's public path shows how an injected value can change parentheses and OR precedence so the conditions originally built around the current account no longer carry their intended restriction. A blind read can also evaluate a scalar subquery in the predicate and expose information through truth, error behavior, or duration. Both outcomes begin after the account route has admitted the user.

Source review therefore records two objects. The outer relation explains which conversations or contacts begin the normal query. The inner expression explains what PostgreSQL actually evaluates. Finding current_account, Current.account, or account_id establishes the first layer. The second closes only after every dynamic value, key, operator, and group has completed construction.

This separation explains the advisory's changed-scope rating. The authenticated user holds account-level application authority; the connected role evaluates SQL. If that role can read shared tables, global users, access tokens, conversations, contacts, or integration settings across accounts, the expression inherits the corresponding ceiling. Chatwoot's advisory gives cross-tenant data and reusable credentials high response priority for that reason.

3.2 The incident-time PostgreSQL role sets the local impact ceiling

Each deployment needs a reach map rooted in the exact connection authority used by web and worker processes: endpoint, current_user, role membership, search_path, schema privileges, tables and views, function execution, foreign data links, ownership, and RLS enablement and force state. Stronger migration or administrative authority enters this ceiling only when an ordinary request process can acquire it.

RLS needs runtime evidence. The policy must exist on the relevant table, apply to the real role, and receive correct tenant context after pool reuse, rollback, and timeout. Table owners, privileged roles, security-context functions, and views owned by another principal can change the result. A catalog snapshot paired with a synthetic two-account test directly records current behavior.

The reach map states an investigative upper bound; execution and consequence evidence determine the number of records actually read. The upper bound selects datasets, credentials, and tenants for hunting and conservative action, while the latter evidence advances the incident grade. Preserve the pre-remediation grant snapshot before hardening because today's reduced authority describes only the current state.

Mark global objects separately. User identities, installation settings, and shared tokens may lack account_id while affecting many accounts. Application-layer encryption narrows the ceiling only when the database role cannot obtain decryption authority. Record tenant key, sensitive fields, key location, credential owner, and rotation blast radius for each readable object so communication can move from "the database" to exact data categories.

4 Historical impact advances from eligibility through reach, execution, and consequence

4.1 Each grade answers one narrower question

  1. Eligibility: the target process ran a release from 2.2.0 through one before 4.11.2. That state enters the investigation inventory.
  2. Path reach: an authenticated identity called a filter or export path, or created a definition that a later filter referenced. That state establishes entry point and principal.
  3. Dangerous input: the request value or stored key violates the fixed type, key, or structure contract. That state separates ordinary filters from events needing deeper retrieval.
  4. Execution: an application exception, database session, normalized query, plan, or duration aligns with the same request or job. That state establishes evaluation.
  5. Consequence: a result, export, information signal, credential use, or tenant data event forms an explainable join. That state supports disclosure, credential action, and notification.

A version scan triggers repair as soon as it finds an old artifact. Incident language cites the highest grade already established on this ladder; a cross-tenant disclosure finding requires execution or consequence evidence.

4.2 Web, queue, process, database, and definition history must meet on one event

For synchronous paths, preserve account, immutable actor or token ID, request ID, route, time, status, response size, application instance, release revision, database session, and query identifier. Minimized request evidence can retain operator, definition ID, value count, length bucket, and keyed digest. Raw customer fields stay in a restricted evidence store.

For export, add job ID, queue, enqueue/start/finish time, retries, worker digest, and output disposition. For the stored-key route, add object ID, account, creator, key digest, model, timestamps, update and deletion history, and saved-filter references. An HTTP 200 covers its own stage; the later worker SQL requires an independent execution record.

On PostgreSQL, preserve the settings and records that existed during the interval: session user, application name, query ID, normalized shape, duration, rows, errors, cancellation, waits, and log time zone. pg_stat_statements aggregates and normalizes; managed performance tools may sample. An empty search result carries capacity, reset time, retention, threshold, and sampling semantics. Each telemetry gap receives an earliest and latest reliable time, owner, compensating source, and decision date.

The case timeline keeps at least five distinct clocks: first vulnerable artifact deployment, earliest surviving evidence, fixed artifact active across every process, legacy-key cleanup, and reachable-credential revocation. A single "patched" timestamp cannot say when stale workers, stored state, and identity consequences were each resolved.

An identity badge, request envelope, application process, database session, and query fingerprint connect across one timeline, with primary, replica, backup, and search copies mapped separately.
Figure 3. Identity, request or job, effective process, database session, and query shape form the event spine. Definition history and replicas join only when they explain entry, execution, or consequence.

5 Acceptance proves that values stay in binds and rejected objects stop before SQL

5.1 Upstream regression tests pin repair intent to query structure and Ruby types

The contact suite at lines 281-312 asserts that a standard date uses :value_0, is stored as a Date, and rejects an invalid form with InvalidValue. Lines 411-475 cover a custom date, a BigDecimal numeric value, and the rejection path. The conversation suite applies the same structural assertion to created_at at lines 420-452.

The key suite at custom_attribute_definition_spec.rb lines 9-57 accepts underscore, hyphen, period, and Unicode letters; rejects quotation, internal space, semicolon, and parentheses; and confirms trimming of surrounding whitespace. The fixed helper also routes additional-attribute keys through a fixed sanitization template at lines 48-60.

These tests state the maintainer's repaired contract: program code selects structure, scalars are typed and bound, and keys follow a product grammar plus contextual construction. A deployer adds controller, queue, real database role, process replacement, and historical data coverage, then uses artifact digests and process evidence to confirm the code loaded by each production object.

5.2 A synthetic two-account matrix closes the path without touching production data

In isolation, create accounts A and B with one uniquely marked synthetic conversation and contact each. Use database authority equivalent to, or broader than, production read visibility. First run legal dates, numbers, Unicode keys, and ordinary text filters to preserve result, export, and saved-filter behavior. Then submit malformed dates and decimals plus keys rejected by the new grammar; the application must return a controlled failure before any matching SQL event.

The matrix covers conversation filtering, contact filtering, and an executed contact-export job. Custom attributes cover positive and not_equal_to branches. Mixed AND/OR groups assert unique bind names. Every result and CSV produced under account A excludes account B's sentinel, and every invalid case produces zero relevant database statements.

Where RLS is enabled, repeat the test through connection-pool reuse, rollback, and timeout to prove refreshed tenant context. Compare plan and duration before and after the fix using synthetic data at representative scale. A compatibility or planning regression is handled through template, index, or forward patch work while typed binds and key controls remain intact.

Accept downstream forks by behavior. Record build origin, image digest, and source revision, then run the valid and invalid cases from the fixed upstream suite against the private build. Every added display type or operator needs its own coercion, bind, and template case. A fork qualifies as equivalent only after all three paths, both dynamic positions, and exception handling satisfy the contract.

6 Closure requires fixed processes, resolved legacy state, and recorded incident decisions

6.1 Rollout proceeds through preservation, replacement, cleanup, and verification

Freeze the ticket's asset and evidence inventory before replacing every web and worker process with the current maintained artifact. If new and old processes coexist during rollout, keep the exact routes restricted. Stop old workers from claiming jobs. Recreate or hand queued exports to new workers after preservation. After restart, bind each process to image digest, source revision, start time, and one ordinary filter result.

Preserve abnormal custom definitions before the account owner chooses migration, quarantine, or disablement. Migrate JSON data, saved filters, and automation references together, retaining the old-to-new object map and row counts. A business recovery from backup runs the same audit before external traffic resumes. The offline evidence copy stays unchanged.

Credential action follows the Chapter 3 reach map and Chapter 4 grade. Revoke a token used for a strongly suspicious request. Rotate API tokens, integration secrets, inbox credentials, or database credentials readable during an execution-supported event in dependency order. If password hashes fall within plausible reach, product, legal, and identity owners decide password reset and session revocation. Each item records inclusion reason, consumer, cutover, and last old use.

6.2 Rollback retains compensating controls, and every unknown closes with an owner

Compatibility defects receive a forward fix. An emergency rollback starts only after incident authority approves compensating controls: exact route restrictions, a trusted-principal set, statement timeout, old-worker isolation, minimized enhanced telemetry, and automatic expiry. Rollback may target only a maintained artifact that contains the complete repair, never an old build chosen merely because it meets the historical 4.11.2 floor. Policy keeps every other artifact from receiving traffic until the current artifact and data migration pass again.

Closure has four independent confirmations. The software inventory shows every execution object fixed. The data inventory shows definitions and references compliant, migrated, quarantined, or preserved as evidence. The database inventory retains incident-time authority and records current hardening. The incident inventory records a disposition for every request or job, reachable credential, dataset, and tenant. A telemetry gap can remain an analytical limit only with an owner, compensating action, decision date, and residual-risk approval.

CVE-2026-44706 leaves a precise engineering result. The account path constrains who can start a business request. The query constructor constrains how that request becomes SQL. The database role constrains what the SQL can observe. Those layers form one continuous path. Version 4.11.2 marks the first release with the complete typed-bind and key-grammar repair; production should run the current maintained release, which is 4.16.1 at this review. Asset, database, and incident evidence close the remaining operational layers.

Research record

7Evidence, objects, and sources

The material below preserves the identifiers and references used in this report.

7.1Research objects

Products, actors, techniques, affected objects, and control points discussed in the report.

CVECVE-2026-44706

Authenticated Chatwoot filter SQL injection affecting tenant confidentiality

Affected versionsChatwoot >= 2.2.0 and < 4.11.2

4.11.2 is the historical minimum fixed release; as of July 26, 2026, move every web and worker to current 4.16.1

Endpoint/api/v1/accounts/*/conversations/filter

Authenticated conversation-filter entry

Endpoint/api/v1/accounts/*/contacts/filter

Authenticated contact-filter entry

Endpoint/api/v1/accounts/*/contacts/export

Authenticated contact-export entry executed later by Account::ContactsExportJob

Stored input/api/v1/accounts/*/custom_attribute_definitions

Stored custom-attribute-key entry relevant to the second sink; creation itself does not execute the vulnerable query

Repair commit432462f967373fe9a9a2722c374840115f9a6aee

Typed comparison binds, key validation, sanitization, and regression coverage

7.2Event chronology

  1. GitHub Security Lab reports the issue

    GHSL-2026-059 is submitted through GitHub Private Vulnerability Reporting.

  2. FilterService repair lands

    Commit 432462f967 introduces typed binds, custom-key controls, and regression tests.

  3. Chatwoot publishes version 4.11.2

    The fixed release follows the repair commit and contains the hardened FilterService object.

  4. Chatwoot publishes its advisory

    GHSA-9pgm-75gg-6948 records the affected range, authenticated prerequisite, impact, and fixed release.

  5. GitHub Security Lab publishes source analysis

    GHSL-2026-059 traces a comparison value into vulnerable SQL construction in v4.11.0.

7.3Sources and material

  1. Chatwoot GHSA-9pgm-75gg-6948 security advisoryhttps://github.com/chatwoot/chatwoot/security/advisories/GHSA-9pgm-75gg-6948
  2. GitHub Security Lab GHSL-2026-059https://securitylab.github.com/advisories/GHSL-2026-059_Chatwoot/
  3. Chatwoot repair commit 432462f967https://github.com/chatwoot/chatwoot/commit/432462f967373fe9a9a2722c374840115f9a6aee
  4. Vulnerable lt_gt_filter_values() in parent, lines 84-98https://github.com/chatwoot/chatwoot/blob/a08125e283b8e15b84b5073def1e785137b059ba/app/services/filter_service.rb#L84-L98
  5. Custom-key interpolation in parent, lines 152-177https://github.com/chatwoot/chatwoot/blob/a08125e283b8e15b84b5073def1e785137b059ba/app/services/filter_service.rb#L152-L177
  6. Typed comparison bind in the fixed commit, lines 84-106https://github.com/chatwoot/chatwoot/blob/432462f967373fe9a9a2722c374840115f9a6aee/app/services/filter_service.rb#L84-L106
  7. Coercion, rejection, and key construction in the fixed commit, lines 160-212https://github.com/chatwoot/chatwoot/blob/432462f967373fe9a9a2722c374840115f9a6aee/app/services/filter_service.rb#L160-L212
  8. Key grammar and normalization in the fixed commit, lines 31-56https://github.com/chatwoot/chatwoot/blob/432462f967373fe9a9a2722c374840115f9a6aee/app/models/custom_attribute_definition.rb#L31-L56
  9. Additional-attribute key sanitization in the fixed commit, lines 48-60https://github.com/chatwoot/chatwoot/blob/432462f967373fe9a9a2722c374840115f9a6aee/app/helpers/filters/filter_helper.rb#L48-L60
  10. Contact standard-date bind and rejection tests, lines 281-312https://github.com/chatwoot/chatwoot/blob/432462f967373fe9a9a2722c374840115f9a6aee/spec/services/contacts/filter_service_spec.rb#L281-L312
  11. Contact custom-date and numeric bind and rejection tests, lines 411-475https://github.com/chatwoot/chatwoot/blob/432462f967373fe9a9a2722c374840115f9a6aee/spec/services/contacts/filter_service_spec.rb#L411-L475
  12. Conversation date bind and rejection tests, lines 420-452https://github.com/chatwoot/chatwoot/blob/432462f967373fe9a9a2722c374840115f9a6aee/spec/services/conversations/filter_service_spec.rb#L420-L452
  13. Custom-key grammar and normalization tests, lines 9-57https://github.com/chatwoot/chatwoot/blob/432462f967373fe9a9a2722c374840115f9a6aee/spec/models/custom_attribute_definition_spec.rb#L9-L57
  14. Chatwoot v4.11.2 releasehttps://github.com/chatwoot/chatwoot/releases/tag/v4.11.2
  15. Chatwoot v4.16.1 current release as of July 26, 2026https://github.com/chatwoot/chatwoot/releases/tag/v4.16.1
  16. Rails Active Record query conditions and bindshttps://guides.rubyonrails.org/active_record_querying.html#conditions
  17. Rails ActiveRecord sanitization APIhttps://api.rubyonrails.org/classes/ActiveRecord/Sanitization/ClassMethods.html
  18. PostgreSQL JSON operators and functionshttps://www.postgresql.org/docs/current/functions-json.html
  19. PostgreSQL row-security policieshttps://www.postgresql.org/docs/current/ddl-rowsecurity.html
  20. PostgreSQL pg_stat_statementshttps://www.postgresql.org/docs/current/pgstatstatements.html