Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/like-redos-linear-matcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Fix ReDoS (CWE-1333) in `like()`/`ilike()`: patterns are now matched with an iterative two-pointer walk instead of being compiled to a RegExp, so crafted patterns with many `%` wildcards can no longer trigger catastrophic backtracking on near-miss values. The matcher preserves SQL wildcard semantics when the input value itself contains `%` or `_` characters.
49 changes: 39 additions & 10 deletions packages/db/src/query/compiler/evaluators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,13 @@ export function isCaseWhenConditionTrue(value: any): boolean {

/**
* Evaluates LIKE/ILIKE patterns
*
* `%` matches any sequence of characters (including none), `_` matches
* exactly one. The pattern is matched with an iterative two-pointer walk
* instead of being compiled to a RegExp: patterns with many `%` wildcards
* would produce overlapping `.*` segments whose catastrophic backtracking
* makes near-miss inputs take exponential time (CWE-1333). The walk is
* O(value.length * pattern.length) in the worst case.
*/
function evaluateLike(
value: any,
Expand All @@ -648,15 +655,37 @@ function evaluateLike(
const searchValue = caseInsensitive ? value.toLowerCase() : value
const searchPattern = caseInsensitive ? pattern.toLowerCase() : pattern

// Convert SQL LIKE pattern to regex
// First escape all regex special chars except % and _
let regexPattern = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`)

// Then convert SQL wildcards to regex
regexPattern = regexPattern.replace(/%/g, `.*`) // % matches any sequence
regexPattern = regexPattern.replace(/_/g, `.`) // _ matches any single char
let valueIndex = 0
let patternIndex = 0
// Position of the most recent `%` and the value position it restarts from
let starPatternIndex = -1
let starValueIndex = 0

while (valueIndex < searchValue.length) {
const patternChar =
patternIndex < searchPattern.length
? searchPattern[patternIndex]
: undefined
if (patternChar === `%`) {
starPatternIndex = patternIndex
starValueIndex = valueIndex
patternIndex++
} else if (patternChar === `_` || patternChar === searchValue[valueIndex]) {
valueIndex++
patternIndex++
} else if (starPatternIndex !== -1) {
// Mismatch after a `%`: let it consume one more character and retry
starValueIndex++
valueIndex = starValueIndex
patternIndex = starPatternIndex + 1
} else {
return false
}
}

// 's' (dotAll flag) makes '.' match all characters including line terminations
const regex = new RegExp(`^${regexPattern}$`, 's')
return regex.test(searchValue)
// The value is consumed; only trailing `%` wildcards may remain
while (searchPattern[patternIndex] === `%`) {
patternIndex++
}
return patternIndex === searchPattern.length
}
197 changes: 197 additions & 0 deletions packages/db/tests/query/compiler/evaluators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,81 @@ import { compileExpression } from '../../../src/query/compiler/evaluators.js'
import { Func, PropRef, Value } from '../../../src/query/ir.js'
import type { NamespacedRow } from '../../../src/types.js'

/**
* Reference law: `%` consumes zero or more UTF-16 code units, `_` consumes
* exactly one, and every other pattern code unit is literal. The dynamic
* program is structurally independent of the production two-pointer walk.
*/
function referenceLike(
value: string,
pattern: string,
caseInsensitive: boolean,
): boolean {
const searchValue = caseInsensitive ? value.toLowerCase() : value
const searchPattern = caseInsensitive ? pattern.toLowerCase() : pattern
let previous = new Array<boolean>(searchPattern.length + 1).fill(false)
previous[0] = true

for (
let patternIndex = 1;
patternIndex <= searchPattern.length;
patternIndex++
) {
previous[patternIndex] =
searchPattern[patternIndex - 1] === `%` && previous[patternIndex - 1]!
}

for (let valueIndex = 1; valueIndex <= searchValue.length; valueIndex++) {
const current = new Array<boolean>(searchPattern.length + 1).fill(false)
for (
let patternIndex = 1;
patternIndex <= searchPattern.length;
patternIndex++
) {
const patternCharacter = searchPattern[patternIndex - 1]
current[patternIndex] =
patternCharacter === `%`
? current[patternIndex - 1]! || previous[patternIndex]!
: (patternCharacter === `_` ||
patternCharacter === searchValue[valueIndex - 1]) &&
previous[patternIndex - 1]!
}
previous = current
}

return previous[searchPattern.length]!
}

function* deterministicLikeCases(count: number): Generator<{
value: string
pattern: string
caseInsensitive: boolean
}> {
// Repeated wildcard entries keep the bounded campaign concentrated on the
// precedence boundary while retaining literals, case folds, and newlines.
const alphabet = [`a`, `b`, `A`, `B`, `%`, `%`, `%`, `_`, `_`, `.`, `\n`, `é`]
let state = 0x1745
const next = () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0
return state
}
const pick = (exclusiveUpperBound: number) =>
Math.floor((next() / 0x1_0000_0000) * exclusiveUpperBound)
const build = (length: number) => {
let result = ``
for (let index = 0; index < length; index++) {
result += alphabet[pick(alphabet.length)]
}
return result
}

for (let index = 0; index < count; index++) {
const value = build(pick(8))
const pattern = build(pick(8))
yield { value, pattern, caseInsensitive: pick(2) === 1 }
}
}

describe(`evaluators`, () => {
describe(`compileExpression`, () => {
it(`handles unknown expression type`, () => {
Expand Down Expand Up @@ -316,6 +391,128 @@ describe(`evaluators`, () => {
expect(compiled({})).toBe(true)
})

it(`handles like with wildcard in the middle`, () => {
const func = new Func(`like`, [
new Value(`hello brave new world`),
new Value(`hello%world`),
])
const compiled = compileExpression(func)

expect(compiled({})).toBe(true)
})

it(`treats % as a wildcard when the value also contains %`, () => {
const likeFunc = compileExpression(
new Func(`like`, [new Value(`100% done`), new Value(`100%done`)]),
)
const ilikeFunc = compileExpression(
new Func(`ilike`, [
new Value(`A% LONG VALUE`),
new Value(`a%value`),
]),
)

expect(likeFunc({})).toBe(true)
expect(ilikeFunc({})).toBe(true)
})

it(`matches a bounded deterministic campaign against the LIKE law`, () => {
let mismatchCount = 0
const mismatchSamples: Array<{
value: string
pattern: string
caseInsensitive: boolean
expected: boolean
actual: boolean
}> = []

for (const testCase of deterministicLikeCases(20_000)) {
const functionName = testCase.caseInsensitive ? `ilike` : `like`
const compiled = compileExpression(
new Func(functionName, [
new Value(testCase.value),
new Value(testCase.pattern),
]),
)
const actual = compiled({})
const expected = referenceLike(
testCase.value,
testCase.pattern,
testCase.caseInsensitive,
)

if (actual !== expected) {
mismatchCount++
if (mismatchSamples.length < 5) {
mismatchSamples.push({ ...testCase, expected, actual })
}
}
}

expect({ mismatchCount, mismatchSamples }).toEqual({
mismatchCount: 0,
mismatchSamples: [],
})
})

it(`handles like where _ must match exactly one character`, () => {
const func = new Func(`like`, [new Value(`hell`), new Value(`hell_`)])
const compiled = compileExpression(func)

expect(compiled({})).toBe(false)
})

it(`handles like with a pattern of only wildcards`, () => {
const func = new Func(`like`, [new Value(``), new Value(`%%`)])
const compiled = compileExpression(func)

expect(compiled({})).toBe(true)
})

it(`handles like with an empty pattern`, () => {
const emptyValue = compileExpression(
new Func(`like`, [new Value(``), new Value(``)]),
)
const nonEmptyValue = compileExpression(
new Func(`like`, [new Value(`a`), new Value(``)]),
)

expect(emptyValue({})).toBe(true)
expect(nonEmptyValue({})).toBe(false)
})

it(`handles like matching across line breaks`, () => {
const func = new Func(`like`, [
new Value(`hello\nworld`),
new Value(`hello%world`),
])
const compiled = compileExpression(func)

expect(compiled({})).toBe(true)
})

it(`evaluates pathological wildcard patterns without backtracking (ReDoS)`, () => {
// Compiled to a regex, this pattern produces 20 overlapping `.*`
// segments; the near-miss value (fails only at the last character)
// then made the regex engine backtrack exponentially and hang.
const pattern = `a%`.repeat(19) + `a`
const nearMiss = `a`.repeat(200) + `b`
const likeFunc = compileExpression(
new Func(`like`, [new Value(nearMiss), new Value(pattern)]),
)
const ilikeFunc = compileExpression(
new Func(`ilike`, [
new Value(nearMiss.toUpperCase()),
new Value(pattern),
]),
)

const start = performance.now()
expect(likeFunc({})).toBe(false)
expect(ilikeFunc({})).toBe(false)
expect(performance.now() - start).toBeLessThan(1000)
})

it(`handles like with null value (3-valued logic)`, () => {
const func = new Func(`like`, [new Value(null), new Value(`hello%`)])
const compiled = compileExpression(func)
Expand Down
Loading