fix(#2128): route <tag> scans through a hardened shared seam (root-cause ReDoS fix)

A convergence audit showed the `<tag>[\s\S]*?</tag>` lazy-scan ReDoS was pervasive
(a dozen+ bespoke copies across roadmap-parser/check-command-router/verify), each
a distinct quadratic vector on a large document with unclosed tags. Rather than
whack-a-mole, single-source them (maintainer-directed):

- markdown-sectionizer: extractTaggedBlocks now shares one ReDoS-safe
  `taggedBlockPattern` (stop-at-next-open, bounded optional attributes) and gains
  a `stripTaggedBlocks` companion for block removal.
- roadmap-parser: 3 `<details>` strips -> stripTaggedBlocks (behavior-identical —
  no <details> here carries attributes).
- verify: actionZones + both <task> loops + their nested <name>/<files>/gate/req
  extractions -> extractTaggedBlocks (behavior byte-equivalent, verified).
- check-command-router: the objective|tasks?|action alternation hardened in place
  (distinct multi-tag shape); HTML-comment strip gains a `$` fallback.

Every vector now linear (<3ms on 1.5MB adversarial); real content unchanged
(end-to-end verify/roadmap resolution + task extraction confirmed). The only
remaining `<!--…-->` scan (uat.cts:201) is anchored + non-global — one scan, safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-10 10:42:18 -04:00
parent 2f6662d195
commit 01b691fae8
4 changed files with 50 additions and 41 deletions

View File

@@ -535,10 +535,7 @@ export function extractTaggedBlocks(content: string, tagName: string): string[]
if (typeof content !== 'string' || content.length === 0) return [];
if (typeof tagName !== 'string' || tagName.length === 0) return [];
// Escape the tag name for safe interpolation into a RegExp.
const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`<${escapedTag}>((?:(?!<${escapedTag}>)[\\s\\S])*?)</${escapedTag}>`, 'g');
const pattern = taggedBlockPattern(tagName, 'g');
const results: string[] = [];
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
@@ -547,6 +544,36 @@ export function extractTaggedBlocks(content: string, tagName: string): string[]
return results;
}
/**
* Build the single, ReDoS-safe `<tag>…</tag>` block regex shared by
* `extractTaggedBlocks` (extract bodies) and `stripTaggedBlocks` (remove blocks).
*
* Safety: the body uses a `(?:(?!<tag[\s>])[\s\S])*?` negative-lookahead scan
* that terminates at the NEXT opening `<tag>` instead of lazily rescanning the
* whole remaining document for a `</tag>` that may never appear — so a large
* document full of unclosed `<tag>` openings stays LINEAR, not quadratic
* (#2128). The opening tag tolerates optional attributes (`<tag foo="bar">`),
* bounded to 1000 chars so the attribute scan cannot itself ReDoS.
* Group 1 is the block body.
*/
function taggedBlockPattern(tagName: string, flags: string): RegExp {
const esc = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`<${esc}(?:\\s[^>]{0,1000})?>((?:(?!<${esc}[\\s>])[\\s\\S])*?)</${esc}>`, flags);
}
/**
* Remove every `<tagName>…</tagName>` block (opening tag, body, and closing tag)
* from `content`. The ReDoS-safe counterpart to `extractTaggedBlocks` — same
* hardened pattern, `.replace(…, '')` instead of body extraction. Case-insensitive
* by default (matching the `<details>` strip call sites); pass `caseSensitive`
* to force exact-case matching.
*/
export function stripTaggedBlocks(content: string, tagName: string, caseSensitive = false): string {
if (typeof content !== 'string' || content.length === 0) return '';
if (typeof tagName !== 'string' || tagName.length === 0) return content;
return content.replace(taggedBlockPattern(tagName, caseSensitive ? 'g' : 'gi'), '');
}
// ─── replaceSection ───────────────────────────────────────────────────────────
/**