diff --git a/src/main/files/search-in-files.js b/src/main/files/search-in-files.js index c933fff..662fbc7 100644 --- a/src/main/files/search-in-files.js +++ b/src/main/files/search-in-files.js @@ -7,10 +7,31 @@ const MAX_RESULTS = 1000; const MAX_FILE_BYTES = 2 * 1024 * 1024; const MAX_FILES = 10000; const MAX_QUERY_LENGTH = 1024; +const MAX_REGEX_LENGTH = 200; const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.cache']); -// Reject regexes with nested quantifiers / classic ReDoS shapes. -const UNSAFE_REGEX = /(\([^)]*[+*][^)]*\)[+*])|(\[[^\]]*\][+*])|(\.[+*]\.[+*])|(\(\?\:)/; +// Reject regexes with classic ReDoS shapes. This is a defense-in-depth +// denylist, not a proof of safety — the hard caps on length, files, and +// results are the primary defense. +const UNSAFE_REGEX = new RegExp( + // nested quantifiers: (a+)+, [a-z]*+ + '\\([^)]*[+*][^)]*\\)[+*?]' + + '|' + + // class with quantifier: [a-z]+ + '\\[[^\\]]*\\][+*]' + + '|' + + // dot-quantifier followed by dot-quantifier + '\\.[+*]\\s*\\.[+*]' + + '|' + + // lookahead / lookbehind + '\\(\\?[=!]' + + '|' + + // backrefs + '\\\\[1-9]' + + '|' + + // alternation with quantifier + '\\([^)]*\\|[^)]*\\)[+*]' +); function listFiles(rootPath) { const out = []; @@ -68,6 +89,7 @@ function listFiles(rootPath) { function makeMatcher(query, isRegex, caseSensitive) { if (isRegex) { + if (query.length > MAX_REGEX_LENGTH) return null; if (UNSAFE_REGEX.test(query)) return null; try { const re = new RegExp(query, caseSensitive ? '' : 'i'); diff --git a/tests/unit/main/files/search-in-files.test.js b/tests/unit/main/files/search-in-files.test.js index 62e5ed5..e56df84 100644 --- a/tests/unit/main/files/search-in-files.test.js +++ b/tests/unit/main/files/search-in-files.test.js @@ -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-'));