From eca4d8e13f359293abe513c424ebcfe5d2b6b8db Mon Sep 17 00:00:00 2001 From: tim-hub Date: Tue, 14 Jul 2026 22:15:21 +1200 Subject: [PATCH] fix(toolkit): parse reference without ReDoS-prone regex A single greedy regex kept tripping CodeQL js/polynomial-redos: any .-class quantifier adjacent to \s+ backtracks polynomially (latest witness: 'aA !' + many 'aa !'). Replace it with a linear scan for the last 'letter + whitespace + non-space' boundary using a non-overlapping pattern, then slice. Behavior unchanged; 38 toolkit tests pass; witness input now linear (200k reps in ~6ms). Claude-Session: https://claude.ai/code/session_013pCNGDmUyfJia4tE4aWoYM --- .../src/lib/reference.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/bible-reference-toolkit/src/lib/reference.ts b/packages/bible-reference-toolkit/src/lib/reference.ts index 154cac1..f8c4a4d 100644 --- a/packages/bible-reference-toolkit/src/lib/reference.ts +++ b/packages/bible-reference-toolkit/src/lib/reference.ts @@ -33,19 +33,25 @@ export class Reference implements IReference { reference = reference.replace(/\./g, ''); this.source = reference; - // Match up to last letter - thats the book. Everything else === the chapter/verse. - // Anchored (^...$) and the separator (\s+) owns all whitespace while the - // chapter/verse group starts with a non-space (\S) so no two quantifiers - // overlap - avoids super-linear backtracking (ReDoS) on long inputs. - const referenceParts = reference.match(/^(.+[A-Za-z])\s+(\S.*)$/); + // Split at the last whitespace run that is preceded by a letter: everything + // before is the book, everything after is the chapter/verse. The matcher + // (a letter, then whitespace, then a non-space) has no overlapping + // quantifiers, so scanning stays linear - no ReDoS. (A single greedy regex + // like /(.+[A-Za-z])\s+(.+)/ backtracks polynomially on long input.) + const separator = /[A-Za-z]\s+(?=\S)/g; + let splitIndex = -1; + let match: RegExpExecArray | null; + while ((match = separator.exec(reference)) !== null) { + splitIndex = match.index + 1; + } - if (!referenceParts?.length || referenceParts?.length < 3) { + if (splitIndex === -1) { throw new Error( 'You must supply a Bible reference, either a string (i.e. "Mark 2") or an object (i.e. { book: 1, chapter: 2, verse: 1 })' ); } - const bookName = referenceParts[1]; - const chapterAndVerse = referenceParts[2]; + const bookName = reference.slice(0, splitIndex); + const chapterAndVerse = reference.slice(splitIndex).replace(/^\s+/, ''); // Lookup the book book = Reference.bookIdFromName(bookName);