/** * Sanitization SSOT for the unified markdown engine. * * Layered defense (order matters, see engine.tsx): * 1. `escapeUnknownHtmlTags` — TEXT pre-pass. Escapes ``s outside the * effective allowlist so LLM-emitted pseudo-tags (``, ``) * never reach React as unknown elements (React 19 crash guard). * NOT a security boundary. * 2. `rehype-raw` parses remaining raw HTML into HAST. * 3. `rehypeSanitize` with `buildSanitizeSchema(...)` — the audited * allow-list boundary (hast-util-sanitize) with a schema extended to * exactly what our surfaces need. * 4. `rehypeStripUnsafe` — custom strip pass kept as defense-in-depth * (srcset candidate scanning, iframe[srcdoc], belt-and-suspenders if * the schema is ever loosened). * * COUPLED-ALLOWLIST INVARIANT (tested in __tests__/sanitize-invariant.test.ts): * the two effective tag lists are EQUAL (case-insensitively), both computed * AFTER merging `extraAllowedHtmlTags`. Both directions matter: * - pre-pass ⊆ sanitizer: the pre-pass must never admit a raw tag the * sanitizer then silently drops. * - sanitizer ⊆ pre-pass: the pre-pass must never ESCAPE a tag the * sanitizer would happily keep. This direction was broken before * 2026-07: `strike` (and every other `defaultSchema`-only tag) survived * the sanitizer but was escaped to `<strike>` source text by the * pre-pass, so legacy authored markup regressed to visible tag soup. * Both lists are now derived from the SINGLE `effectiveTagList()` below — * never fork them. * * ONE documented exception, and it is CONTENT-dependent rather than * list-level (so the invariant test still holds as an equality of tag SETS): * an UNCLOSED RAWTEXT/RCDATA opener (`` written inside that span * VISIBLE in the closer haystack, `hasLaterCloser` true, the prose opener LIVE, * and parse5 swallowing the rest of the message as the textarea's value. * A clean cliff, padding length the only variable: span content ≤4094 chars ⇒ * blanked, opener escaped, 0 live textareas; ≥4099 ⇒ closer visible, 1 live * textarea. That is a fail-OPEN in the security boundary, and it contradicts * this module's own contract that every mask approximation "rounds towards * blanking". * * THE BOUND COULD NOT SIMPLY BE DROPPED. `[^\n]` confines backtracking to one * LINE, but a single line is not a small input — a chat message can be one. * MEASURED (round 16, this repo's vitest env, one line of nothing but * backticks — the pathological shape; figures from plain node are within 3%): * * input capped regex uncapped regex this linear scan * 50K chars 295 ms (5.9 µs/c) 615 ms (12.3 µs/c) 0.69 ms (14 ns/c) * 200K chars 1220 ms (6.1 µs/c) 9772 ms (48.9 µs/c) 0.42 ms (2 ns/c) * 800K chars 5072 ms (6.3 µs/c) 158263 ms (198 µs/c) — (node) * * The capped regex is flat per char (linear, huge constant); the UNCAPPED one * is plainly QUADRATIC — 31x the capped cost at 800KB and still climbing. Every * other shape probed (lone tick + text, `` `` `` + text, one tick per 32 chars, * one tick per line) is ≈2-4 ns/char in BOTH regex spellings, so the blowup is * specific to long backtick runs — which an attacker controls. The cap was load * bearing; the REGEX is what had to go. A realistic 260KB backtick-dense * message (`Use `foo` and `bar` here.` × 10000) scans in 4.2 ms. * * `findInlineCodeRanges` is a LINEAR index scan that reproduces the old * regex's match semantics exactly (verified by differential fuzz against the * uncapped regex) with no backtracking and no cap: per line it collects the * backtick RUNS, then for each opener run of length `n` picks the largest * closer length `k ≤ n` that occurs later on the line — either inside the same * run (needs `n ≥ 2k`, mirroring the regex giving back backticks from a greedy * `` (`+) ``) or at the earliest following run of length ≥ k — and takes the * EARLIEST such position (the lazy quantifier). The forward walk is amortized * O(1) per run because the scan cursor jumps past every run it skipped. * * `PROTECTED_SPAN_RE` (the CARVE) KEEPS its cap, deliberately: the two * consumers round in OPPOSITE directions, see the note on * `escapeLeftoverTagStarts`. In the carve, "not known to be code" means ESCAPE, * so an over-cap span there costs a code sample rendered as escaped text. */ const BACKTICK_CODE = 0x60 /** * PARAGRAPH SEGMENTS, NOT LINES (round 18 — SECURITY). A CommonMark code span * CROSSES LINE BREAKS: `` `foo\n` `` is one `inlineCode` node, so * that `` is a code sample and not a closer — yet this scan (and * `PROTECTED_SPAN_RE`, whose body class is `[^\n]`) was strictly PER LINE, so * the mask never saw the span, the closer stayed visible in the haystack, * `hasLaterCloser` returned true, and a prose `` — proving the closer is a code sample * — beside a live textarea swallowing the prose). This is the shape that is not * a CONTAINER at all, so no container sweep could ever have reached it. * * A code span CANNOT cross a paragraph break, so the scan unit is a maximal run * of non-blank lines. Blank lines still terminate a segment, which keeps the * fail-CLOSED direction (an unterminated opener consumes at most its own * paragraph, never the rest of the document) and keeps the bound linear — the * `suffMax` / cursor structure is unchanged, `\n` is simply an ordinary * character inside a segment. * * `PROTECTED_SPAN_RE` (the CARVE) is deliberately left per-line: it rounds the * other way, so at worst a multi-line code sample renders as escaped text. */ function findInlineCodeRanges(source: string): Array<[number, number]> { const ranges: Array<[number, number]> = [] const len = source.length let pos = 0 while (pos <= len) { // Grow one PARAGRAPH SEGMENT: the maximal run of non-blank lines starting // at or after `pos`. `segStart`/`segEnd` bound it; blank lines never enter. let segStart = -1 let segEnd = -1 while (pos <= len) { let end = source.indexOf('\n', pos) if (end === -1) end = len const blank = isBlankLine(source.slice(pos, end)) if (blank && segStart !== -1) break if (!blank) { if (segStart === -1) segStart = pos segEnd = end } pos = end === len ? len + 1 : end + 1 } if (segStart === -1) break const lineStart = segStart const lineEnd = segEnd const runStart: number[] = [] const runLen: number[] = [] for (let i = lineStart; i < lineEnd; i++) { if (source.charCodeAt(i) !== BACKTICK_CODE) continue let j = i + 1 while (j < lineEnd && source.charCodeAt(j) === BACKTICK_CODE) j++ runStart.push(i) runLen.push(j - i) i = j - 1 } const n = runStart.length if (n > 0) { // suffMax[t] = longest run at or after t; 0 past the end. const suffMax = new Array(n + 1).fill(0) for (let t = n - 1; t >= 0; t--) suffMax[t] = Math.max(runLen[t], suffMax[t + 1]) let cursor = lineStart let idx = 0 while (idx < n) { const runEnd = runStart[idx] + runLen[idx] // The scan resumes at the END of the previous match, which can land // MID-RUN — exactly as the global regex's `lastIndex` did. The // REMAINDER of the run is then an opener in its own right (`` `a`` `` // matches twice), so clamp rather than skip. if (runEnd <= cursor) { idx++ continue } const p = Math.max(runStart[idx], cursor) const openLen = runEnd - p // Largest closer length reachable via a LATER run, and via THIS one. const kLater = Math.min(openLen, suffMax[idx + 1]) const kSame = openLen >> 1 const k = Math.max(kLater, kSame) // No match is possible only when this is the last run and it is a // single backtick — every longer run closes on itself, so advancing by // one character (what the regex does) cannot find one either. if (k < 1) { cursor = runEnd idx++ continue } let q = kSame >= k ? p + k : -1 if (q === -1) for (let t = idx + 1; t < n; t++) if (runLen[t] >= k) { q = runStart[t] break } ranges.push([p, q + k]) cursor = q + k } } } return ranges } /** Exported for the differential fuzz against the retired regex. */ export const __findInlineCodeRangesForTest = findInlineCodeRanges /** * ASCII-ONLY case fold. `String.prototype.toLowerCase()` is NOT * length-preserving: U+0130 (Turkish dotted capital `İ`) expands to `i` + * U+0307 (1 code unit → 2). It is the only BMP character that does so, and it * is ordinary Turkish prose (`İstanbul`, `İzmir`) — so a message with enough * of them ahead of a ``, and left the opener LIVE * — reopening the RAWTEXT swallow the mask exists to close. * * Tag names are ASCII by definition (`TAG_LIKE_REGEX` only matches * `[a-zA-Z][a-zA-Z0-9-]*`), so folding ASCII alone loses nothing. * `buildCloserHaystack(src).length === src.length` is asserted over the whole * fixture corpus in the parity test — that invariant is the actual guard. */ function foldAsciiCase(text: string): string { return text.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32)) } /** * --------------------------------------------------------------------------- * MASK-ONLY code-region blanking (never the carve) * --------------------------------------------------------------------------- * All of the blanking passes below share one contract: * * - they SCAN `source` (the folded but otherwise unmasked copy) and APPLY the * resulting ranges to `masked`. That split is LOAD-BEARING: the inline-code * pass chews a pair of backticks off an unclosed ```` ``` ```` opener (the * opener run gives back backticks until a single one matches the next one as * its closer), so a fence scan over the masked copy sees no fence at all. Both * strings have identical indices, so offsets transfer verbatim. * `blankComments` is the ONE deliberate exception (it is fed the masked * copy, and runs last) — see its docblock for why the reasoning inverts. * - they are LENGTH-PRESERVING (every non-newline char in a range becomes a * space), because `escapeOutsideFences` indexes the mask with offsets it * computed from the ORIGINAL text. * - they fail CLOSED. Blanking too much can only make `hasLaterCloser` return * false, i.e. ESCAPE a RAWTEXT opener that could have stayed live; blanking * too little leaves a prose `` stayed * in the haystack and the opener above it stayed live, in ten spellings). * These two tables have caught six literally-false or missing claims across * six rounds; they are the instrument, and the round that skipped them is the * round that regressed. Filling them in is not documentation, it is the audit. * * blankLinkDefinitions * R no `[` on the line / `LINK_DEF_OPEN_RE` fails → not a definition line. * R `\[^` (GFM footnote), REFERENCED → BLOCK-parsed body, may * hold real HTML (r19). * Only when the label is * REFERENCED: r22 found * the decline fail-OPEN * for the unreferenced * case, which * `blankUnreferencedFootnotes` * now blanks whole. * R `findLabelClose`: `]` not followed by `:` → shortcut reference or * plain text; remark * EMITS it (the same * exclusion * `blankBracketLabels` * documents). * R `findLabelClose`: unescaped `[` in the label → CommonMark rejects the * label → paragraph text. * R `findLabelClose`: paragraph bound, no `]:` → an ordinary * `[`-leading prose * paragraph. * R `parseDestOnLine` -1 (angle dest unclosed) → no line ending allowed * in `<…>`, and a bare * dest may not start with * `<` → not a definition. * R `parseDefTail`/`parseTitleTail` `decline` → trailing content, or a * title neither * space-separated nor * delimiter-opened → * remark reads a * PARAGRAPH. On the * OPENER line this * unwinds the WHOLE * construct (nothing is * blanked). On a * CONTINUATION line it * splits in TWO, and the * old single sentence * was true of only one * (r22): * · after `needTitle` * the definition WAS * already complete * (a title is * optional), so * stopping is exact; * · after `needDest` * it was NOT — with * no parseable * destination remark * reads the whole run * as a PARAGRAPH — * and lines * `i..close.line` * are ALREADY blanked * by the committed * loop. So this exit * OVER-blanks the * label lines; safe * because * over-blanking only * hides closers. * C `openTitle` (title opens, never closes) → BLANK to the paragraph * bound. * C end of `lines` / blank line while continuing → everything up to the * bound is already * blanked. * (no length cap exists in this pass at all) * * blankInlineLinkPayloads / parseInlineLinkPayload * C `q >= limit`, input REMAINS past the cap → returns `limit`, and * the caller widens to * `paragraphEnd` (r20). * R `q >= limit` because the INPUT IS EXHAUSTED → returns -1. Nothing * closes the payload and * nothing will, so remark * reads text too. Split * out in r22: it used to * share the cap exit, so * the ordinary STREAMING * tail `see [a](/x` * blanked its paragraph * and flickered. * R angle dest not closed before `\n`/end → CommonMark forbids a * line ending in `<…>`. * R bare dest with unbalanced `(` → not a link → text. * R no `)` where the payload must end → not a link → text. * - `s[q] !== close` after the title loop → UNREACHABLE: the loop * exits only on the * closer or on `q >= * limit`, and the latter * returns `overflow` * first. * * blankUnreferencedFootnotes (round 23 — the entry round 22 never wrote) * R `masked.indexOf('[^') === -1` (whole-pass skip) → the document contains * no footnote SPELLING at * all, so there is nothing * to blank. Exact. * R `FOOTNOTE_DEF_OPEN_RE` fails / no `:` after the * label / `footnoteLabelEnd` -1 on the OPENER → not a definition line; * remark reads a paragraph * and any closer on it is * REAL. * R label IS referenced (in the REF-MASK) → round 19's case: * remark keeps the * definition, its body is * BLOCK-parsed and may * hold real HTML. * C `footnoteLabelEnd === -1` mid-line → `break` → abandons the REST OF * THE LINE's references. * Fail-CLOSED (fewer * references ⇒ more * definitions blanked), * but note the shape it * gives up on: a line * `[^x[ … [^f]` silently * stops counting at the * voided label, so a REAL * `[^f]` after it can be * missed and its * definition over-blanked * into escaped source * (cosmetic). * C body walk `break` on a SECOND definition line → the body ended; the * neighbour is blanked (or * not) on its OWN merits. * C body walk `break` on a de-indented line after a * blank one → GFM's own body bound. * (both body `break`s only SHORTEN the blanked range, i.e. leave MORE * haystack visible — the same direction as declining the definition * entirely, which is round 19's shipped behaviour, never a new hole) * (no length cap exists in this pass at all) * * blankBracketLabels * R no `[` in the document → no bracket construct. * R `]` with an empty stack → closes nothing. * - no length cap and no parse that can fail: the walk is total over the * document and crosses newlines, so it has NO give-up path to classify. * * WHICH LINES EACH PASS CLAIMS, AND AT WHAT COLUMN: * * findInlineCodeRanges — EVERY line, at column 0 of its PARAGRAPH SEGMENT * (a maximal run of non-blank lines). Container-agnostic: backtick runs are * matched with no column or prefix anchoring, so a `> ` / indent prefix is * ordinary text between ticks. Spans CROSS line breaks (round 18) and stop * at a paragraph break, which is exactly CommonMark's bound. * blankFencedRegions — every line of the run it is GIVEN, at that run's * column (the caller cut it). Absolute-column-limited by `FENCE_RE`'s 0..3 * indent cap, which is WHY the container passes must re-cut and re-run it. * blankIndentedCode — every line of the run it is given, at that run's * column, with a list-content-column stack for the +4 threshold. * blankLinkDefinitions — EVERY line, in TWO dimensions that must both be * stated, because round 21 found the entry true of the first and silently * false of the second. * COLUMN: at column 0 AND at the column its own prefix reaches. * `LINK_DEF_CONTAINER_PREFIX` absorbs a blockquote run and AT MOST ONE * list marker, so the top-level call covers a definition at nesting depth * 0 or 1 directly. DEEPER nesting (`- - [a]: …`) is NOT covered by the * top-level call — round 19's corrected entry — and is reached only * because both container passes re-run this pass on their stripped runs, * and `blankListItemCode` re-cuts nested markers by recursing into * ITSELF. That re-cut is load-bearing, not redundancy. * SHAPE: what the label, destination and title may CONTAIN — the * dimension the old wording never mentioned, so five ESCAPED-delimiter * spellings and five MULTI-LINE spellings were "covered" by an entry that * had not examined them. The pass is now a CHARACTER PARSER, not a line * regex: `\` + one character is consumed as a unit EVERYWHERE (so a * title may hold `\"` / `\'` / `\)` and a label `\]`), the LABEL may * span lines up to the paragraph bound, and an unterminated TITLE is * blanked to that same bound. There is no length cap of any kind. What it * does NOT claim, and why, is on the exits themselves (see below). * blankUnreferencedFootnotes — EVERY line, container-agnostic and at ANY * depth: `FOOTNOTE_DEF_OPEN_RE`'s own prefix absorbs a blockquote run plus * ANY NUMBER of list markers, so unlike `blankLinkDefinitions` this pass * needs no container re-cut — and could not use one, because its reference * set is document-GLOBAL and a stripped run cannot see it. Exactly ONE * top-level call. No length cap of any kind. * IT READS TWO SOURCES, and that split is the security-load-bearing part * (round 23): * DEFINITIONS from the current MASK — a definition an earlier pass hid is * not blanked, which leaves it in the haystack (round 19's direction). * REFERENCES from a SEPARATE, MORE-BLANKED copy (`footnoteReferenceMask`), * because every region remark consumes into an ATTRIBUTE or drops — image * alt, full-reference label, inline link title / angle destination, HTML * comment, raw HTML block, inline tag attribute, autolink — yields a * PHANTOM reference, and a phantom keeps a dropped definition (and its * ``) in the haystack: fail-OPEN, reproduced live in ten * spellings. Counting FEWER references only blanks MORE, so that copy may * over-blank freely. * CLAIMED BODY: the label line, its lazy paragraph continuations, and * further blocks indented >= 4 columns past the blockquote run. * blankInlineLinkPayloads — EVERY inline link/image payload in the document, * container-agnostic: the scan is anchored on the `](` bigram with no column * or prefix anchoring, so a container prefix is ordinary text ahead of it. * Claims ONLY the `(…)` payload — never the `[…]` text of an INLINE LINK, * which is inline-parsed and reaches the document as HTML. Its cap * (`INLINE_LINK_PAYLOAD_MAX`) BLANKS THROUGH rather than declining; only an * unparseable SHAPE declines (round 20). * blankBracketLabels — EVERY `[…]` group in the document whose text remark * consumes into an attribute or an identifier: an image's alt (`[` preceded * by `!`), the second group of a `][` adjacency (a full reference's label), * the first group of a `][]` adjacency (a collapsed reference's identifier), * and a footnote label (`[^…]`, reference AND definition). Container- * agnostic: one left-to-right bracket walk, no column or prefix anchoring. * Claims NEITHER an inline link's `[…]` NOR a bare shortcut reference's — * remark emits both as HTML, so a closer there is real (round 20). The * `][` / `][]` adjacency is compared PER NESTING DEPTH (round 23 — a single * `prev` let a nested group clobber the sibling it had to be compared with, * so `[txt][[^f]]`'s label was never claimed). Its ONE option, * `{ footnoteLabels: false }`, is for `footnoteReferenceMask` only. * blankComments — EVERY line, container-agnostic: `HTML_COMMENT_RE` is * `[\s\S]`-based and anchored nowhere, so a comment matches straight through * any prefix. Runs LAST, over the masked copy (see its docblock). * blankQuotedCode — supplies runs cut at the BLOCKQUOTE content column, * for every maximal run of quote-prefixed lines, INCLUDING the line that * opens the quote (the prefix regex matches it like any other). * blankListItemCode — supplies runs cut at the LIST-ITEM content column * for EVERY line inside a list item at ANY content column >= 1 (round 19 — * the gate used to be `>= 4` on the claim that "below column 4 the top-level * passes already cover the line at the right column", which is true of a * CONTINUATION line and FALSE of the MARKER line: at content column 2 or 3 * the marker line is examined only at column 0, where the leading `- ` / * `1. ` is not whitespace and `FENCE_RE` cannot match). Includes THE MARKER * LINE ITSELF (round 18) and re-cuts NESTED markers by recursing into itself * (round 19), since `LIST_MARKER_RE` matches only the FIRST marker on a * line. * * The two container passes call each other AND `blankListItemCode` calls itself, * and all of them call the fence + indented + link-definition passes, so a line * nested in any order and any DEPTH of containers is eventually cut to its own * content column. That composition is a MEANS to the invariant above, not a * substitute for it. When adding a pass or a container, the question to answer * is "which lines does it claim, at which column, and is any line now claimed by * nobody" — not "does the call graph look symmetric". */ /** * Length-preserving blank of MANY ranges in one pass. Ranges must be * non-overlapping and ascending. * * THE ONLY BLANKING PRIMITIVE (round 18 — performance). There used to be a * single-range `blankRange` beside it, and `blankIndentedCode` / * `blankFencedRegions` / `blankComments` each folded the document through it * ONCE PER LINE OR REGION. Every call rebuilds the entire string, so masking an * all-indented-code document was QUADRATIC — measured on * `__buildCloserHaystackForTest`: 37 KB → 6 ms, 151 KB → 178 ms, 389 KB → * 1127 ms, 989 KB → 3753 ms (2.5x input ⇒ ~6x time), and the two container * passes re-run both over every nested run, multiplying the constant. A ~400 KB * KB article or release-notes page — all of which go through this renderer — * blocked the main thread for over a second. Every pass now COLLECTS ranges and * applies them here exactly once, which is what the inline pass already did. * After, same four sizes and same harness: 2 ms / 5 ms / 12 ms / 28 ms — dead * linear at ~28 ns/char, a 134x improvement at 989 KB. * * Do not reintroduce a per-range helper; a pass that blanks in a loop is the * regression. */ function blankRanges(masked: string, ranges: Array<[number, number]>): string { if (ranges.length === 0) return masked const parts: string[] = [] let cursor = 0 for (const [from, to] of ranges) { parts.push(masked.slice(cursor, from), masked.slice(from, to).replace(/[^\n]/g, ' ')) cursor = to } parts.push(masked.slice(cursor)) return parts.join('') } /** One scannable line: where it starts, and (for container-nested scans) where * its scanned content starts once the container prefix is stripped. */ interface MaskLine { start: number contentStart: number content: string } function toMaskLines(source: string): MaskLine[] { const out: MaskLine[] = [] let offset = 0 for (const line of source.split('\n')) { out.push({ start: offset, contentStart: offset, content: line }) offset += line.length + 1 } return out } /** * Blank every FENCED region in a line run, using the real CommonMark fence * state machine (`createFenceTracker`) rather than a regex. * * This replaces the old `blankUnclosedFence` + `PROTECTED_SPAN_RE` fence * alternative and subsumes both: * - a CLOSED fence is blanked from its opener line through its closer line; * - an EOF-terminated fence is blanked from its opener line to the end of the * run (the case `blankUnclosedFence` covered); * - a would-be closer carrying an INFO STRING (```` ```html ````) no longer * ends the region, because the tracker applies CommonMark's rule that a * closer may not have one. `PROTECTED_SPAN_RE` did end the span there, so * ` ```js … ```html\n\n``` ` left the `` unmasked and * a prose opener above it stayed live. */ function blankFencedRegions(masked: string, lines: MaskLine[]): string { const fences = createFenceTracker() const ranges: Array<[number, number]> = [] let openStart: number | null = null let lastEnd = 0 for (const line of lines) { const role = fences.push(line.content) lastEnd = line.contentStart + line.content.length if (role === 'open') openStart = line.start else if (role === 'close' && openStart !== null) { ranges.push([openStart, lastEnd]) openStart = null } } if (openStart !== null) ranges.push([openStart, lastEnd]) return blankRanges(masked, ranges) } /** * Blank INDENTED code blocks. A `` written as an indented code * sample is code, not a closer — but `FENCE_RE` deliberately caps fence indent * at 3 spaces, so the tracker never sees these lines. * * The threshold is LIST-AWARE, not a flat 4 columns. CommonMark measures * indented code from the enclosing list item's CONTENT column, so under * `1. ` (content column 4) a 4-space line is a paragraph continuation, not * code — and `"1. Here is a form:\n\n \n"` had * its closer blanked, `hasLaterCloser` returned false, and a perfectly real * element got escaped. A numbered list containing markup is a very ordinary * chat answer, so "fail closed" is not a good enough excuse here. * * The walk mirrors `blankQuotedCode`'s line-state approach: a stack of open * list content columns, `code` meaning `indent >= top + 4`. Blank lines keep * the state (a list item survives them); a line indented below the top of the * stack pops it. A line indented past the code threshold is treated as code * BEFORE it is considered as a list marker. * * SCAN-SOURCE INVERSION (same reasoning as `blankComments`, and NOT the shared * contract): the caller must pass lines re-derived from the CURRENT mask, not * from `folded`. Fence content is already blanked by `blankFencedRegions`, but * that only holds for WRITING the mask — a walk over `folded` still SEES those * lines, so a `- x` written inside a fence pushed a content column of 2 and a * later top-level column-4 indented-code line then failed `indent >= top + 4`, * went unblanked, and its code-sample `` kept a prose opener LIVE. * Over the masked copy those lines are all spaces, hit the `isBlankLine` * continue, preserve list state and push no bogus column. * * CONTENT-COLUMN CLAMP: CommonMark clamps an item's content column to * `markerEnd + 1` when the first block starts MORE than 4 spaces after the * marker — the remainder is indented code INSIDE the item. Taking the literal * column instead meant `-` + six spaces raised the threshold to 11, so a * column-7 `` code sample was not blanked. * * KNOWN OMISSION (deliberate, fail-CLOSED): there is NO paragraph state. Under * CommonMark indented code cannot interrupt a paragraph, so a LAZY * continuation line — `'Here is a form: \n'` * — is paragraph text, yet this walk blanks it as code and the (real, properly * closed) element is escaped to visible source. That is cosmetic, and the * option NOT taken here is the fail-OPEN direction: skipping the code test in * paragraph state means blanking LESS, i.e. more closers visible to * `hasLaterCloser` and more openers left live. The list-awareness above was * worth its risk because it is unconditional over an entire list item; this * one is not, so it is documented rather than implemented. */ const LIST_MARKER_RE = /^([ \t]*)(?:[-*+]|\d{1,9}[.)])([ \t]+)(?=\S)/ /** Visual column of `upTo` chars of `line`, expanding tabs to 4-col stops. */ function visualColumn(line: string, upTo: number): number { let col = 0 for (let i = 0; i < upTo; i++) col = line[i] === '\t' ? col + 4 - (col % 4) : col + 1 return col } function leadingIndent(line: string): number { const ws = /^[ \t]*/.exec(line)![0] return visualColumn(line, ws.length) } /** Character index at which `line` reaches visual column `col`, or -1 when the * column falls INSIDE a tab (no exact character boundary) or the line is too * short. The mask is length-preserving, so a container prefix can only ever be * cut at a character boundary; -1 makes the caller decline to strip, which * leaves the line looking indented and therefore blanks MORE (fail-CLOSED). */ function charIndexAtColumn(line: string, col: number): number { let c = 0 for (let i = 0; i < line.length; i++) { if (c === col) return i c = line[i] === '\t' ? c + 4 - (c % 4) : c + 1 if (c > col) return -1 } return c === col ? line.length : -1 } function blankIndentedCode(masked: string, lines: MaskLine[]): string { const listContentCols: number[] = [] const ranges: Array<[number, number]> = [] for (const line of lines) { // CommonMark's blank line (spaces/tabs, `\r`-tolerant), NOT `trim()` — see // `isBlankLine`. An NBSP-only line is CONTENT, and skipping it here as // "blank" is the same one-character reopening documented there. if (isBlankLine(line.content)) continue const indent = leadingIndent(line.content) const top = listContentCols.length ? listContentCols[listContentCols.length - 1] : 0 if (indent >= top + 4) { ranges.push([line.contentStart, line.contentStart + line.content.length]) continue } while (listContentCols.length && indent < listContentCols[listContentCols.length - 1]) listContentCols.pop() const marker = LIST_MARKER_RE.exec(line.content) if (marker) { const markerEndCol = visualColumn(line.content, marker[0].length - marker[2].length) const contentCol = visualColumn(line.content, marker[0].length) listContentCols.push(contentCol - markerEndCol > 4 ? markerEndCol + 1 : contentCol) } } return blankRanges(masked, ranges) } /** Re-derive scannable lines from the CURRENT mask, preserving each line's * original `start` / `contentStart` (every pass is length-preserving, so the * offsets transfer verbatim). See `blankIndentedCode`'s SCAN-SOURCE * INVERSION. */ function remapToMask(masked: string, lines: MaskLine[]): MaskLine[] { return lines.map((line) => ({ ...line, content: masked.slice(line.contentStart, line.contentStart + line.content.length), })) } /** * Blank HTML COMMENTS. `` is not a closer — parse5 consumes * it as comment data — yet it satisfied the raw substring search. The * unterminated form is blanked to EOF, matching what the tokenizer does with a * comment that never ends (and, again, failing closed). * * SCAN-SOURCE EXCEPTION: this is the ONE pass fed the already-masked copy * rather than the unmasked one. The shared contract exists because * the inline-code pass chews backticks off an unclosed fence opener and would * blind a fence scan — but for comments the reasoning INVERTS: a `|` is dropped entirely. LAST, as everywhere * else, because it scans the masked copy. (The pipeline's own comment pass * still runs last over the REAL mask; this is a separate string.) * * OVER-BLANKING HERE IS FREE: fewer references means more definitions look * unreferenced, which blanks MORE of the haystack — the fail-CLOSED direction. * That is why this copy may apply passes out of the pipeline's order and may * use a block model that only approximates remark's. */ function footnoteReferenceMask(masked: string, folded: string): string { let ref = blankInlineLinkPayloads(masked, folded) ref = blankBracketLabels(ref, folded, { footnoteLabels: false }) ref = blankRanges( ref, mergeRanges(computeHtmlBlockRanges(folded).map(({ start, end }) => [start, end])), ) ref = blankComments(ref, ref) ref = ref.replace(AUTOLINK_LIKE_RE, (m) => ' '.repeat(m.length)) return blankTagAttributes(ref) } /** AUTOLINKS, both spellings, DELIBERATELY over-wide (this regex is only ever * applied to the reference-counting copy, where over-blanking is free): a * CommonMark `` autolink and a GFM LITERAL autolink both become an * `href`, so `` and `https://e.example/x[^f]y` are * phantom references — each reproduced a live iframe. It stops at whitespace, * so an ordinary `see https://e.example [^f]` keeps its REAL reference. Email * autolinks are deliberately absent: a `[` voids the email shape, so `[^f]` * inside one IS a real reference (verified — remark emits the footnote). */ const AUTOLINK_LIKE_RE = /<[a-z][a-z0-9+.-]{1,31}:[^\s<>]*>|(?:https?:\/\/|www\.)[^\s<]*/gi /** Blank the ATTRIBUTE RUN of every tag-like span, length-preserving. Blanking * the WHOLE tag would blank real `` closers too, so only the run between * the tag name and the `>` is cleared. Shared by `buildCloserHaystack`'s final * step and by `footnoteReferenceMask`, where an INLINE tag's attribute is one * more region remark never resolves a `[^f]` in (`` * reproduced a live iframe). */ function blankTagAttributes(masked: string): string { return masked.replace( TAG_LIKE_REGEX, (_m, slash: string, tag: string, rest: string, selfClose: string) => `<${slash}${tag}${' '.repeat(rest.length)}${selfClose}>`, ) } /** Leading indent of `c` in COLUMNS (tabs advance to the next multiple of 4) * measured PAST the blockquote run, which is the column GFM measures a * footnote's continuation blocks at. */ function footnoteIndentCols(c: string): number { FOOTNOTE_QUOTE_PREFIX_RE.lastIndex = 0 let q = FOOTNOTE_QUOTE_PREFIX_RE.exec(c)![0].length let col = 0 for (; q < c.length && isSpaceTab(c[q]); q++) col = c[q] === '\t' ? col + 4 - (col % 4) : col + 1 return col } /** * Blank the PARENTHESISED PAYLOAD of an INLINE link or image (round 19 — * SECURITY, a whole shelter class the table did not name). * * remark consumes an inline link's DESTINATION and TITLE exactly as it consumes * a reference definition's: both become href/title ATTRIBUTES on the emitted * node and never reach the document as HTML. So a `` written in * either one is not a closer — but `blankLinkDefinitions` only covers the * DEFINITION spelling, and no pass covered the inline one. All eight spellings * reproduced live (`escapeUnknownHtmlTags` byte-identical, one live * `") [a](/x '') [a](/x ()) * ![a](/x "") [a]() > [a](/x "") * - [a](/x "") See [a](/x "") for more. * * …and the escalation: an `](/x)` yielded a LIVE iframe retaining `src`, `width` * and `height`. * * WHAT IS CLAIMED, AND WHAT IS DELIBERATELY NOT: * * - a `[…]` whose `[` is immediately preceded by `!` — an image's alt is a * STRING attribute in every image spelling (inline, reference, collapsed, * shortcut), so the bracket text never reaches the document as HTML; * - the SECOND `[…]` of a `][` adjacency — a FULL reference's label, which * remark resolves to a definition and never renders; * - the FIRST `[…]` of a `][]` adjacency — a COLLAPSED reference, whose * bracket text IS the identifier. (remark also inline-parses it for display, * so unlike an alt this one is not purely an attribute; blanking it is the * fail-CLOSED direction and the reviewer-confirmed shelter, not a claim that * the text is unrendered.) * - a footnote LABEL, `[^…]`, in BOTH the reference and the definition — * remark percent-encodes it into `href`/`id`. Only the LABEL: round 19 was * right that a footnote definition's BODY is BLOCK-parsed and may hold real * HTML, which is why `blankLinkDefinitions` refuses the whole line. * - NOT the `[…]` of an inline `[text](…)` link, and NOT a bare SHORTCUT * reference `[label]`: in both, remark emits the bracket text as inline * HTML, so a `` there IS a real closer and must stay visible. * (Verified: with a live opener above it, that closer pairs.) * * The reference spellings are NOT reachable from the `](`-anchored scan in * `blankInlineLinkPayloads` — there is no `](` in `![x][r]` or `[a][r]` at all — * so this pass carries its own anchors. * * CONTAINER-AGNOSTIC BY CONSTRUCTION, like `blankInlineLinkPayloads`, * `blankLinkDefinitions` and `blankComments`: a single left-to-right bracket * walk with no column or prefix anchoring, so a blockquote run or list marker is * ordinary text ahead of it and ONE top-level call covers every nesting. * * FAIL DIRECTION: brackets that do not resolve to a link/image at all (ordinary * prose `see [1][2]`) are still blanked. Every such over-detection only hides * closers, i.e. escapes MORE openers. A backslash escape is consumed as a pair, * so `\[` does not open a group; `\!` still leaves the following `[` looking * image-like, which over-blanks in the same safe direction. */ function blankBracketLabels( masked: string, source: string, { footnoteLabels = true }: { footnoteLabels?: boolean } = {}, ): string { if (source.indexOf('[') === -1) return masked const ranges: Array<[number, number]> = [] /** Open `[` positions, innermost last. */ const open: number[] = [] /** * The most recently CLOSED group AT EACH NESTING DEPTH, for the `][` / `][]` * adjacencies. Round 23: this used to be a SINGLE `prev`, which any NESTED * group clobbered — so in `[txt][[^f]]` the outer second group (a full * reference's LABEL) was compared against the INNER `[^f]` instead of against * `[txt]`, the adjacency failed and the label was never blanked. That spelling * was a live fail-open through `blankUnreferencedFootnotes`' phantom count. * Depth-keyed, siblings are compared with siblings. */ const prevByDepth: Array<{ open: number; close: number } | null> = [] for (let i = 0; i < source.length; i++) { const ch = source[i] if (ch === '\\') { i++ continue } if (ch === '[') { open.push(i) // Whatever closed at this depth before belongs OUTSIDE the group just // opened, so it cannot be adjacent to anything inside it. prevByDepth[open.length] = null continue } if (ch !== ']') continue const from = open.pop() if (from === undefined) { prevByDepth[0] = null continue } const prev = prevByDepth[open.length] ?? null // IMAGE alt — `![…]`, every image spelling. if (from > 0 && source[from - 1] === '!') ranges.push([from + 1, i]) // FOOTNOTE label — `[^…]`, reference and definition alike. Suppressed for // the reference-counting copy only (`footnoteReferenceMask`), where blanking // the labels would erase the very references being counted. if (footnoteLabels && source[from + 1] === '^') ranges.push([from + 2, i]) // REFERENCE label — the second group of `[…][…]`, or, when that group is // EMPTY (`[…][]`), the first group, which is then the identifier. if (prev !== null && prev.close === from - 1) { if (i === from + 1) ranges.push([prev.open + 1, prev.close]) else ranges.push([from + 1, i]) } prevByDepth[open.length] = { open: from, close: i } } return blankRanges(masked, mergeRanges(ranges)) } /** `> ` / `>` container prefixes, including nested ones (`> > `). */ const BLOCKQUOTE_PREFIX_RE = /^(?: {0,3}>[ \t]?)+/ /** * EXACT NO-OP GUARDS for the container cross-calls (round 19 — performance). * * `blankQuotedCode` and `blankListItemCode` each call the other and * `blankListItemCode` now calls itself, and each of those calls walks the run * and folds the WHOLE document through `blankRanges` per flush. Widening the * list gate to `top >= 1` made every ordinary `- ` item open a run, so a * list-dense document paid that constant on every line (measured 3.1x at * 989 KB before these guards). * * Both guards are EXACT, not heuristic: `blankQuotedCode` only ever opens a run * on a line `BLOCKQUOTE_PREFIX_RE` matches and `blankListItemCode` only ever * pushes a column for a line `LIST_MARKER_RE` matches, so a run containing no * such line produces no runs at all and returns `masked` byte-identical. Skipping * a provable identity cannot change coverage — do NOT weaken either predicate * into an approximation of "probably nothing here"; that is how the eight * fail-open instances above were born. */ const hasListMarker = (line: MaskLine): boolean => LIST_MARKER_RE.test(line.content) const hasQuotePrefix = (line: MaskLine): boolean => BLOCKQUOTE_PREFIX_RE.test(line.content) /** * WINDOWED RUNS (round 19 — performance, and the same lesson as `blankRanges` * one level up). * * `blankRanges` is O(document): it rebuilds the whole string. The container * passes used to hand it the WHOLE document once per nested pass PER RUN, so a * document that is one long sequence of list/quote runs paid O(runs × document) * — a second quadratic, sitting directly above the one round 18 removed. * Widening the list gate to `top >= 1` tripled the run count and made it * visible: a 989 KB all-fenced-in-list-items document went 320 ms → 1006 ms. * * Runs are DISJOINT and ASCENDING, and every range any nested pass produces * lies inside its own run's span (fence ranges start at `line.start`, every * other pass at `line.contentStart`). So a run can be masked in ISOLATION, on a * window sliced out of the caller's baseline with all offsets rebased, and the * windows spliced back in ONE fold at the end. Same output, one document * rebuild per pass instead of one per run. * * Do not reintroduce a per-run fold; a container pass that reassigns the whole * `masked` inside its `flush` is the regression. */ function rebaseRun(run: MaskLine[], from: number): MaskLine[] { return run.map((line) => ({ start: line.start - from, contentStart: line.contentStart - from, content: line.content, })) } function spliceWindows(masked: string, edits: Array<[number, number, string]>): string { if (edits.length === 0) return masked const parts: string[] = [] let cursor = 0 for (const [from, to, text] of edits) { if (from > cursor) parts.push(masked.slice(cursor, from)) parts.push(text) cursor = to } parts.push(masked.slice(cursor)) return parts.join('') } /** The window a run occupies: from the first line's START (fence ranges are * anchored there, before any container prefix) to the last line's END. */ function runWindow(run: MaskLine[]): [number, number] { const last = run[run.length - 1] return [run[0].start, last.contentStart + last.content.length] } /** * CONTAINER NESTING DEPTH GUARD — and it FAILS CLOSED (round 19). * * The round-17 termination note claimed the mutual recursion was "verified * empirically on `> - ` alternation nested 1/2/5/20/100/500/2000/8000 levels * deep … no throw, ≤4 ms, and the observed recursion depth CAPPED AT 4". THAT * CLAIM IS FALSE and was false when written: HEAD throws `RangeError: Maximum * call stack size exceeded` on that exact input from depth ~2000 up. The * recursion terminates (the measure argument is sound) but its DEPTH is bounded * only by input length, and V8's stack is not. A `RangeError` out of the * sanitizer is a rendering crash, i.e. a denial of service on a 24 KB message. * * Round 19's list self-recursion widened the trigger (a single line of `- ` * markers overflows from depth ~4000, where HEAD survived because * `LIST_MARKER_RE` matches only the first marker), so the guard lands here. * * THE GUARD IS NOT A COVERAGE HOLE. At the limit the run is not skipped — it is * BLANKED WHOLE, which is the strictly more aggressive answer and exactly the * fail direction this module rounds towards everywhere else. A markdown document * nested 64 containers deep is a code sample rendered as escaped text, not a * shelter. Do NOT convert this into a `return` / `continue`: skipping is the * fail-OPEN direction and would be a new instance of the class. */ const CONTAINER_NEST_LIMIT = 64 /** * Blank code regions inside BLOCKQUOTES. * * `FENCE_RE` matches at column 0..3, so a fence inside a quote (```` > ```html ````) * is invisible to the top-level tracker — and a blockquoted code sample is an * utterly ordinary chat answer ("here's the markup:" followed by a quoted * fence). The closer inside it satisfied `hasLaterCloser` and the prose opener * above stayed live. * * CHOSEN APPROACH: strip the quote prefix off each run of quoted lines and run * a NESTED tracker (plus the indented-code rule) over the stripped content, * blanking only the code regions found. The blunter alternative — blank every * `^ {0,3}>` line — is also sound (it only over-blanks) but it would escape a * legitimately PAIRED `` written inside a blockquote, * turning quoted HTML into visible `<…>` source. The nested scan costs * one extra line walk and keeps that shape rendering. * * --------------------------------------------------------------------------- * MUTUAL RECURSION — TERMINATION (round 17) * --------------------------------------------------------------------------- * `blankQuotedCode` and `blankListItemCode` now call EACH OTHER (the missing * quote→list direction was the seventh instance of the fail-open class). The * recursion terminates on the measure `M(run) = Σ line.content.length`: * * - `blankQuotedCode` only puts a line in a run when `BLOCKQUOTE_PREFIX_RE` * matches, and that pattern is `(?: {0,3}>[ \t]?)+` — at least one `>`, so * the stripped content is at least 1 char SHORTER. Blank lines never match * (they carry no `>`), so EVERY line in a quoted run strictly shortens. * - `blankListItemCode` only puts a line in a run when the content column * `top >= 1` (round 19 — was `>= 4`), and `charIndexAtColumn(content, top)` * with `top >= 1` returns an index `>= 1` (it can only return 0 when the * requested column is 0), so that line strictly shortens too. ROUND-19 * RE-VERIFICATION: the same bound covers the new SELF-recursion — the run it * hands itself is cut at the same `top >= 1`, so `M` strictly decreases * across that call exactly as across the `blankQuotedCode` one. ROUND-18 * RE-VERIFICATION: this also * covers the MARKER LINE, whose cut lands at `marker[0].length` (or * `markerEnd + 1` under the clamp) — both `>= 2` for every marker spelling, * so the bound `cut >= 1` is unchanged and the measure still strictly * decreases. The reorder moved WHICH lines join a run, not the shortening * property that makes the recursion finite. It also carries blank separators into an * ALREADY-OPEN run as `content: ''` (length 0 ≤ original), and a run is only * ever opened by a non-blank, strictly-shortened line. * * So each nested call is handed a run whose measure is strictly smaller than * the caller's, `M` is a non-negative integer, and the chain is finite. * * --------------------------------------------------------------------------- * FINITE IS NOT THE SAME AS SHALLOW (round 19 — the third false claim) * --------------------------------------------------------------------------- * Round 17 concluded here: "It is bounded by input length, so no depth guard is * added — there is no non-shortening case to guard against, and a speculative * bound would be a second, untested policy. Verified empirically on `> - ` * alternation nested 1/2/5/20/100/500/2000/8000 levels deep (240 KB source): * length invariant held, no throw, ≤4 ms, and the observed recursion depth * CAPPED AT 4 regardless of nesting." * * THE EMPIRICAL PART OF THAT IS FALSE, and was false when written. Re-run on the * described input, HEAD raises `RangeError: Maximum call stack size exceeded` * from depth ~2000 up — a 24 KB message crashes the renderer. The depth cap of 4 * held only for the shapes round 17 happened to try; `BLOCKQUOTE_PREFIX_RE` * consumes a `> > >` nest in one match, but an ALTERNATING `> - > - …` line * gives each pass exactly one level to strip and the chain is as deep as the * line is long. Round 19's list self-recursion widened it further (a plain `- ` * run overflows from ~4000, where HEAD survived only because `LIST_MARKER_RE` * matches the first marker alone). * * Termination was never the property at risk — STACK DEPTH was, and "bounded by * input length" is precisely the bound that does not help. `CONTAINER_NEST_LIMIT` * now caps it, blanking an over-deep run WHOLE rather than recursing, which is * fail-CLOSED and therefore not a coverage hole. Pinned by * `masks arbitrarily deep container nesting without throwing` at depths up to * 40000 (469 KB, 11 ms, closer masked at every depth). */ function blankQuotedCode(masked: string, lines: MaskLine[], depth = 0): string { let run: MaskLine[] = [] // One edit per run, spliced in a SINGLE fold at the end — see `spliceWindows`. const edits: Array<[number, number, string]> = [] const flush = () => { if (run.length === 0) return const [from, to] = runWindow(run) const wl = rebaseRun(run, from) let win = masked.slice(from, to) // Depth limit: blank the run WHOLE rather than recurse — see // `CONTAINER_NEST_LIMIT`. Fail-closed, never a skip. if (depth >= CONTAINER_NEST_LIMIT) { edits.push([from, to, blankRanges(win, [[0, win.length]])]) run = [] return } // The nested FENCE scan needs the unmasked `run` content (the inline-code // pass would have blinded it), but the nested INDENTED scan needs the CURRENT // mask — see `blankIndentedCode`'s SCAN-SOURCE INVERSION. const afterFences = blankFencedRegions(win, wl) win = blankIndentedCode(afterFences, remapToMask(afterFences, wl)) win = blankLinkDefinitions(win, wl) // …and the LIST-container pass, mirroring the call `blankListItemCode` // already makes in the other direction. Without it a fenced sample inside a // LIST ITEM inside a QUOTE was seen by NO pass: `FENCE_RE` caps fence indent // at 3 ABSOLUTE columns, so at a quote-relative content column >= 4 // (`> 1. ` / `> - ` / `> -\t`) the fence is invisible to the nested // tracker, and `blankIndentedCode`'s list-aware threshold (`contentCol + 4`) // starts at 8 and never reaches it either. Reproduced live for textarea and // iframe, at both list spellings, the tab spelling and depth-2 quotes; // `escapeUnknownHtmlTags` returned the input BYTE-IDENTICAL. if (wl.some(hasListMarker)) win = blankListItemCode(win, wl, depth + 1) edits.push([from, to, win]) run = [] } for (const line of lines) { const prefix = BLOCKQUOTE_PREFIX_RE.exec(line.content) if (!prefix) { flush() continue } run.push({ start: line.start, contentStart: line.contentStart + prefix[0].length, content: line.content.slice(prefix[0].length), }) } flush() return spliceWindows(masked, edits) } /** * Blank code regions nested inside LIST ITEMS, the list-container analogue of * `blankQuotedCode` (round 14). * * `FENCE_RE` caps fence indent at 3 columns ABSOLUTE, but CommonMark measures a * fence's indent from the enclosing item's CONTENT COLUMN. Every list wrapper * the corpus swept had a content column of 2 or 3 (`- `, `1. `), so the cap * happened to cover them and the gap was invisible; at content column 4 or more * — `-` + three spaces, `1.` + three spaces, or the TAB spelling `-\t`, all * ordinary ways to write a list — a fenced code sample inside the item is seen * by NO pass. Its `` then satisfied `hasLaterCloser`, and a prose * `"`) was cut to the OUTER item's column only — still short of // its own. Reproduced live for both shapes at zero quote depth // (`escapeUnknownHtmlTags` byte-identical, one live `` — live in the haystack. Reproduced at ZERO // nesting depth (`escapeUnknownHtmlTags` returned the input BYTE-IDENTICAL, // one live editable `` sitting * inside a code fence, an inline-code span, or another tag's attribute string * satisfied "is closed later", the prose opener was left LIVE, and parse5's * RAWTEXT span swallowed the rest of the message anyway — the whole fix was * one code sample away from being bypassed, which is exactly what an LLM * answer about HTML looks like. * * Masking (rather than deleting) keeps every index identical to the original * string, so the caller's offset arithmetic is unchanged. THE LENGTH * INVARIANT IS LOAD-BEARING — see `foldAsciiCase`. * * CARVE DECISION (deliberate, do not "unify"): these tracker-derived regions * are NOT fed to the escaping carve, even though that would stop an authored * EOF-terminated fence body from rendering as literal `<their>`. * * The genuine asymmetry is the EOF-TERMINATED fence, and only that one. The * tracker protects an unclosed opener all the way to end of input, so a single * stray ``` line — mid-stream, or inside an open raw-HTML block where a ``` * line is content rather than a fence — would carve the ENTIRE remainder of the * document out of the escaping pass. `PROTECTED_SPAN_RE` protects nothing at * all there (it only recognizes a fence CLOSED by a same-marker run), so its * failure mode is bounded: a code sample renders as escaped text. In the carve * an over-detected region is a region that is NOT escaped — a fail-OPEN, i.e. * exactly the swallow this module exists to prevent — so the materially larger * fail-open surface decides it. * * SHARED over-detection (e.g. a ``` line inside an HTML block — `
`, * `
`, `
` — where CommonMark says the line is HTML content, not a * fence) was previously dismissed here as "not an argument either way". THAT * WAS WRONG: it is precisely the residual fail-open. The intersection guard * below only reconciles DISAGREEMENT, so when BOTH engines open the same bogus * fence the guard is a no-op and a live `` in a destination or title is not a closer. masked = blankLinkDefinitions(masked, lines) // …and the GFM FOOTNOTE definitions that pass deliberately refuses, but only // the UNREFERENCED ones: remark-gfm drops those whole, so their bodies are // not document text either. It reads the CURRENT mask for DEFINITIONS and a // separate, more-blanked copy for REFERENCES (`footnoteReferenceMask`), both // behind a `[^` guard. // // ITS SLOT IS CONSTRAINED ON BOTH SIDES, and neither bound is cosmetic: // · it may not run EARLIER than the code passes, whose output is the // definition source; // · it may not simply be MOVED after `blankInlineLinkPayloads` / // `blankBracketLabels` to pick up the phantom-reference fix, because // `blankBracketLabels` blanks footnote labels "reference AND definition // alike" — after it, EVERY reference is gone and every referenced // definition would be over-blanked into escaped source. Hence the // separate scratch copy instead of a reorder. masked = blankUnreferencedFootnotes(masked, lines, folded) // …and the INLINE link/image spelling of the same shelter, which remark // likewise turns into href/title attributes. Container-agnostic, so like // the definition pass it needs exactly one top-level call. masked = blankInlineLinkPayloads(masked, folded) // …and the BRACKET half of that same class — an image's alt, a reference // label, a footnote label — which remark consumes into an attribute or an // identifier. Container-agnostic, so likewise exactly one top-level call. masked = blankBracketLabels(masked, folded) masked = blankQuotedCode(masked, lines) // …and the LIST-container analogue, for items whose content column exceeds // the 3-column fence-indent cap. Monotonic, so its position among the // block passes is not load-bearing. masked = blankListItemCode(masked, lines) // Comments scan the MASKED copy, not `folded` — see `blankComments`. Must // stay LAST: it relies on every code region already being blanked. masked = blankComments(masked, masked) // 3. Attribute regions. Blanking the WHOLE tag would blank real `` // closers too (and break the closed-form fixtures), so only the // attribute run between the tag name and the `>` is cleared. masked = blankTagAttributes(masked) return masked } /** Exported for the length-preservation invariant test only. */ export const __buildCloserHaystackForTest = buildCloserHaystack /** * True when a well-formed `` (optional trailing whitespace) occurs at * or after `from` in the MASKED lowercased source (see `buildCloserHaystack`). * Substring search rather than a per-tag `RegExp` — the tag comes from * `RAWTEXT_TAGS`, but building regexes from tag names in a hot path invites * an injection footgun on the next edit. */ function hasLaterCloser(lowerSource: string, tag: string, from: number): boolean { const needle = `` or `` closes it; `` is a different tag. if (/^\s*>/.test(lowerSource.slice(at + needle.length, at + needle.length + 64))) return true cursor = at + needle.length } } /** * True when the mask considers `[from, to)` entirely code — every character * blanked to a space (newlines are never blanked, so they count as blank). * Both strings are the same length by construction (see `foldAsciiCase`). */ function isMaskedBlank(lowerSource: string, from: number, to: number): boolean { for (let i = from; i < to; i++) { const c = lowerSource[i] if (c !== ' ' && c !== '\n') return false } return true } /** * True when `span` contains a RAWTEXT opener with no matching closer INSIDE * the span — the self-containment test the carve applies before pushing a * protected span through verbatim. See the CARVE BALANCE GUARD in * `escapeUnknownHtmlTags`. * * A closer with no opener before it is harmless (it cannot start a RAWTEXT * span), so the counter floors at zero rather than going negative. * * SELF-CLOSING IS AN OPENER (round 11). HTML ignores the self-closing flag on * non-void, non-foreign elements, so parse5 tokenizes `` into a live closer — the * fail-OPEN direction. Same line-state concept, opposite fail directions, so * they stay two walks. */ interface HtmlBlockRange { start: number end: number } /** CommonMark start-condition 6 tag list (verbatim from the spec). */ const HTML_BLOCK_TYPE_6_TAGS = new Set([ 'address', 'article', 'aside', 'base', 'basefont', 'blockquote', 'body', 'caption', 'center', 'col', 'colgroup', 'dd', 'details', 'dialog', 'dir', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hr', 'html', 'iframe', 'legend', 'li', 'link', 'main', 'menu', 'menuitem', 'nav', 'noframes', 'ol', 'optgroup', 'option', 'p', 'param', 'search', 'section', 'summary', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'title', 'tr', 'track', 'ul', ]) const HTML_BLOCK_START_1 = /^ {0,3}<(?:script|pre|style|textarea)(?:[ \t>]|\r?$)/i const HTML_BLOCK_END_1 = /<\/(?:script|pre|style|textarea)>/i const HTML_BLOCK_START_2 = /^ {0,3}') !== -1 case 3: return line.indexOf('?>') !== -1 case 4: return line.indexOf('>') !== -1 default: return line.indexOf(']]>') !== -1 } } function htmlBlockStartKind(line: string, inParagraph: boolean): number | null { if (line.indexOf('<') === -1) return null if (HTML_BLOCK_START_1.test(line)) return 1 if (HTML_BLOCK_START_2.test(line)) return 2 if (HTML_BLOCK_START_3.test(line)) return 3 if (HTML_BLOCK_START_5.test(line)) return 5 if (HTML_BLOCK_START_4.test(line)) return 4 const six = HTML_BLOCK_START_6.exec(line) if (six && HTML_BLOCK_TYPE_6_TAGS.has(six[2].toLowerCase())) return 6 // Condition 7 is the ONLY one that cannot interrupt a paragraph. if (!inParagraph && HTML_BLOCK_START_7.test(line)) return 7 return null } function computeHtmlBlockRanges(text: string): HtmlBlockRange[] { if (text.indexOf('<') === -1) return [] const ranges: HtmlBlockRange[] = [] const fences = createFenceTracker() let kind: number | null = null let start = 0 let lastEnd = 0 let inParagraph = false let offset = 0 // Container state for the normalization above. `listContentCol` is the width // of the innermost list marker seen; `openPrefixLen` is how many prefix // COLUMNS the CURRENTLY open block consumed on its OPENING line, which decides // whose notion of "blank line" terminates a type-6/7 block (see below). let listContentCol = 0 let openPrefixLen = 0 for (const line of text.split('\n')) { const lineStart = offset const lineEnd = offset + line.length offset = lineEnd + 1 // Columns, not characters (see `expandTabs`). Every comparison against "the // line as written" below must use THIS, or a tab-prefixed container reads as // a container that opened nothing. const expanded = expandTabs(line) const norm = stripContainerPrefix(expanded, listContentCol) if (norm.openedListCol >= 0) listContentCol = norm.openedListCol else if (!isBlankLine(norm.text) && norm.text === expanded) listContentCol = 0 const normBlank = isBlankLine(norm.text) // A type-6/7 block ends at the first blank line. At top level the raw line // decides — a bare `-` or `>` line inside a top-level HTML block is CONTENT, // and treating it as blank would END the range early (the one // under-detecting direction). // // Inside a container the container's own filler (`>`, `> >`, the item's // indent) IS that blank line, so the block must end there — but ONLY the // filler of the container the block actually opened in. Round 13 used the // fully-stripped `norm.text` here, and `stripContainerPrefix` strips ANY // container markers, not the ones that were open. So a line holding a // DIFFERENT container's opener (` >` under a `-
`, `> -` under a // `>
`) normalized to empty, read as blank, and ended the range early — // re-opening the very shelter the range exists to expose (verified: 1 live // `` written * inside an over-long inline span and re-opened the RAWTEXT swallow this module * exists to close. A residual note must state the fail direction PER CONSUMER; * a single "safe direction" verdict for a value read by passes that round * opposite ways is not a finding, it is an averaging error. * * RESOLVED for the haystack: the mask no longer uses a capped regex at all * (`findInlineCodeRanges` — linear, uncapped), so an over-long inline span is * blanked like any other and the haystack's fail-OPEN row above no longer has * an over-cap case. Pinned by the `spanLength` axis of the swallow sweep * (cap−k and cap+k for every shelter spelling). * RESIDUAL, deliberately kept: the CARVE keeps its cap, and so does this pass's * view of an over-cap span in a document the mask ALSO declines to blank — both * of those round CLOSED per the table, i.e. they cost at worst a visible `<`. */ const LEFTOVER_TAG_START_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9-]{0,63})(?=[\s>])/g function escapeLeftoverTagStarts(gap: string, lowerSource: string, gapOffset: number): string { if (gap.indexOf('<') === -1) return gap LEFTOVER_TAG_START_RE.lastIndex = 0 return gap.replace( LEFTOVER_TAG_START_RE, (m: string, slash: string, tag: string, at: number) => isMaskedBlank(lowerSource, gapOffset + at, gapOffset + at + m.length) ? m : `<${slash}${tag}`, ) } export function escapeUnknownHtmlTags( text: string, allowedTags: Set = SAFE_HTML_TAGS, ): string { if (!text || text.indexOf('<') === -1) return text // Masked, length-preserving, lowercased whole-document copy for the RAWTEXT // closer lookup — the closer may live in a later segment than the opener, // so the search must span the ENTIRE source, not the segment being escaped, // and must ignore closers that are only code samples / attribute text. const lowerSource = buildCloserHaystack(text) // HTML-block ranges for the CARVE BALANCE GUARD below. Computed LAZILY: only // a protected span that actually carries an unbalanced RAWTEXT opener needs // them, which no ordinary message has. let htmlBlocks: HtmlBlockRange[] | null = null const spanInsideHtmlBlock = (from: number, to: number): boolean => { htmlBlocks ??= computeHtmlBlockRanges(text) return htmlBlocks.some((r) => r.start < to && r.end > from) } // Carve out fenced code blocks AND inline-backtick spans so `` // examples inside code are preserved verbatim. const parts: string[] = [] let cursor = 0 PROTECTED_SPAN_RE.lastIndex = 0 let span: RegExpExecArray | null while ((span = PROTECTED_SPAN_RE.exec(text)) !== null) { if (span.index > cursor) { parts.push( escapeOutsideFences(text.slice(cursor, span.index), allowedTags, lowerSource, cursor), ) } // INTERSECTION GUARD (soundness, not an instance patch). A protected span // is pushed through VERBATIM, so a live RAWTEXT opener inside one never // reaches `escapeOutsideFences` at all and the mask's correctness is // bypassed. Carve and mask run different engines, so the carve CAN protect // a region the mask correctly blanked — `PROTECTED_SPAN_RE`'s closer // alternative accepts an info string, ends its span early, desyncs, and can // open a new span from a line CommonMark treats as ordinary text. Protect // only what BOTH engines call code: if the mask left anything non-blank // over this exact range, escape the span instead. // // CARVE BALANCE GUARD (the residual fail-open the intersection alone does // NOT close). The intersection only reconciles DISAGREEMENT; when BOTH // engines over-detect the SAME region it is a no-op. CommonMark says an // HTML block (type 1 `
`/`
`, type 6 `
`) runs to its // terminator, so a ``` line inside one is HTML CONTENT and not a fence — // and NEITHER `createFenceTracker` nor `PROTECTED_SPAN_RE` models HTML // blocks, so both open a bogus fence at the same line and shelter whatever // follows. So the range check is paired with a self-containment check: a // protected span is by definition a complete code region, therefore any // RAWTEXT opener inside it must be BALANCED within it. An unbalanced one // means the span is not really code — route it through the escaper. This // is engine-independent (it needs no HTML-block tracking). // // GATED ON HTML-BLOCK MEMBERSHIP, NOT ON THE SPAN'S FLAVOR (round 12). The // property that makes a protected span "not really code" is that it sits // inside an HTML BLOCK — where CommonMark says every line is HTML content. // Two earlier rounds gated on flavor instead and traded one hole for the // other: // - round 9 applied the guard to BOTH alternatives. That over-applied to // inline code, where entity references are NOT recognized, so an escaped // `<title>` was shown to the reader LITERALLY — and naming a tag in // inline code (`` `` ``) is the single most common way a docs // answer mentions one. // - round 11 scoped it to FENCES, justified by "an inline span cannot // shelter a live opener: remark emits it as an `inlineCode` TEXT node, so // parse5 never tokenizes its content". That invariant is asserted in a // comment and holds only OUTSIDE an HTML block. Inside one, remark emits // raw HTML, backticks are not code, and `` `<textarea>` `` on its own // line inside `<div>` / `<pre>` / `<details>` / `<span>` sheltered a live // opener that swallowed the rest of the message. // Membership covers BOTH spellings with one property, and leaves ordinary // prose inline code untouched. `isFence` is kept as an independent // sufficient condition: a fenced span that the mask blanked but the HTML // walk does not consider part of a block (the two engines can still desync) // must stay under the round-9 guarantee. // Group 1 is the fence marker, group 2 the inline backtick run. // // PROPERTY GUARANTEED: no protected span that is either a FENCE or inside an // HTML BLOCK can carry an unbalanced RAWTEXT opener into the output // verbatim. That is strictly weaker than "the carve never over-detects" — an // over-detected span with no RAWTEXT opener in it is still pushed verbatim, // which stays cosmetic-only. const isFence = span[1] !== undefined const spanEnd = span.index + span[0].length parts.push( isMaskedBlank(lowerSource, span.index, spanEnd) && !( hasUnbalancedRawtextOpener(span[0]) && (isFence || spanInsideHtmlBlock(span.index, spanEnd)) ) ? span[0] : escapeOutsideFences(span[0], allowedTags, lowerSource, span.index), ) cursor = span.index + span[0].length } if (cursor < text.length) { parts.push(escapeOutsideFences(text.slice(cursor), allowedTags, lowerSource, cursor)) } return parts.join('') } /** * Walks `segment` tag by tag rather than using `String.replace`, so the regions * the main regex did NOT consume are addressable: each gap is handed to * `escapeLeftoverTagStarts` (see it for the over-long-attribute fail-open it * closes), while every matched tag keeps its ORIGINAL index. Preserving that * index matters — `hasLaterCloser` indexes `lowerSource`, which is built from * the untouched text, so any offset drift reopens the round-5 desync class. */ function escapeOutsideFences( segment: string, allowedTags: Set<string>, lowerSource: string, segmentOffset: number, ): string { const out: string[] = [] let cursor = 0 TAG_LIKE_REGEX.lastIndex = 0 let m: RegExpExecArray | null while ((m = TAG_LIKE_REGEX.exec(segment)) !== null) { const [match, slash, tag, rest, selfClose] = m if (m.index > cursor) out.push( escapeLeftoverTagStarts( segment.slice(cursor, m.index), lowerSource, segmentOffset + cursor, ), ) const lower = tag.toLowerCase() const escaped = `<${slash}${tag}${rest}${selfClose}>` if (!allowedTags.has(lower)) { out.push(escaped) } else if (slash === '' && RAWTEXT_TAGS.has(lower)) { // Allowlisted — but an UNCLOSED RAWTEXT opener would swallow the rest of // the document during tokenization, before any allowlist applies. // // The SELF-CLOSED spelling counts as an opener (round 11): HTML ignores // the self-closing flag on non-void, non-foreign elements, so parse5 // tokenizes `<textarea/>` as a start tag and enters RAWTEXT identically. // Excluding it here left the entire defense — prose openers, HTML-block // shelters, all of it — bypassable by one extra slash. `RAWTEXT_TAGS` has // no void members, so nothing legitimate self-closes. // // COSMETIC COST (accepted, fail-closed): self-closing IS honored in // foreign content, so an EMPTY `<title/>` inside `<svg>` now escapes // rather than rendering. It carries no accessible name either way, and // the real a11y form `<title>Chart` is unaffected. SECOND COST // added by the same round: a protected span the balance guard deems // not-really-code is routed through this function whole, so bare `