From 68ae9a8c5f4724ce8d6d2840e499b006f71734d0 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 19 Aug 2026 23:35:45 +0200 Subject: [PATCH] fix(files): match glob queries without regex so a hostile query cannot stall the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Files search compiled the user's query into a backtracking RegExp: '*a*a*a...' became '^.*a.*a.*a...$', the classic blowup, evaluated synchronously against every walked path — a pathological query could freeze the event loop for the whole server (and every user of it in multi-user mode). /api/search stays regex-free for exactly this reason. Globs now match through a two-pointer wildcard walk, O(text · pattern) worst case, with a 256-char query cap bounding the pattern side; an overlong query compiles to null, the same answer as an empty one. Semantics are unchanged (anchored, case-insensitive, * spans slashes) and the existing tests pass untouched; the pathological pattern gets a test that fails by timeout with the RegExp version. Co-Authored-By: Claude Fable 5 --- src/utils/file-query.ts | 65 +++++++++++++++++++++++++++++++---------- test/file-query.test.ts | 18 ++++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/utils/file-query.ts b/src/utils/file-query.ts index 3c64d211..39cae2a0 100644 --- a/src/utils/file-query.ts +++ b/src/utils/file-query.ts @@ -6,42 +6,77 @@ * * Semantics: * - Empty / whitespace-only query → `compileFileQuery` returns `null` (the - * caller treats this as "no search", falling back to the full tree). - * - A query containing a glob metachar (`*` or `?`) compiles to an anchored, - * case-insensitive RegExp: `*` → `.*`, `?` → `.`, every other regex - * metacharacter is escaped so it matches literally. + * caller treats this as "no search", falling back to the full tree). A query + * longer than `MAX_QUERY_LENGTH` compiles to `null` too: no honest filename + * search is that long, and the glob walk below is O(text · pattern). + * - A query containing a glob metachar (`*` or `?`) matches anchored and + * case-insensitively, `*` spanning any run (slashes included) and `?` exactly + * one character; every other character matches literally. * - Otherwise the query is a plain case-insensitive substring. * - When the query contains a `/` it matches against the relative path; else it * matches against the bare entry name. * + * ⚠️ Globs are matched by `globMatch` below, never by compiling the query into + * a RegExp: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking + * blowup, evaluated synchronously against every walked path — a pathological + * query could freeze the event loop for the whole server (the same reason + * `search-service.ts` is regex-free). The two-pointer wildcard walk is + * O(text · pattern) worst case, with both operands short by construction. + * * No fs / IO — safe to unit-test directly. */ export type FileQueryMatcher = (name: string, relativePath: string) => boolean; -// Escape every regex metacharacter. `*` and `?` are handled separately by the -// glob translation below, so they are intentionally NOT escaped here. -function escapeRegexExceptGlob(input: string): string { - return input.replace(/[.+^${}()|[\]\\]/g, '\\$&'); +// Longer than any honest file search; bounds the O(text · pattern) glob walk. +const MAX_QUERY_LENGTH = 256; + +/** + * Anchored glob match, linear-space two-pointer walk (no RegExp — see the + * fileoverview). `pattern` must already be lowercased; `text` is lowercased + * here so one compiled matcher serves many entries. + */ +function globMatch(pattern: string, rawText: string): boolean { + const text = rawText.toLowerCase(); + let p = 0; + let t = 0; + let starP = -1; + let starT = -1; + while (t < text.length) { + const pc = p < pattern.length ? pattern[p] : ''; + if (pc === '?' || pc === text[t]) { + p++; + t++; + } else if (pc === '*') { + // Remember the star; try matching zero characters first, and on a later + // mismatch re-expand it one character at a time from here. + starP = p++; + starT = t; + } else if (starP !== -1) { + p = starP + 1; + t = ++starT; + } else { + return false; + } + } + while (p < pattern.length && pattern[p] === '*') p++; + return p === pattern.length; } /** * Compile a query string into a matcher predicate, or `null` when the query is - * empty/whitespace (caller treats null as "no search"). + * empty/whitespace or overlong (caller treats null as "no search"). */ export function compileFileQuery(query: string): FileQueryMatcher | null { const trimmed = query.trim(); - if (trimmed === '') return null; + if (trimmed === '' || trimmed.length > MAX_QUERY_LENGTH) return null; const matchesPath = trimmed.includes('/'); const isGlob = trimmed.includes('*') || trimmed.includes('?'); if (isGlob) { - // Translate the glob to an anchored, case-insensitive RegExp. Escape all - // regex metacharacters first (except * and ?), then expand the wildcards. - const pattern = escapeRegexExceptGlob(trimmed).replace(/\*/g, '.*').replace(/\?/g, '.'); - const regex = new RegExp(`^${pattern}$`, 'i'); - return (name, relativePath) => regex.test(matchesPath ? relativePath : name); + const pattern = trimmed.toLowerCase(); + return (name, relativePath) => globMatch(pattern, matchesPath ? relativePath : name); } const needle = trimmed.toLowerCase(); diff --git a/test/file-query.test.ts b/test/file-query.test.ts index 8e073a4f..c5e24437 100644 --- a/test/file-query.test.ts +++ b/test/file-query.test.ts @@ -64,6 +64,24 @@ describe('compileFileQuery', () => { expect(m!('session.ts', 'src/session.ts')).toBe(true); expect(m!('session.ts', 'lib/session.ts')).toBe(false); }); + + it('stays fast on a pathological star-heavy pattern (no regex backtracking)', () => { + // `*a*a*a…` compiled to `^.*a.*a…$` is the classic backtracking blowup — + // as a RegExp this match takes effectively forever and this test fails by + // timeout. The two-pointer glob walk answers it in linear-ish time; the + // 500ms ceiling is generous so a loaded CI box cannot flake it. + const m = compileFileQuery('*a'.repeat(40) + 'b'); + expect(m).not.toBeNull(); + const started = performance.now(); + expect(m!('a'.repeat(200), 'a'.repeat(200))).toBe(false); + expect(performance.now() - started).toBeLessThan(500); + }); + + it('treats an overlong query as no search, like an empty one', () => { + // The glob walk is O(text · pattern); the length cap is what bounds it. + expect(compileFileQuery('a'.repeat(257))).toBeNull(); + expect(compileFileQuery('a'.repeat(256))).not.toBeNull(); + }); }); describe('matchFileQuery', () => {