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:
+17
-7
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user