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:
+102
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+74
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user