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:
+15
-23
@@ -2,7 +2,7 @@ import { type ColumnType, type QueryRunner } from 'typeorm';
|
||||
|
||||
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
|
||||
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
|
||||
export class WorkspaceSchemaColumnManagerService {
|
||||
async addColumns({
|
||||
@@ -18,12 +18,10 @@ export class WorkspaceSchemaColumnManagerService {
|
||||
}): Promise<void> {
|
||||
if (columnDefinitions.length === 0) return;
|
||||
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const addColumnClauses = columnDefinitions.map(
|
||||
(column) => `ADD COLUMN ${buildSqlColumnDefinition(column)}`,
|
||||
);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ${addColumnClauses.join(', ')}`;
|
||||
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} ${addColumnClauses.join(', ')}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -43,15 +41,12 @@ export class WorkspaceSchemaColumnManagerService {
|
||||
}): Promise<void> {
|
||||
if (columnNames.length === 0) return;
|
||||
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const cascadeClause = cascade ? ' CASCADE' : '';
|
||||
const dropClauses = columnNames.map((name) => {
|
||||
const safeName = removeSqlDDLInjection(name);
|
||||
|
||||
return `DROP COLUMN IF EXISTS "${safeName}"${cascadeClause}`;
|
||||
});
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ${dropClauses.join(', ')}`;
|
||||
const dropClauses = columnNames.map(
|
||||
(name) =>
|
||||
`DROP COLUMN IF EXISTS ${escapeIdentifier(name)}${cascadeClause}`,
|
||||
);
|
||||
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} ${dropClauses.join(', ')}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -69,11 +64,7 @@ export class WorkspaceSchemaColumnManagerService {
|
||||
oldColumnName: string;
|
||||
newColumnName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeOldColumnName = removeSqlDDLInjection(oldColumnName);
|
||||
const safeNewColumnName = removeSqlDDLInjection(newColumnName);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" RENAME COLUMN "${safeOldColumnName}" TO "${safeNewColumnName}"`;
|
||||
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} RENAME COLUMN ${escapeIdentifier(oldColumnName)} TO ${escapeIdentifier(newColumnName)}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -92,20 +83,21 @@ export class WorkspaceSchemaColumnManagerService {
|
||||
defaultValue?: string | number | boolean | null;
|
||||
columnType?: ColumnType;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
const tableRef = `${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)}`;
|
||||
const columnRef = escapeIdentifier(columnName);
|
||||
|
||||
const computeDefaultValueSqlQuery = () => {
|
||||
if (defaultValue === undefined) {
|
||||
return `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" DROP DEFAULT`;
|
||||
return `ALTER TABLE ${tableRef} ALTER COLUMN ${columnRef} DROP DEFAULT`;
|
||||
}
|
||||
|
||||
if (defaultValue === null) {
|
||||
return `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" SET DEFAULT NULL`;
|
||||
return `ALTER TABLE ${tableRef} ALTER COLUMN ${columnRef} SET DEFAULT NULL`;
|
||||
}
|
||||
|
||||
return `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" SET DEFAULT ${defaultValue}`;
|
||||
// defaultValue here is pre-serialized by serializeDefaultValue which
|
||||
// already applies escaping/sanitization to the value.
|
||||
return `ALTER TABLE ${tableRef} ALTER COLUMN ${columnRef} SET DEFAULT ${defaultValue}`;
|
||||
};
|
||||
|
||||
const sql = computeDefaultValueSqlQuery();
|
||||
|
||||
+65
-92
@@ -7,9 +7,11 @@ import {
|
||||
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
|
||||
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
|
||||
import { computePostgresEnumName } from 'src/engine/workspace-manager/workspace-migration/utils/compute-postgres-enum-name.util';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
import {
|
||||
escapeIdentifier,
|
||||
escapeLiteral,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
|
||||
// TODO: upstream does not guarantee transactionality, implement IF EXISTS or equivalent for idempotency
|
||||
export class WorkspaceSchemaEnumManagerService {
|
||||
async createEnum({
|
||||
queryRunner,
|
||||
@@ -30,13 +32,10 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
}
|
||||
|
||||
const sanitizedValues = values
|
||||
.map((value) => removeSqlDDLInjection(value.toString()))
|
||||
.map((value) => `'${value}'`)
|
||||
.map((value) => escapeLiteral(value.toString()))
|
||||
.join(', ');
|
||||
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeEnumName = removeSqlDDLInjection(enumName);
|
||||
const sql = `CREATE TYPE "${safeSchemaName}"."${safeEnumName}" AS ENUM (${sanitizedValues})`;
|
||||
const sql = `CREATE TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(enumName)} AS ENUM (${sanitizedValues})`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -50,9 +49,7 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
schemaName: string;
|
||||
enumName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeEnumName = removeSqlDDLInjection(enumName);
|
||||
const sql = `DROP TYPE IF EXISTS "${safeSchemaName}"."${safeEnumName}"`;
|
||||
const sql = `DROP TYPE IF EXISTS ${escapeIdentifier(schemaName)}.${escapeIdentifier(enumName)}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -68,10 +65,7 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
oldEnumName: string;
|
||||
newEnumName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeOldEnumName = removeSqlDDLInjection(oldEnumName);
|
||||
const safeNewEnumName = removeSqlDDLInjection(newEnumName);
|
||||
const sql = `ALTER TYPE "${safeSchemaName}"."${safeOldEnumName}" RENAME TO "${safeNewEnumName}"`;
|
||||
const sql = `ALTER TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(oldEnumName)} RENAME TO ${escapeIdentifier(newEnumName)}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -91,19 +85,12 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
beforeValue?: string;
|
||||
afterValue?: string;
|
||||
}): Promise<void> {
|
||||
const sanitizedValue = removeSqlDDLInjection(value);
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeEnumName = removeSqlDDLInjection(enumName);
|
||||
let sql = `ALTER TYPE "${safeSchemaName}"."${safeEnumName}" ADD VALUE '${sanitizedValue}'`;
|
||||
let sql = `ALTER TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(enumName)} ADD VALUE ${escapeLiteral(value)}`;
|
||||
|
||||
if (beforeValue) {
|
||||
const sanitizedBeforeValue = removeSqlDDLInjection(beforeValue);
|
||||
|
||||
sql += ` BEFORE '${sanitizedBeforeValue}'`;
|
||||
sql += ` BEFORE ${escapeLiteral(beforeValue)}`;
|
||||
} else if (afterValue) {
|
||||
const sanitizedAfterValue = removeSqlDDLInjection(afterValue);
|
||||
|
||||
sql += ` AFTER '${sanitizedAfterValue}'`;
|
||||
sql += ` AFTER ${escapeLiteral(afterValue)}`;
|
||||
}
|
||||
|
||||
await queryRunner.query(sql);
|
||||
@@ -122,16 +109,11 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
}): Promise<void> {
|
||||
const sanitizedOldValue = removeSqlDDLInjection(oldValue);
|
||||
const sanitizedNewValue = removeSqlDDLInjection(newValue);
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeEnumName = removeSqlDDLInjection(enumName);
|
||||
const sql = `ALTER TYPE "${safeSchemaName}"."${safeEnumName}" RENAME VALUE '${sanitizedOldValue}' TO '${sanitizedNewValue}'`;
|
||||
const sql = `ALTER TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(enumName)} RENAME VALUE ${escapeLiteral(oldValue)} TO ${escapeLiteral(newValue)}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
// TODO: optimize this to not create a temp enum and column if not necessary (e.g. using ADD VALUE)
|
||||
async alterEnumValues({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
@@ -255,11 +237,7 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
oldColumnName: string;
|
||||
newColumnName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeOldColumnName = removeSqlDDLInjection(oldColumnName);
|
||||
const safeNewColumnName = removeSqlDDLInjection(newColumnName);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" RENAME COLUMN "${safeOldColumnName}" TO "${safeNewColumnName}"`;
|
||||
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} RENAME COLUMN ${escapeIdentifier(oldColumnName)} TO ${escapeIdentifier(newColumnName)}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -277,15 +255,12 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
columnDefinition: WorkspaceSchemaColumnDefinition;
|
||||
enumTypeName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
|
||||
const columnDef = buildSqlColumnDefinition({
|
||||
...columnDefinition,
|
||||
type: `"${safeSchemaName}"."${enumTypeName}"`,
|
||||
type: `${escapeIdentifier(schemaName)}.${escapeIdentifier(enumTypeName)}`,
|
||||
});
|
||||
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD COLUMN ${columnDef}`;
|
||||
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} ADD COLUMN ${columnDef}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -301,15 +276,11 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" DROP COLUMN "${safeColumnName}"`;
|
||||
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} DROP COLUMN ${escapeIdentifier(columnName)}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
// TODO: explore USING clause to avoid the need for this function
|
||||
private async migrateEnumData({
|
||||
queryRunner,
|
||||
schemaName,
|
||||
@@ -334,36 +305,38 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
columnName: newColumnName,
|
||||
});
|
||||
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeOldColumnName = removeSqlDDLInjection(oldColumnName);
|
||||
const safeNewColumnName = removeSqlDDLInjection(newColumnName);
|
||||
const escapedSchema = escapeIdentifier(schemaName);
|
||||
const escapedTable = escapeIdentifier(tableName);
|
||||
const escapedOldColumn = escapeIdentifier(oldColumnName);
|
||||
const escapedNewColumn = escapeIdentifier(newColumnName);
|
||||
const escapedNewEnumType = `${escapedSchema}.${escapeIdentifier(newEnumTypeName)}`;
|
||||
|
||||
const caseStatements = Object.entries(oldToNewEnumOptionMap)
|
||||
.map(
|
||||
([oldEnumOption, newEnumOption]) =>
|
||||
`WHEN '${removeSqlDDLInjection(oldEnumOption)}' THEN '${removeSqlDDLInjection(newEnumOption)}'::"${safeSchemaName}"."${newEnumTypeName}"`,
|
||||
`WHEN ${escapeLiteral(oldEnumOption)} THEN ${escapeLiteral(newEnumOption)}::${escapedNewEnumType}`,
|
||||
)
|
||||
.join(' ');
|
||||
const mappedValuesCondition = Object.keys(oldToNewEnumOptionMap)
|
||||
.map((oldValue) => `'${removeSqlDDLInjection(oldValue)}'`)
|
||||
.map((oldValue) => escapeLiteral(oldValue))
|
||||
.join(', ');
|
||||
|
||||
const sqlQuery = columnDefinition.isArray
|
||||
? this.updateArrayEnum({
|
||||
safeSchemaName,
|
||||
safeTableName,
|
||||
safeOldColumnName,
|
||||
safeNewColumnName,
|
||||
newEnumTypeName,
|
||||
oldEnumTypeName,
|
||||
escapedSchema,
|
||||
escapedTable,
|
||||
escapedOldColumn,
|
||||
escapedNewColumn,
|
||||
escapedNewEnumType,
|
||||
escapedOldEnumType: `${escapedSchema}.${escapeIdentifier(oldEnumTypeName)}`,
|
||||
caseStatements,
|
||||
mappedValuesCondition,
|
||||
})
|
||||
: this.updateAtomicEnum({
|
||||
safeSchemaName,
|
||||
safeTableName,
|
||||
safeOldColumnName,
|
||||
safeNewColumnName,
|
||||
escapedSchema,
|
||||
escapedTable,
|
||||
escapedOldColumn,
|
||||
escapedNewColumn,
|
||||
caseStatements,
|
||||
mappedValuesCondition,
|
||||
});
|
||||
@@ -372,61 +345,61 @@ export class WorkspaceSchemaEnumManagerService {
|
||||
}
|
||||
|
||||
private updateArrayEnum({
|
||||
safeNewColumnName,
|
||||
safeOldColumnName,
|
||||
safeSchemaName,
|
||||
safeTableName,
|
||||
newEnumTypeName,
|
||||
oldEnumTypeName,
|
||||
escapedNewColumn,
|
||||
escapedOldColumn,
|
||||
escapedSchema,
|
||||
escapedTable,
|
||||
escapedNewEnumType,
|
||||
escapedOldEnumType,
|
||||
caseStatements,
|
||||
mappedValuesCondition,
|
||||
}: {
|
||||
safeSchemaName: string;
|
||||
safeTableName: string;
|
||||
safeOldColumnName: string;
|
||||
safeNewColumnName: string;
|
||||
newEnumTypeName: string;
|
||||
oldEnumTypeName: string;
|
||||
escapedSchema: string;
|
||||
escapedTable: string;
|
||||
escapedOldColumn: string;
|
||||
escapedNewColumn: string;
|
||||
escapedNewEnumType: string;
|
||||
escapedOldEnumType: string;
|
||||
caseStatements: string;
|
||||
mappedValuesCondition: string;
|
||||
}) {
|
||||
return `
|
||||
UPDATE "${safeSchemaName}"."${safeTableName}"
|
||||
SET "${safeNewColumnName}" = (
|
||||
UPDATE ${escapedSchema}.${escapedTable}
|
||||
SET ${escapedNewColumn} = (
|
||||
SELECT array_agg(
|
||||
CASE unnest_value::text
|
||||
${caseStatements}
|
||||
ELSE unnest_value::text::"${safeSchemaName}"."${newEnumTypeName}"
|
||||
ELSE unnest_value::text::${escapedNewEnumType}
|
||||
END
|
||||
)
|
||||
FROM unnest("${safeOldColumnName}") AS unnest_value
|
||||
FROM unnest(${escapedOldColumn}) AS unnest_value
|
||||
)
|
||||
WHERE "${safeOldColumnName}" IS NOT NULL
|
||||
AND "${safeOldColumnName}" && ARRAY[${mappedValuesCondition}]::"${safeSchemaName}"."${oldEnumTypeName}"[]`;
|
||||
WHERE ${escapedOldColumn} IS NOT NULL
|
||||
AND ${escapedOldColumn} && ARRAY[${mappedValuesCondition}]::${escapedOldEnumType}[]`;
|
||||
}
|
||||
|
||||
private updateAtomicEnum({
|
||||
safeNewColumnName,
|
||||
safeOldColumnName,
|
||||
safeSchemaName,
|
||||
safeTableName,
|
||||
escapedNewColumn,
|
||||
escapedOldColumn,
|
||||
escapedSchema,
|
||||
escapedTable,
|
||||
caseStatements,
|
||||
mappedValuesCondition,
|
||||
}: {
|
||||
caseStatements: string;
|
||||
mappedValuesCondition: string;
|
||||
safeSchemaName: string;
|
||||
safeTableName: string;
|
||||
safeOldColumnName: string;
|
||||
safeNewColumnName: string;
|
||||
escapedSchema: string;
|
||||
escapedTable: string;
|
||||
escapedOldColumn: string;
|
||||
escapedNewColumn: string;
|
||||
}) {
|
||||
return `
|
||||
UPDATE "${safeSchemaName}"."${safeTableName}"
|
||||
SET "${safeNewColumnName}" =
|
||||
CASE "${safeOldColumnName}"::text
|
||||
UPDATE ${escapedSchema}.${escapedTable}
|
||||
SET ${escapedNewColumn} =
|
||||
CASE ${escapedOldColumn}::text
|
||||
${caseStatements}
|
||||
END
|
||||
WHERE "${safeOldColumnName}" IS NOT NULL
|
||||
AND "${safeOldColumnName}"::text IN (${mappedValuesCondition})`;
|
||||
WHERE ${escapedOldColumn} IS NOT NULL
|
||||
AND ${escapedOldColumn}::text IN (${mappedValuesCondition})`;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-19
@@ -1,7 +1,15 @@
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { type WorkspaceSchemaForeignKeyDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-foreign-key-definition.type';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
|
||||
const ALLOWED_FK_ACTIONS = new Set([
|
||||
'CASCADE',
|
||||
'SET NULL',
|
||||
'RESTRICT',
|
||||
'NO ACTION',
|
||||
'SET DEFAULT',
|
||||
]);
|
||||
|
||||
export class WorkspaceSchemaForeignKeyManagerService {
|
||||
async createForeignKey({
|
||||
@@ -20,13 +28,19 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
[foreignKey.referencedColumnName],
|
||||
);
|
||||
|
||||
let sql = `ALTER TABLE "${schemaName}"."${foreignKey.tableName}" ADD CONSTRAINT "${foreignKeyName}" FOREIGN KEY ("${foreignKey.columnName}") REFERENCES "${schemaName}"."${foreignKey.referencedTableName}" ("${foreignKey.referencedColumnName}")`;
|
||||
let sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(foreignKey.tableName)} ADD CONSTRAINT ${escapeIdentifier(foreignKeyName)} FOREIGN KEY (${escapeIdentifier(foreignKey.columnName)}) REFERENCES ${escapeIdentifier(schemaName)}.${escapeIdentifier(foreignKey.referencedTableName)} (${escapeIdentifier(foreignKey.referencedColumnName)})`;
|
||||
|
||||
if (foreignKey.onDelete) {
|
||||
if (!ALLOWED_FK_ACTIONS.has(foreignKey.onDelete)) {
|
||||
throw new Error(`Unsupported ON DELETE action: ${foreignKey.onDelete}`);
|
||||
}
|
||||
sql += ` ON DELETE ${foreignKey.onDelete}`;
|
||||
}
|
||||
|
||||
if (foreignKey.onUpdate) {
|
||||
if (!ALLOWED_FK_ACTIONS.has(foreignKey.onUpdate)) {
|
||||
throw new Error(`Unsupported ON UPDATE action: ${foreignKey.onUpdate}`);
|
||||
}
|
||||
sql += ` ON UPDATE ${foreignKey.onUpdate}`;
|
||||
}
|
||||
|
||||
@@ -44,10 +58,7 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
tableName: string;
|
||||
foreignKeyName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" DROP CONSTRAINT IF EXISTS "${safeForeignKeyName}"`;
|
||||
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} DROP CONSTRAINT IF EXISTS ${escapeIdentifier(foreignKeyName)}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -63,10 +74,7 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
tableName: string;
|
||||
foreignKeyName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER CONSTRAINT "${safeForeignKeyName}" NOT DEFERRABLE`;
|
||||
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} ALTER CONSTRAINT ${escapeIdentifier(foreignKeyName)} NOT DEFERRABLE`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -82,10 +90,7 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
tableName: string;
|
||||
foreignKeyName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER CONSTRAINT "${safeForeignKeyName}" DEFERRABLE`;
|
||||
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} ALTER CONSTRAINT ${escapeIdentifier(foreignKeyName)} DEFERRABLE`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -101,10 +106,7 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
}): Promise<string | undefined> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeColumnName = removeSqlDDLInjection(columnName);
|
||||
|
||||
// Uses parameterized query ($1, $2, $3) — safe against injection
|
||||
const foreignKeys = await queryRunner.query(
|
||||
`
|
||||
SELECT
|
||||
@@ -121,7 +123,7 @@ export class WorkspaceSchemaForeignKeyManagerService {
|
||||
AND tc.table_name = $2
|
||||
AND kcu.column_name = $3
|
||||
`,
|
||||
[safeSchemaName, safeTableName, safeColumnName],
|
||||
[schemaName, tableName, columnName],
|
||||
);
|
||||
|
||||
return foreignKeys[0]?.constraint_name;
|
||||
|
||||
+30
-15
@@ -1,7 +1,17 @@
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { type WorkspaceSchemaIndexDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-index-definition.type';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
import { validateAndReturnIndexWhereClause } from 'src/engine/workspace-manager/workspace-migration/utils/validate-index-where-clause.util';
|
||||
|
||||
const ALLOWED_INDEX_TYPES = new Set([
|
||||
'BTREE',
|
||||
'HASH',
|
||||
'GIST',
|
||||
'SPGIST',
|
||||
'GIN',
|
||||
'BRIN',
|
||||
]);
|
||||
|
||||
export class WorkspaceSchemaIndexManagerService {
|
||||
async createIndex({
|
||||
@@ -15,25 +25,32 @@ export class WorkspaceSchemaIndexManagerService {
|
||||
tableName: string;
|
||||
index: WorkspaceSchemaIndexDefinition;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const safeIndexName = removeSqlDDLInjection(index.name);
|
||||
|
||||
const quotedColumns = index.columns.map(
|
||||
(column) => `"${removeSqlDDLInjection(column)}"`,
|
||||
const quotedColumns = index.columns.map((column) =>
|
||||
escapeIdentifier(column),
|
||||
);
|
||||
const isUnique = index.isUnique ? 'UNIQUE' : '';
|
||||
const indexType =
|
||||
index.type && index.type !== 'BTREE' ? `USING ${index.type}` : '';
|
||||
const whereClause = index.where ? `WHERE ${index.where}` : ''; // TODO: to sanitize -> might search for a lib to sanitize sql queries
|
||||
|
||||
let indexType = '';
|
||||
|
||||
if (index.type && index.type !== 'BTREE') {
|
||||
if (!ALLOWED_INDEX_TYPES.has(index.type)) {
|
||||
throw new Error(`Unsupported index type: ${index.type}`);
|
||||
}
|
||||
indexType = `USING ${index.type}`;
|
||||
}
|
||||
|
||||
const validatedWhereClause = validateAndReturnIndexWhereClause(index.where);
|
||||
const whereClause = validatedWhereClause
|
||||
? `WHERE ${validatedWhereClause}`
|
||||
: '';
|
||||
|
||||
const sql = [
|
||||
'CREATE',
|
||||
isUnique && 'UNIQUE',
|
||||
'INDEX IF NOT EXISTS',
|
||||
`"${safeIndexName}"`,
|
||||
escapeIdentifier(index.name),
|
||||
'ON',
|
||||
`"${safeSchemaName}"."${safeTableName}"`,
|
||||
`${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)}`,
|
||||
indexType,
|
||||
`(${quotedColumns.join(', ')})`,
|
||||
whereClause,
|
||||
@@ -54,9 +71,7 @@ export class WorkspaceSchemaIndexManagerService {
|
||||
schemaName: string;
|
||||
indexName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeIndexName = removeSqlDDLInjection(indexName);
|
||||
const sql = `DROP INDEX IF EXISTS "${safeSchemaName}"."${safeIndexName}"`;
|
||||
const sql = `DROP INDEX IF EXISTS ${escapeIdentifier(schemaName)}.${escapeIdentifier(indexName)}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
+4
-12
@@ -2,7 +2,7 @@ import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
|
||||
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
|
||||
export class WorkspaceSchemaTableManagerService {
|
||||
async createTable({
|
||||
@@ -21,16 +21,13 @@ export class WorkspaceSchemaTableManagerService {
|
||||
buildSqlColumnDefinition(columnDefinition),
|
||||
) || [];
|
||||
|
||||
// Add default columns if no columns specified
|
||||
if (sqlColumnDefinitions.length === 0) {
|
||||
sqlColumnDefinitions.push(
|
||||
'"id" uuid PRIMARY KEY DEFAULT gen_random_uuid()',
|
||||
);
|
||||
}
|
||||
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const sql = `CREATE TABLE IF NOT EXISTS "${safeSchemaName}"."${safeTableName}" (${sqlColumnDefinitions.join(', ')})`;
|
||||
const sql = `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} (${sqlColumnDefinitions.join(', ')})`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -46,10 +43,8 @@ export class WorkspaceSchemaTableManagerService {
|
||||
tableName: string;
|
||||
cascade?: boolean;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeTableName = removeSqlDDLInjection(tableName);
|
||||
const cascadeClause = cascade ? ' CASCADE' : '';
|
||||
const sql = `DROP TABLE IF EXISTS "${safeSchemaName}"."${safeTableName}"${cascadeClause}`;
|
||||
const sql = `DROP TABLE IF EXISTS ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)}${cascadeClause}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
@@ -65,10 +60,7 @@ export class WorkspaceSchemaTableManagerService {
|
||||
oldTableName: string;
|
||||
newTableName: string;
|
||||
}): Promise<void> {
|
||||
const safeSchemaName = removeSqlDDLInjection(schemaName);
|
||||
const safeOldTableName = removeSqlDDLInjection(oldTableName);
|
||||
const safeNewTableName = removeSqlDDLInjection(newTableName);
|
||||
const sql = `ALTER TABLE "${safeSchemaName}"."${safeOldTableName}" RENAME TO "${safeNewTableName}"`;
|
||||
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(oldTableName)} RENAME TO ${escapeIdentifier(newTableName)}`;
|
||||
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
|
||||
+31
-127
@@ -3,243 +3,147 @@ import { sanitizeDefaultValue } from 'src/engine/twenty-orm/workspace-schema-man
|
||||
describe('sanitizeDefaultValue', () => {
|
||||
describe('allowed functions', () => {
|
||||
it('should allow uuid_generate_v4() function', () => {
|
||||
// Prepare
|
||||
const input = 'public.uuid_generate_v4()';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe('public.uuid_generate_v4()');
|
||||
});
|
||||
|
||||
it('should allow now() function', () => {
|
||||
// Prepare
|
||||
const input = 'now()';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe('now()');
|
||||
});
|
||||
|
||||
it('should be case insensitive for allowed functions', () => {
|
||||
// Act & Assert
|
||||
|
||||
expect(sanitizeDefaultValue('NOW()')).toBe('NOW()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQL injection prevention', () => {
|
||||
it('should sanitize potential SQL injection in string values', () => {
|
||||
// Prepare
|
||||
describe('SQL injection prevention via escapeLiteral', () => {
|
||||
it('should escape single quotes to prevent SQL injection', () => {
|
||||
const maliciousInput = "'; DROP TABLE users; --";
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(maliciousInput);
|
||||
|
||||
// Assert
|
||||
expect(result).not.toContain('DROP TABLE');
|
||||
expect(result).not.toContain(';');
|
||||
expect(result).not.toContain('--');
|
||||
expect(result).toBe("'DROPTABLEusers'");
|
||||
// Single quotes are doubled, making injection impossible
|
||||
expect(result).toBe("'''; DROP TABLE users; --'");
|
||||
});
|
||||
|
||||
it('should sanitize quotes in string values', () => {
|
||||
// Prepare
|
||||
it('should preserve double quotes inside string literals (safe in SQL strings)', () => {
|
||||
const inputWithQuotes = 'test"value';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(inputWithQuotes);
|
||||
|
||||
// Assert
|
||||
expect(result).not.toContain('"');
|
||||
expect(result).toBe("'testvalue'");
|
||||
expect(result).toBe("'test\"value'");
|
||||
});
|
||||
|
||||
it('should sanitize parentheses in non-function values', () => {
|
||||
// Prepare
|
||||
it('should preserve parentheses inside string literals (safe in SQL strings)', () => {
|
||||
const inputWithParens = 'test(value)';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(inputWithParens);
|
||||
|
||||
// Assert
|
||||
expect(result).not.toContain('(');
|
||||
expect(result).not.toContain(')');
|
||||
expect(result).toBe("'testvalue'");
|
||||
expect(result).toBe("'test(value)'");
|
||||
});
|
||||
|
||||
it('should sanitize backslashes', () => {
|
||||
// Prepare
|
||||
it('should escape backslashes with E-string syntax', () => {
|
||||
const inputWithBackslash = 'test\\value';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(inputWithBackslash);
|
||||
|
||||
// Assert
|
||||
expect(result).not.toContain('\\');
|
||||
expect(result).toBe("'testvalue'");
|
||||
expect(result).toBe("E'test\\\\value'");
|
||||
});
|
||||
|
||||
it('should sanitize SQL comment patterns', () => {
|
||||
// Prepare
|
||||
it('should preserve comment-like patterns inside string literals (safe in SQL strings)', () => {
|
||||
const inputWithComments = 'value/*comment*/test';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(inputWithComments);
|
||||
|
||||
// Assert
|
||||
expect(result).not.toContain('/*');
|
||||
expect(result).not.toContain('*/');
|
||||
expect(result).toBe("'valuecommenttest'");
|
||||
});
|
||||
|
||||
it('should remove non-alphanumeric characters but preserve SQL keywords in alphanumeric form', () => {
|
||||
// Prepare
|
||||
const inputWithKeywords = 'SELECT * FROM users';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(inputWithKeywords);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("'SELECTFROMusers'");
|
||||
expect(result).not.toContain('*');
|
||||
expect(result).not.toContain(' ');
|
||||
expect(result).toBe("'value/*comment*/test'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('regular values', () => {
|
||||
it('should preserve underscores and alphanumeric characters in simple string values', () => {
|
||||
// Prepare
|
||||
it('should preserve simple string values', () => {
|
||||
const input = 'simple_value';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("'simple_value'");
|
||||
});
|
||||
|
||||
it('should preserve numeric values', () => {
|
||||
// Prepare
|
||||
const input = 12345;
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(12345);
|
||||
expect(sanitizeDefaultValue(12345)).toBe(12345);
|
||||
});
|
||||
|
||||
it('should preserve boolean values', () => {
|
||||
// Act & Assert
|
||||
expect(sanitizeDefaultValue(true)).toBe(true);
|
||||
expect(sanitizeDefaultValue(false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
// Prepare
|
||||
const input = '';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("''");
|
||||
expect(sanitizeDefaultValue('')).toBe("''");
|
||||
});
|
||||
|
||||
it('should remove whitespace but preserve alphanumeric and underscores', () => {
|
||||
// Prepare
|
||||
it('should preserve whitespace in string values', () => {
|
||||
const input = ' test ';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("'test'");
|
||||
expect(result).toBe("' test '");
|
||||
});
|
||||
|
||||
it('should preserve alphanumeric values with underscores', () => {
|
||||
// Prepare
|
||||
const input = 'test_value_123';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("'test_value_123'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed cases', () => {
|
||||
it('should distinguish between allowed functions and similar strings', () => {
|
||||
// Act & Assert
|
||||
expect(sanitizeDefaultValue('now()')).toBe('now()');
|
||||
expect(sanitizeDefaultValue('now_test')).toBe("'now_test'");
|
||||
expect(sanitizeDefaultValue('not_now()')).toBe("'not_now'");
|
||||
expect(sanitizeDefaultValue('not_now()')).toBe("'not_now()'");
|
||||
});
|
||||
|
||||
it('should handle functions with different casing but sanitize non-functions normally', () => {
|
||||
// Act & Assert
|
||||
it('should handle functions with different casing but escape non-functions', () => {
|
||||
expect(sanitizeDefaultValue('NOW()')).toBe('NOW()');
|
||||
expect(sanitizeDefaultValue('now_function')).toBe("'now_function'");
|
||||
});
|
||||
|
||||
it('should handle complex mixed input', () => {
|
||||
// Prepare
|
||||
it('should properly escape complex mixed input', () => {
|
||||
const complexInput = 'test"value; DROP TABLE users; /* comment */ now()';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(complexInput);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("'testvalueDROPTABLEuserscommentnow'");
|
||||
expect(result).not.toContain(';');
|
||||
expect(result).not.toContain('"');
|
||||
expect(result).not.toContain('/*');
|
||||
expect(result).not.toContain('*/');
|
||||
expect(result).not.toContain(' ');
|
||||
expect(result).toBe(
|
||||
"'test\"value; DROP TABLE users; /* comment */ now()'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null', () => {
|
||||
// Act & Assert
|
||||
expect(sanitizeDefaultValue(null)).toBe('NULL');
|
||||
});
|
||||
|
||||
it('should handle strings that start with allowed function names', () => {
|
||||
// Act & Assert
|
||||
expect(sanitizeDefaultValue('now_extended')).toBe("'now_extended'");
|
||||
expect(sanitizeDefaultValue('gen_random_uuid_custom')).toBe(
|
||||
"'gen_random_uuid_custom'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle strings with special characters', () => {
|
||||
// Prepare
|
||||
it('should escape all special characters properly', () => {
|
||||
const specialChars = '!@#$%^&*()+=[]{}|\\:";\'<>?,.';
|
||||
|
||||
// Act
|
||||
const result = sanitizeDefaultValue(specialChars);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("''");
|
||||
// Backslash triggers E-string, single quotes are doubled
|
||||
expect(result).toContain('E');
|
||||
expect(result).toContain("''");
|
||||
});
|
||||
|
||||
it('should handle very long strings', () => {
|
||||
// Prepare
|
||||
const longString = 'a'.repeat(1000) + '; DROP TABLE users;';
|
||||
|
||||
// Act
|
||||
it('should handle very long strings with injection attempts', () => {
|
||||
const longString = 'a'.repeat(1000) + "'; DROP TABLE users;";
|
||||
const result = sanitizeDefaultValue(longString);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(`'${'a'.repeat(1000)}DROPTABLEusers'`);
|
||||
expect(result).not.toContain(';');
|
||||
expect(result).not.toContain(' ');
|
||||
// The single quote in the injection attempt is properly escaped
|
||||
expect(result).toBe(`'${'a'.repeat(1000)}''; DROP TABLE users;'`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+15
-5
@@ -1,19 +1,27 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
|
||||
const ALLOWED_GENERATED_TYPES = new Set(['STORED', 'VIRTUAL']);
|
||||
|
||||
export const buildSqlColumnDefinition = (
|
||||
column: WorkspaceSchemaColumnDefinition,
|
||||
): string => {
|
||||
const safeName = removeSqlDDLInjection(column.name);
|
||||
const parts = [`"${safeName}"`];
|
||||
const parts = [escapeIdentifier(column.name)];
|
||||
|
||||
// column.type is either a PostgreSQL type name from fieldMetadataTypeToColumnType
|
||||
// (safe enum-mapped), or a schema-qualified enum type pre-escaped by the caller.
|
||||
parts.push(column.isArray ? `${column.type}[]` : column.type);
|
||||
|
||||
// asExpression is built internally by getTsVectorColumnExpressionFromFields
|
||||
// (never user-provided). Field names within are escaped at the source.
|
||||
if (column.asExpression && column.type === 'tsvector') {
|
||||
parts.push(`GENERATED ALWAYS AS (${column.asExpression})`); // TODO: to sanitize
|
||||
if (column.generatedType) {
|
||||
parts.push(`GENERATED ALWAYS AS (${column.asExpression})`);
|
||||
if (
|
||||
column.generatedType &&
|
||||
ALLOWED_GENERATED_TYPES.has(column.generatedType)
|
||||
) {
|
||||
parts.push(column.generatedType);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +34,8 @@ export const buildSqlColumnDefinition = (
|
||||
parts.push('NOT NULL');
|
||||
}
|
||||
|
||||
// column.default is pre-serialized by serializeDefaultValue which
|
||||
// applies escapeLiteral/removeSqlDDLInjection to the value.
|
||||
if (isDefined(column.default) && column.type !== 'tsvector') {
|
||||
parts.push(`DEFAULT ${column.default}`);
|
||||
}
|
||||
|
||||
+8
-5
@@ -1,4 +1,9 @@
|
||||
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
import { escapeLiteral } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
|
||||
const ALLOWED_DEFAULT_FUNCTIONS = new Set([
|
||||
'public.uuid_generate_v4()',
|
||||
'now()',
|
||||
]);
|
||||
|
||||
export const sanitizeDefaultValue = (
|
||||
defaultValue: string | number | boolean | null,
|
||||
@@ -7,14 +12,12 @@ export const sanitizeDefaultValue = (
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
const allowedFunctions = ['public.uuid_generate_v4()', 'now()'];
|
||||
|
||||
if (typeof defaultValue === 'string') {
|
||||
if (allowedFunctions.includes(defaultValue.toLowerCase())) {
|
||||
if (ALLOWED_DEFAULT_FUNCTIONS.has(defaultValue.toLowerCase())) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return `'${removeSqlDDLInjection(defaultValue)}'`;
|
||||
return escapeLiteral(defaultValue);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
|
||||
Reference in New Issue
Block a user