fix(table): escape LIKE wildcards in $contains filter values#3949
fix(table): escape LIKE wildcards in $contains filter values#3949lawrence3699 wants to merge 1 commit intosimstudioai:mainfrom
Conversation
The $contains filter operator builds an ILIKE pattern but does not
escape LIKE wildcard characters (%, _) in user-provided values.
This causes incorrect, over-broad query results when the search value
contains these characters. For example, filtering with
{ name: { $contains: "100%" } } matches any row where name
contains "100" followed by anything, not just the literal "100%".
Escape %, _, and \ in the value before interpolating into the ILIKE
pattern so that they match literally.
PR SummaryLow Risk Overview Adds a small Reviewed by Cursor Bugbot for commit b1790f3. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
There was a problem hiding this comment.
Pull request overview
Fixes $contains filtering in the table SQL query builder so user-provided values containing LIKE wildcards are treated literally (avoiding overly broad matches).
Changes:
- Added
escapeLikePattern()to escape%,_, and\before building anILIKEpattern. - Updated
buildContainsClause()to use the escaped value when constructing the%...%match pattern.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** Builds case-insensitive pattern match: `data->>'field' ILIKE '%value%'` */ | ||
| function buildContainsClause(tableName: string, field: string, value: string): SQL { | ||
| const escapedField = field.replace(/'/g, "''") | ||
| return sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${value}%`}` | ||
| return sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${escapeLikePattern(value)}%`}` | ||
| } |
There was a problem hiding this comment.
escapeLikePattern prefixes %, _, and \ with backslashes, but the generated ILIKE expression does not specify an ESCAPE clause. Adding ESCAPE '\\' makes the semantics explicit and matches the established pattern used elsewhere (e.g. apps/sim/lib/knowledge/documents/service.ts:895-907).
| /** Escapes LIKE/ILIKE wildcard characters so they match literally */ | ||
| function escapeLikePattern(value: string): string { | ||
| return value.replace(/[\\%_]/g, '\\$&') | ||
| } | ||
|
|
||
| /** Builds case-insensitive pattern match: `data->>'field' ILIKE '%value%'` */ | ||
| function buildContainsClause(tableName: string, field: string, value: string): SQL { | ||
| const escapedField = field.replace(/'/g, "''") | ||
| return sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${value}%`}` | ||
| return sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${escapeLikePattern(value)}%`}` | ||
| } |
There was a problem hiding this comment.
This change fixes wildcard handling for $contains, but there are no assertions covering the new escaping behavior. Please add unit tests (likely in apps/sim/lib/table/__tests__/sql.test.ts) that verify the generated pattern/params for inputs containing %, _, and \\ (e.g. 100%, a_b, c\\d) so regressions are caught.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b1790f3. Configure here.
| /** Escapes LIKE/ILIKE wildcard characters so they match literally */ | ||
| function escapeLikePattern(value: string): string { | ||
| return value.replace(/[\\%_]/g, '\\$&') | ||
| } |
There was a problem hiding this comment.
Duplicated escapeLikePattern utility across two modules
Low Severity
The new escapeLikePattern function in sql.ts is a semantic duplicate of the existing escapeLikePattern in apps/sim/lib/knowledge/documents/service.ts. Both escape the same three characters (\, %, _) for LIKE patterns, just with slightly different implementations (single regex vs. three chained .replace calls). Having two copies risks divergent bug fixes if one is updated without the other. This could be extracted to a shared utility.
Reviewed by Cursor Bugbot for commit b1790f3. Configure here.
Greptile SummaryThis PR correctly fixes a LIKE-wildcard injection bug in the
Confidence Score: 5/5Safe to merge — the escaping logic is correct and the only finding is a missing unit test (P2). The core fix is correct: the regex and replacement produce valid PostgreSQL LIKE escape sequences in a single pass, and Drizzle's parameterised query mechanism ensures no SQL injection is possible through the value. The only gap is that no unit tests were added for the new helper or for wildcard-containing inputs, which is a P2 quality concern that does not block merge. Only Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["buildFilterClause(filter, tableName)"] --> B{operator?}
B -- "\$contains" --> C["buildContainsClause(tableName, field, value)"]
C --> D["escapeLikePattern(value)"]
D --> E["regex replace /[\\\\%_]/g with \\\\$&"]
E --> F["e.g. '100%' → '100\\%', 'a_b' → 'a\\_b'", '"a\\\\b" → "a\\\\\\\\b"']
F --> G["sql\`col ILIKE ${'%' + escaped + '%'}\`"]
G --> H["Drizzle emits parameterized query\ncol ILIKE $1"]
H --> I["PostgreSQL interprets \\% as literal %\n(default LIKE escape char = \\\\)"]
I --> J["Only exact literal matches returned"]
Reviews (1): Last reviewed commit: "fix(table): escape LIKE wildcards in $co..." | Re-trigger Greptile |
| /** Escapes LIKE/ILIKE wildcard characters so they match literally */ | ||
| function escapeLikePattern(value: string): string { | ||
| return value.replace(/[\\%_]/g, '\\$&') | ||
| } | ||
|
|
||
| /** Builds case-insensitive pattern match: `data->>'field' ILIKE '%value%'` */ | ||
| function buildContainsClause(tableName: string, field: string, value: string): SQL { | ||
| const escapedField = field.replace(/'/g, "''") | ||
| return sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${value}%`}` | ||
| return sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${escapeLikePattern(value)}%`}` |
There was a problem hiding this comment.
Missing tests for
escapeLikePattern and wildcard inputs
The escapeLikePattern function is a new security-relevant helper, but no tests were added for it. The existing $contains test in __tests__/sql.test.ts (line 95) only asserts the result is defined — it does not verify that wildcards are actually escaped in the generated SQL pattern.
The PR description lists three concrete scenarios to verify ($contains: "100%", $contains: "a_b", $contains: "a\\b"), but none were implemented as test cases. Consider adding unit tests directly on escapeLikePattern covering these inputs:
describe('escapeLikePattern', () => {
it('escapes percent signs', () => {
expect(escapeLikePattern('100%')).toBe('100\\%')
})
it('escapes underscores', () => {
expect(escapeLikePattern('a_b')).toBe('a\\_b')
})
it('escapes backslashes', () => {
expect(escapeLikePattern('a\\b')).toBe('a\\\\b')
})
it('leaves plain strings unchanged', () => {
expect(escapeLikePattern('john')).toBe('john')
})
})

Summary
The
$containsfilter operator in the table query builder (buildContainsClause) interpolates user-provided values directly into an ILIKE pattern without escaping LIKE wildcard characters (%,_,\).This causes the filter to return incorrect, over-broad results when the search value contains these characters:
{ name: { $contains: "100%" } }matches any row wherenamecontains"100"followed by anything — not just the literal string"100%"{ name: { $contains: "user_name" } }matches"username","user name","userXname", etc. — because_matches any single character in LIKEFix
Escape
%,_, and\in the value before interpolating into the ILIKE pattern, so they are treated as literal characters. PostgreSQL uses\as the default LIKE escape character.Test plan
$containstests insql.test.tscontinue to pass (values without wildcards are unaffected)$contains: "100%"matches only rows containing the literal string"100%", not"100abc"$contains: "a_b"matches only rows containing the literal string"a_b", not"axb"