Use proper PostgreSQL identifier/literal escaping in workspace DDL (#18024)

## Summary

- Replace the character-stripping approach (`removeSqlDDLInjection`)
with standard PostgreSQL `escapeIdentifier` and `escapeLiteral`
functions across all workspace schema manager services
- Add missing identifier escaping to `createForeignKey` (was the only
method in the FK manager without it)
- Add allowlist validation for index WHERE clauses and FK action types
- Harden tsvector expression builder with proper identifier quoting

## Context

The workspace schema managers build DDL dynamically from metadata (table
names, column names, enum values, etc.). The previous approach stripped
all non-alphanumeric characters — safe but lossy (silently corrupts
values with legitimate special characters). The new approach uses
PostgreSQL's standard escaping:

- **Identifiers**: double internal `"` and wrap → `"my""table"` (same
algorithm as `pg` driver's `escapeIdentifier`)
- **Literals**: double internal `'` and wrap → `'it''s a value'` (same
algorithm as `pg` driver's `escapeLiteral`)

`removeSqlDDLInjection` is kept only for name generation (e.g.,
`computePostgresEnumName`) where stripping to `[a-zA-Z0-9_]` is the
correct behavior.

## Files changed

| File | What |
|------|------|
| `remove-sql-injection.util.ts` | Added `escapeIdentifier` +
`escapeLiteral` |
| `validate-index-where-clause.util.ts` | New — allowlist for partial
index WHERE clauses |
| 5 schema manager services | Replaced strip+manual-quote with
`escapeIdentifier`/`escapeLiteral` |
| `build-sql-column-definition.util.ts` | `escapeIdentifier` for column
names, validated `generatedType` |
| `sanitize-default-value.util.ts` | `escapeLiteral` instead of
stripping |
| `serialize-default-value.util.ts` | `escapeLiteral` for values,
`escapeIdentifier` for enum casts |
| `get-ts-vector-column-expression.util.ts` | `escapeIdentifier` for
field names in expressions |
| `sanitize-default-value.util.spec.ts` | Updated tests for escape
behavior |

## Test plan

- [x] All 64 existing tests pass across 6 test suites
- [x] `lint:diff-with-main` passes
- [x] TypeScript typecheck — no new errors
- [ ] Verify workspace sync-metadata still works end-to-end
- [ ] Verify custom object/field creation works
- [ ] Verify enum field option changes work


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Félix Malfait
2026-02-18 15:24:10 +01:00
committed by GitHub
parent f4a61f26c0
commit 3bd431e95d
14 changed files with 410 additions and 321 deletions
@@ -8,6 +8,7 @@ import {
computeCompositeColumnName,
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { type SearchableFieldType } from 'src/engine/workspace-manager/utils/is-searchable-field.util';
import { isSearchableSubfield } from 'src/engine/workspace-manager/utils/is-searchable-subfield.util';
@@ -27,7 +28,6 @@ export const getTsVectorColumnExpressionFromFields = (
? columnExpressions.join(" || ' ' || ")
: 'NULL';
// Note: changing this expression requires reindexing/backfilling existing searchVector values.
return `to_tsvector('simple', ${concatenatedExpression})`;
};
@@ -59,9 +59,15 @@ const getColumnExpressionsFromField = (
});
if (fieldMetadataTypeAndName.type === FieldMetadataType.PHONES) {
const phoneNumberColumn = `"${fieldMetadataTypeAndName.name}PrimaryPhoneNumber"`;
const callingCodeColumn = `"${fieldMetadataTypeAndName.name}PrimaryPhoneCallingCode"`;
const additionalPhonesColumn = `"${fieldMetadataTypeAndName.name}AdditionalPhones"`;
const phoneNumberColumn = escapeIdentifier(
`${fieldMetadataTypeAndName.name}PrimaryPhoneNumber`,
);
const callingCodeColumn = escapeIdentifier(
`${fieldMetadataTypeAndName.name}PrimaryPhoneCallingCode`,
);
const additionalPhonesColumn = escapeIdentifier(
`${fieldMetadataTypeAndName.name}AdditionalPhones`,
);
const internationalFormats = [
`COALESCE(${callingCodeColumn} || ${phoneNumberColumn}, '')`,
@@ -79,7 +85,9 @@ const getColumnExpressionsFromField = (
}
if (fieldMetadataTypeAndName.type === FieldMetadataType.LINKS) {
const secondaryLinksColumn = `"${fieldMetadataTypeAndName.name}SecondaryLinks"`;
const secondaryLinksColumn = escapeIdentifier(
`${fieldMetadataTypeAndName.name}SecondaryLinks`,
);
const secondaryLinksExpression = `COALESCE(public.unaccent_immutable(TRANSLATE(regexp_replace(${secondaryLinksColumn}::text, '"(label|url)"\\s*:\\s*', '', 'g'), '[]{}",:', ' ')), '')`;
@@ -87,7 +95,9 @@ const getColumnExpressionsFromField = (
}
if (fieldMetadataTypeAndName.type === FieldMetadataType.EMAILS) {
const additionalEmailsColumn = `"${fieldMetadataTypeAndName.name}AdditionalEmails"`;
const additionalEmailsColumn = escapeIdentifier(
`${fieldMetadataTypeAndName.name}AdditionalEmails`,
);
const additionalEmailsExpression = `COALESCE(public.unaccent_immutable(TRANSLATE(${additionalEmailsColumn}::text, '[]",', ' ')), '') || ' ' || COALESCE(public.unaccent_immutable(TRANSLATE(REPLACE(${additionalEmailsColumn}::text, '@', ' '), '[]",', ' ')), '')`;
@@ -105,7 +115,7 @@ const getColumnExpression = (
columnName: string,
fieldType: FieldMetadataType,
): string => {
const quotedColumnName = `"${columnName}"`;
const quotedColumnName = escapeIdentifier(columnName);
switch (fieldType) {
case FieldMetadataType.EMAILS: