Refactor search vector field (#21947)

# Introduction
Refactoring the search vector field validation

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21947?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Paul Rastoin
2026-06-22 13:44:52 +02:00
committed by GitHub
parent a08f424cc5
commit 0b8368cd6c
6 changed files with 418 additions and 4 deletions
@@ -1,6 +1,8 @@
import {
assertSafeTsVectorExpression,
escapeIdentifier,
escapeLiteral,
isSafeTsVectorExpression,
removeSqlDDLInjection,
} from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
@@ -86,3 +88,103 @@ describe('escapeLiteral', () => {
expect(escapeLiteral('test"value')).toBe("'test\"value'");
});
});
describe('assertSafeTsVectorExpression', () => {
it('should accept a real server-generated tsvector expression', () => {
const generated = `to_tsvector('simple', COALESCE(public.unaccent_immutable("name"), '') || ' ' || COALESCE("emailsPrimaryEmail"::text, ''))`;
expect(() => assertSafeTsVectorExpression(generated)).not.toThrow();
});
it('should reject expressions containing a statement terminator', () => {
expect(() =>
assertSafeTsVectorExpression(
`to_tsvector('simple', coalesce("name", ''))) STORED; CREATE TABLE core."x" (a text); --`,
),
).toThrow('Unsafe tsvector expression detected');
});
it('should reject expressions containing a line comment', () => {
expect(() =>
assertSafeTsVectorExpression(`to_tsvector('simple', '') -- comment`),
).toThrow('Unsafe tsvector expression detected');
});
it('should reject expressions containing a block comment', () => {
expect(() =>
assertSafeTsVectorExpression(`to_tsvector('simple', '') /* comment */`),
).toThrow('Unsafe tsvector expression detected');
});
it('should reject null bytes', () => {
expect(() =>
assertSafeTsVectorExpression(`to_tsvector('simple', '\0')`),
).toThrow('Unsafe tsvector expression detected');
});
it('should reject a parenthesis-balanced clause-injection that uses no forbidden token', () => {
// Closes the wrapping AS( early and injects a sibling ADD COLUMN clause - no ";" or comment.
expect(() =>
assertSafeTsVectorExpression(
`to_tsvector('simple', coalesce("x",''))) STORED, ADD COLUMN "evil" text GENERATED ALWAYS AS (to_tsvector('simple', '')`,
),
).toThrow('Unsafe tsvector expression detected');
});
it('should reject dollar-quoting used to smuggle a breakout parenthesis', () => {
// PostgreSQL treats the quotes inside $$...$$ as literal text, so a single-quote-only scanner
// would skip the real ) between the two dollar-quoted segments and miscount it as balanced.
expect(() =>
assertSafeTsVectorExpression(
`to_tsvector('simple', $$'$$ ) STORED, ADD COLUMN "evil" text $$'$$`,
),
).toThrow('Unsafe tsvector expression detected');
});
it('should reject a double-quoted-identifier desync that hides a breakout parenthesis', () => {
// PostgreSQL reads "'" as identifiers (named '), so the middle ) is real code that breaks out.
// A single-quote-only scanner instead treats that ) as inside a string literal and accepts it.
expect(() => assertSafeTsVectorExpression(`"'")"'"`)).toThrow(
'Unsafe tsvector expression detected',
);
});
});
describe('isSafeTsVectorExpression', () => {
it('should return true for a safe generated expression', () => {
expect(
isSafeTsVectorExpression(
`to_tsvector('simple', COALESCE(public.unaccent_immutable("name"), ''))`,
),
).toBe(true);
});
it('should accept balanced parentheses that appear inside string literals', () => {
expect(
isSafeTsVectorExpression(
`to_tsvector('simple', regexp_replace("x"::text, '"(a|b)"\\s*:\\s*', '', 'g'))`,
),
).toBe(true);
expect(isSafeTsVectorExpression(`COALESCE("x", ')')`)).toBe(true);
});
it('should accept parentheses that appear inside a double-quoted identifier', () => {
expect(isSafeTsVectorExpression(`COALESCE("weird)name", '')`)).toBe(true);
});
it('should reject expressions with a forbidden token', () => {
expect(isSafeTsVectorExpression(`to_tsvector('simple', '') ; DROP`)).toBe(
false,
);
});
it('should reject any expression containing a dollar sign', () => {
expect(isSafeTsVectorExpression(`to_tsvector('simple', $$x$$)`)).toBe(
false,
);
});
it('should reject expressions with unbalanced parentheses', () => {
expect(isSafeTsVectorExpression(`coalesce("x", '')) STORED`)).toBe(false);
});
});
@@ -16,6 +16,80 @@ export const escapeIdentifier = (identifier: string): string => {
return '"' + identifier.replace(/"/g, '""') + '"';
};
const FORBIDDEN_TS_VECTOR_EXPRESSION_TOKENS = [
'\0',
';',
'--',
'/*',
'*/',
'$',
];
const hasBalancedParentheses = (expression: string): boolean => {
let depth = 0;
let context: 'code' | 'string' | 'identifier' = 'code';
for (let index = 0; index < expression.length; index++) {
const character = expression[index];
if (context === 'string') {
if (character === "'") {
if (expression[index + 1] === "'") {
index++;
} else {
context = 'code';
}
}
continue;
}
if (context === 'identifier') {
if (character === '"') {
if (expression[index + 1] === '"') {
index++;
} else {
context = 'code';
}
}
continue;
}
if (character === "'") {
context = 'string';
} else if (character === '"') {
context = 'identifier';
} else if (character === '(') {
depth++;
} else if (character === ')') {
depth--;
if (depth < 0) {
return false;
}
}
}
return depth === 0 && context === 'code';
};
export const isSafeTsVectorExpression = (expression: string): boolean => {
const hasForbiddenToken = FORBIDDEN_TS_VECTOR_EXPRESSION_TOKENS.some(
(token) => expression.includes(token),
);
if (hasForbiddenToken) {
return false;
}
return hasBalancedParentheses(expression);
};
export const assertSafeTsVectorExpression = (expression: string): void => {
if (!isSafeTsVectorExpression(expression)) {
throw new Error('Unsafe tsvector expression detected');
}
};
// PostgreSQL standard literal quoting: wraps in single quotes and
// doubles any internal single-quote characters. Prefixes with E when
// backslashes are present (standard_conforming_strings safety).