fix(security): strengthen ReDoS denylist in search-in-files

Follow-up to the push-sweep review: the previous UNSAFE_REGEX used
JS bitwise OR (|) between regex literals, accidentally OR-ing the
RegExp objects instead of combining alternatives into one pattern.

Replace with a single RegExp that catches:
- nested quantifiers (a+)+, [a-z]*+
- class + quantifier
- dot-quantifier followed by dot-quantifier
- lookahead / lookbehind (?=, ?!)
- backrefs \1..\9
- alternation + quantifier

Also add MAX_REGEX_LENGTH=200 hard cap. The denylist is still
defense-in-depth — the length, files-traversed, and per-file-byte caps
are the primary defense.
This commit is contained in:
2026-07-23 09:39:08 +05:30
parent 5e76d77ece
commit f63a678c88
2 changed files with 61 additions and 2 deletions
@@ -105,6 +105,43 @@ describe('searchInFiles', () => {
).toBeGreaterThanOrEqual(0);
});
test('rejects regexes with lookahead / lookbehind', () => {
fs.writeFileSync(path.join(tmpDir, 'a.md'), 'hello');
expect(
searchInFiles({ rootPath: tmpDir, query: '(?=hello)h', isRegex: true })
).toEqual([]);
expect(
searchInFiles({ rootPath: tmpDir, query: '(?!foo)h', isRegex: true })
).toEqual([]);
});
test('rejects regexes with backrefs', () => {
fs.writeFileSync(path.join(tmpDir, 'a.md'), 'hello hello');
expect(
searchInFiles({ rootPath: tmpDir, query: '(hello)\\1', isRegex: true })
).toEqual([]);
});
test('rejects regexes with class + quantifier', () => {
fs.writeFileSync(path.join(tmpDir, 'a.md'), 'abc');
expect(
searchInFiles({ rootPath: tmpDir, query: '[a-z]+', isRegex: true })
).toEqual([]);
});
test('rejects regexes longer than 200 chars', () => {
fs.writeFileSync(path.join(tmpDir, 'a.md'), 'x');
const longRe = 'a'.repeat(250);
expect(
searchInFiles({ rootPath: tmpDir, query: longRe, isRegex: true })
).toEqual([]);
// But 200 chars is fine
const okRe = 'a'.repeat(200);
expect(
searchInFiles({ rootPath: tmpDir, query: okRe, isRegex: true }).length
).toBeGreaterThanOrEqual(0);
});
test('does not follow symlinks that escape rootPath', () => {
// Create a target outside tmpDir and a symlink inside pointing to it.
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'search-outside-'));