Summary
Twenty exposed metadata field updates over REST and GraphQL to users holding the
DATA_MODEL permission. UpdateFieldInput accepted an arbitrary JSON settings
object, and for the system TS_VECTOR field named searchVector,
settings.asExpression was concatenated unescaped into a generated-column
definition and executed as ALTER TABLE ... ADD COLUMN ... GENERATED ALWAYS AS (...).
A workspace administrator with the DATA_MODEL permission could therefore inject
stacked PostgreSQL statements through a normally hidden system field.
This crosses the intended boundary between "edit CRM schema" and "execute arbitrary
SQL as the application database user." Fixed in Twenty 2.15.0 (PR #21947).
Details
settings is a public GraphQL JSON field and is not among the properties omitted by
UpdateFieldInput, so it is fully caller-controlled on both the REST
(PATCH /rest/metadata/fields/:id) and GraphQL (updateOneField) mutations, which
are guarded only by the DATA_MODEL permission.
During update conversion, the caller-supplied settings was copied into
universalSettings, and the system-field migration validator explicitly allowed
universalSettings edits on system fields. The TS_VECTOR validator only checked
the field name, isSystem, and the presence of an expression — it did not parse,
allowlist, or escape asExpression:
.../flat-field-metadata/utils/compute-flat-field-to-update-and-related-flat-field-to-update.util.ts (copies settings → universalSettings)
.../workspace-migration-builder/validators/services/flat-field-metadata-validator.service.ts (SYSTEM_FIELD_ALLOWED_UPDATE_PROPERTIES permits universalSettings)
.../flat-field-metadata/validators/utils/validate-ts-vector-flat-field-metadata.util.ts (only presence check)
The value then reached the SQL sink unescaped. buildSqlColumnDefinition placed it
directly between parentheses, and the result was executed via queryRunner.query:
// packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util.ts
if (column.asExpression && column.type === 'tsvector') {
parts.push(`GENERATED ALWAYS AS (${column.asExpression})`);
Identifier escaping does not help, because the injected value is an SQL expression,
not an identifier.
PoC
As an authenticated user with the DATA_MODEL permission, update the system
searchVector field:
PATCH /rest/metadata/fields/{searchVectorFieldId}
{ "settings": { "asExpression": "to_tsvector('english', 'ok')) STORED; SELECT pg_sleep(5); --", "generatedType": "STORED" } }
The stacked statement breaks out of the GENERATED ALWAYS AS (...) clause in the
executed DDL:
ALTER TABLE "workspace_x"."person" ADD COLUMN "searchVector" tsvector
GENERATED ALWAYS AS (to_tsvector('english', 'ok')) STORED; SELECT pg_sleep(5); --) STORED
The 5-second response delay confirms execution of the injected statement.
Impact
- Primitive: arbitrary PostgreSQL statement execution as the Twenty application
database user.
- Cross-workspace: where the app DB role owns all workspace schemas, a
DATA_MODEL user in one workspace can read or modify data in other workspaces.
- Remote code execution (default self-hosted deployments): the shipped Docker
Compose, Podman, and app-dev defaults connect as a PostgreSQL superuser, so the
injection escalates to OS command execution inside the database container via
COPY ... TO PROGRAM. Helm / managed-Postgres deployments running as a
non-superuser role are limited to SQL execution as the app role.
- Availability: the same path can corrupt generated columns or run expensive
statements, causing workspace- or database-level denial of service.
This requires an authenticated, high-privilege (DATA_MODEL) workspace user; it is
not exploitable unauthenticated.
Remediation
Upgrade to Twenty 2.15.0 or later, which validates and rejects unsafe TS_VECTOR
expressions at the metadata layer and adds a last-resort guard before the
GENERATED ALWAYS AS (...) clause is emitted.
Additional hardening for self-hosted deployments: run the application against a
dedicated, non-superuser PostgreSQL role (do not use the postgres bootstrap
superuser), and restrict the DATA_MODEL permission to trusted operators.
Summary
Twenty exposed metadata field updates over REST and GraphQL to users holding the
DATA_MODELpermission.UpdateFieldInputaccepted an arbitrary JSONsettingsobject, and for the system
TS_VECTORfield namedsearchVector,settings.asExpressionwas concatenated unescaped into a generated-columndefinition and executed as
ALTER TABLE ... ADD COLUMN ... GENERATED ALWAYS AS (...).A workspace administrator with the
DATA_MODELpermission could therefore injectstacked PostgreSQL statements through a normally hidden system field.
This crosses the intended boundary between "edit CRM schema" and "execute arbitrary
SQL as the application database user." Fixed in Twenty
2.15.0(PR #21947).Details
settingsis a public GraphQL JSON field and is not among the properties omitted byUpdateFieldInput, so it is fully caller-controlled on both the REST(
PATCH /rest/metadata/fields/:id) and GraphQL (updateOneField) mutations, whichare guarded only by the
DATA_MODELpermission.During update conversion, the caller-supplied
settingswas copied intouniversalSettings, and the system-field migration validator explicitly alloweduniversalSettingsedits on system fields. TheTS_VECTORvalidator only checkedthe field name,
isSystem, and the presence of an expression — it did not parse,allowlist, or escape
asExpression:.../flat-field-metadata/utils/compute-flat-field-to-update-and-related-flat-field-to-update.util.ts(copiessettings→universalSettings).../workspace-migration-builder/validators/services/flat-field-metadata-validator.service.ts(SYSTEM_FIELD_ALLOWED_UPDATE_PROPERTIESpermitsuniversalSettings).../flat-field-metadata/validators/utils/validate-ts-vector-flat-field-metadata.util.ts(only presence check)The value then reached the SQL sink unescaped.
buildSqlColumnDefinitionplaced itdirectly between parentheses, and the result was executed via
queryRunner.query:Identifier escaping does not help, because the injected value is an SQL expression,
not an identifier.
PoC
As an authenticated user with the
DATA_MODELpermission, update the systemsearchVectorfield:The stacked statement breaks out of the
GENERATED ALWAYS AS (...)clause in theexecuted DDL:
The 5-second response delay confirms execution of the injected statement.
Impact
database user.
DATA_MODELuser in one workspace can read or modify data in other workspaces.Compose, Podman, and app-dev defaults connect as a PostgreSQL superuser, so the
injection escalates to OS command execution inside the database container via
COPY ... TO PROGRAM. Helm / managed-Postgres deployments running as anon-superuser role are limited to SQL execution as the app role.
statements, causing workspace- or database-level denial of service.
This requires an authenticated, high-privilege (
DATA_MODEL) workspace user; it isnot exploitable unauthenticated.
Remediation
Upgrade to Twenty
2.15.0or later, which validates and rejects unsafeTS_VECTORexpressions at the metadata layer and adds a last-resort guard before the
GENERATED ALWAYS AS (...)clause is emitted.Additional hardening for self-hosted deployments: run the application against a
dedicated, non-superuser PostgreSQL role (do not use the
postgresbootstrapsuperuser), and restrict the
DATA_MODELpermission to trusted operators.