Skip to content
NusaSec
Research / Disclosure

SQL Injection in Twenty CRM searchVector leads to Remote Code Execution

Summary

Twenty exposes metadata field updates over REST and GraphQL to users with the DATA_MODEL permission. UpdateFieldInput includes arbitrary JSON settings, and for the system TS_VECTOR field named searchVector, settings.asExpression is later spread into a generated-column definition and concatenated into ALTER TABLE ... ADD COLUMN ... GENERATED ALWAYS AS (...). A data-model administrator can therefore inject stacked PostgreSQL statements through a normally hidden system field.

The primitive is post-auth, high-privilege within a workspace, but it crosses the intended boundary between "edit CRM schema" and "execute arbitrary SQL as the application database user". In deployments where the app DB role owns all workspace schemas, this can read or modify other workspaces. In the shipped Docker Compose, Podman, and app-dev defaults, the application connects as the bootstrap PostgreSQL superuser, so the SQL injection escalates to OS command execution in the database container through PostgreSQL COPY ... TO PROGRAM.

Technical Detail

Reachable source

settings is a public GraphQL JSON field, and UpdateFieldInput omits only a small set of fields, not settings:

export class UpdateFieldInput extends OmitType(
  PartialType(FieldMetadataDTO, InputType),
  [
    'id',
    'type',
    'createdAt',
    'updatedAt',
    'standardOverrides',
    'applicationId',
    'morphId',
  ] as const,
) {
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
settings?: FieldMetadataSettings<T>;

The REST controller is guarded by DATA_MODEL, lists field IDs, and passes the request body directly into the field update service:

@Controller('rest/metadata/fields')
@UseGuards(
  JwtAuthGuard,
  WorkspaceAuthGuard,
  SettingsPermissionGuard(PermissionFlagType.DATA_MODEL),
)
@Patch(':id')
async updateOnePatch(
  @Param('id', new ParseUUIDPipe()) id: string,
  @Body() update: UpdateFieldInput,
  @AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
  return this.handleUpdate({ id, update, workspaceId });
}

The same mutation is exposed through metadata GraphQL:

@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Mutation(() => FieldMetadataDTO)
async updateOneField(

Default Docker privilege boundary

The RCE escalation depends on database role privileges. The shipped plain Docker Compose file wires the app and worker to the same role used to initialize the postgres:16 container:

PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
 
POSTGRES_USER: ${PG_DATABASE_USER:-postgres}

The official Postgres image creates POSTGRES_USER as the bootstrap superuser. With no environment overrides, the app connects as postgres; with PG_DATABASE_USER overridden, the app connects as the custom bootstrap superuser created by the same image.

The Podman deployment uses the same pattern with POSTGRES_USER: twenty and PG_DATABASE_URL: postgresql://twenty:..., and the app-dev single-image initializer explicitly creates the application role as a superuser:

CREATE ROLE twenty WITH LOGIN PASSWORD 'twenty' SUPERUSER

By contrast, the Helm chart creates a dedicated application user with plain CREATE USER ... WITH PASSWORD ..., so Helm and managed-Postgres deployments should be described as arbitrary SQL as the app database role unless the role is separately granted superuser or pg_execute_server_program.

References

https://attack.mitre.org/techniques/T1190/