fix(website): clamp pathological markdown nesting depth before parse - #8916
fix(website): clamp pathological markdown nesting depth before parse#8916javenciu wants to merge 2 commits into
Conversation
UX Review (Fable 5, fork) — ✅ PASSUX-level review of UX-Verdict: PASS Pure crash-guard with no new controls, labels, or strings — the only user-visible change turns a whole-transcript crash into a rendered message. The diff adds a pre-parse clamp ( [UX-REVIEWED] 9f68e58 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of I have everything I need. Verified against the base tree: the fix targets the single parse choke point in First-Principles-Verdict: CONCERNS The crash fix is cause-level and earns its place; the exported clamp util ships tunable parameters and constants no caller uses. What this change shipsIntent: stop one pathologically nested chat message from crashing the whole transcript view — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 9f68e58 |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound choke-point clamp, but a second react-markdown parse site (mochi's ChatPanel) renders the same untrusted chat content and stays crashable. Watch
Suggestions
[DESIGN-REVIEWED] 9f68e58 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've independently verified the clamp logic. My analysis confirms:
I could not derive any grounded (a)/(b)/(c) defect at 80+ confidence. No findings. [OPUS-REVIEWED] 9f68e58 |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 1 of 1 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- website/src/components/MarkdownRenderer.tsx:3552 -- Raw HTML nesting bypasses the depth clamp Adjudication (Opus 4.8) — is blocking on each finding proportionate?The renderer wires This is the fenced (unbounded-harm) block; the adjudicable block is empty. The raw-HTML nesting vector is not extreme or self-contradicting — it arrives through the very same input-controlled markdown channel as the [ADJUDICATION] 9f68e58 total=0 uphold=0 downgrade=0 |
0cf09c1 to
9f68e58
Compare
|
Human-writer override request for the source-position-drift flag (F2), per the adjudication result on this PR: The flagged drift (data-sourcepos coordinates vs unclamped content; |
A chat message with thousands of nested blockquote markers (or equivalently deep list indentation) parses into a tree whose depth equals the nesting count. remark/rehype's recursive transforms and MarkdownRenderer's own recursive walkers then exceed the JS call-stack limit and throw RangeError: Maximum call stack size exceeded while rendering one message. Fix at the single input choke point in MarkdownBlock: clampNestingDepth rewrites only lines beyond generous bounds (>100 blockquote markers or >256 indent columns), leaving ordinary content byte-identical, so every downstream walker inherits the guarantee instead of carrying its own cap.
…se, containers and raw HTML
Chat markdown is input-controlled. THREE spellings of the same class parse
into a tree whose depth equals the nesting count, and recursive layers
downstream (remark-rehype's mdast->hast transform, rehype-raw's parse5->hast
transform, this module's own walkers) recurse to that depth and throw
RangeError: Maximum call stack size exceeded while rendering one message:
1. CommonMark container prefixes in any interleaving the spec allows
(">>>>", "> > >" re-opens, "> - > -" quote/list mixes): clamped by
scanning whole CONTAINER units (up to 3 spaces + quote or list marker,
sticky-iterated), not consecutive ">" characters.
2. Progressively indented list items (multi-line, invisible to per-line
marker counting): clamped by MAX_LIST_INDENT_COLS indent truncation.
3. Raw HTML tag runs (<div><div>...): rehype-raw is wired into both rehype
pipelines, so embedded HTML parses (parse5) into HAST the walkers recurse
over BEFORE sanitization: clamped by a conservative model of parse5's
open-element stack that neutralizes opens past MAX_HTML_TAG_DEPTH by
rewriting '<' to '<' (literal in both micromark and parse5 contexts).
The HTML model errs over-count-only by construction: voids never push;
self-closing spelling on non-voids still opens (per parse5, honoring the
slash would under-count); same-name implied-end siblings replace the stack
top (li spam is flat); closes pop only on exact top match (bogus closers
cannot drain the counter); closes inside quoted attribute values or inline
code spans never pop (fake-close vectors). Fence exemption (micromark spec
4.5 semantics, fail-closed on backtick-in-info-string) applies to all
passes. Below every bound, content is byte-identical; clamped lines drift
by one inserted byte (containers) or 3 bytes per neutralized tag (HTML).
Fails-without-fix proven per spelling: 5,000-deep runs of ">"-variants and
of <div> each throw the RangeError at the unfixed tree (the HTML vector
inside hast-util-from-parse5) and render in tens of ms fixed.
Exemption windows are judged by the downstream parser's semantics, in both
directions. A line opening an HTML BLOCK (CommonMark 4.6: type-6 known
block names, condition-7 complete tags alone on a line, <pre>/<script>/
<style>/<textarea>, comments, PIs, declarations, CDATA) swallows every
following line as raw block content until the block's end condition (blank
line for 6/7, textual closers for 1-5) -- a ```-shaped line there is NOT a
fence to micromark, so entering fence state on it would exempt an unclamped
deep run behind a 3-token shield prefix. While the block latch is open,
fence-OPEN recognition is suppressed (backtick and tilde alike), container
clamps do not apply (marker runs there are literal text), the inline
code-span mask is inert (no spans exist in raw content, so a swallowed
backtick line can neither latch nor mask a past-bound open), and the HTML
tag pass keeps applying -- raw block content is exactly what parse5 nests.
Block-name tables are copied from micromark's own
micromark-util-html-tag-name lists. Approximations over-latch only
(cosmetic clamping of would-be fenced content, never an unclamped run).
Fails-without-fix: <div> + fence-line shield prefixes ahead of 5,000-deep
tag runs return unchanged from the clamp and throw the same RangeError in
the rehype-raw pipeline at the unfixed tree; clamped and rendering fixed.
9f68e58 to
2a03bd2
Compare
|
Maintainer re-run request: the CI and Build workflow runs on this PR failed inside the 17:19Z rate-limit window. The |
Problem / Motivation
A chat message containing pathologically nested markdown — for example thousands of leading
>blockquote markers, or equivalently deep nested list indentation — parses into an mdast/hast tree whose depth equals the nesting count.MarkdownRenderer.tsxwalks that tree with plain recursive functions (eight self-recursivewalkhelpers), and remark-rehype's own mdast→hast transform recurses too. Past the engine's call-stack limit this throwsRangeError: Maximum call stack size exceededwhile rendering a single message.Reproduced at
origin/main(971dcce): a message of 50,000 nested blockquote markers crashes<Markdown>withRangeError(React error-boundary trace pointing into the render), and the affected vitest run hangs to timeout.Why it matters
One message — pasted by a user, emitted by an agent, or arriving from any transcript source — takes down the whole chat rendering path. The failure is input-controlled: nothing upstream caps nesting depth, so the renderer is the layer that has to defend itself.
What changed (motivation → approach → change)
The tree's depth is decided before any walker runs — at parse time, from the raw markdown text. Two fix shapes were considered: per-walker depth caps (eight vendored walkers plus the remark/rehype pipeline's own visitors, each needing its own cap and each a future maintenance hazard) versus one input-side clamp at the single choke point where every message enters parsing. The input clamp wins structurally: every downstream walker — present and future — inherits the guarantee.
website/src/utils/clampNestingDepth.ts: rewrites only lines whose leading blockquote run exceedsMAX_BLOCKQUOTE_DEPTH(100) markers or whose list indent exceedsMAX_LIST_INDENT_COLS(256) columns. Ordinary content passes through byte-identical. Single pass, linear in input length. Fenced code blocks are exempt (marker runs inside a fence are literal text), and the standard for "is this line a fence?" is micromark's CommonMark semantics: a backtick fence's info string may not contain a backtick, so such a line is a paragraph to the parser and does not open an exemption window here. Where fence detection is unsure it fails closed (stays clampable) — the worst case is cosmetic clamping of fenced marker art, never an unclamped deep run reaching the parser.MarkdownRenderer.tsx(MarkdownBlock):clean = clampNestingDepth(clean)immediately after the existing stray-tag strip, before any parsing. Runs in both sourcePos modes — a message that crashes the renderer has no coordinates worth preserving.Commit 2 generalizes the clamp from consecutive quote markers to every
input-controlled nesting spelling that reaches a recursive layer. Review and
CI caught gaps in the first commit from three sides:
> > > ...)counted as one marker and sailed past the clamp, and interleaved quote/list
prefixes (
> - > - ...) nested ~two tree levels per four bytes with no longmarker run and no indent growth. The scanner now consumes container units
iteratively (0-3 spaces + quote marker, or 0-3 spaces + list marker) and
bounds the total count per line.
<div><div>...) bypassed the container clamp entirely:rehype-rawis wired into both rehype pipelines, so embedded HTML parses(parse5) into HAST that the recursive walkers consume before sanitization —
the same stack-overflow class through an alternate spelling (flagged by AI
review on the previous head). A third clamp pass now models parse5's
open-element stack conservatively and neutralizes opening tags past
MAX_HTML_TAG_DEPTHby rewriting<to<(literal in both micromarkand parse5 contexts). The model errs over-count-only by construction: voids
never push; self-closing spelling on non-voids still opens (per parse5 —
honoring the slash would under-count); same-name implied-end siblings
replace the stack top (
<li>spam is flat); closes pop only on exact topmatch (bogus closers cannot drain the counter); closes inside quoted
attribute values or inline code spans never pop (fake-close vectors).
Clamping stays minimal-mutation: below every bound content is byte-identical;
a clamped line drifts by one inserted escape byte (containers) or 3 bytes per
neutralized tag (HTML). The fence exemption (micromark CommonMark semantics,
fail-closed on backtick-in-info-string) applies to all passes. The clamp API
is deliberately minimal:
clampNestingDepth(s)with the bounds as moduleconstants — no tunable parameters, since the only production caller uses the
defaults and unused tunables are a maintenance liability.
The nested-list D-pin fixture shrinks from 1,500 to 600 levels: the fixture
is quadratic in depth (~2.3MB at 1,500), parsed ~3s locally but 28s on CI
against the 15s per-test budget — a fixture-cost timeout, not a clamp miss.
600 levels keeps the conviction 4.7x past the ~128 effective-level bound.
Tests
New
website/src/test/MarkdownRenderer.depthGuard.test.tsx(29 tests; the overflow vectors and the pseudo-fence vector fail without the fix):```x`yis a paragraph per CommonMark; treating it as a fence would exempt a following deep run from clamping — pinned failing-without-fix with the exactRangeError)Raw-HTML vector (11 more; the deep-
<div>conviction fails without the fix insidehast-util-from-parse5):<div>nesting without a stack overflow (fails without the HTML pass:RangeErrorinside hast-util-from-parse5, ~2.2s; passes in ~54ms fixed)<div/>still opens per parse5 — honoring the slash would under-count)</span>under a div run)<li>spam is not nesting)<br>spam untouched)<past boundHTML-block fence shield (7 more; CommonMark 4.6 — a line opening an HTML block swallows following lines as raw content, so a ```-shaped line there is not a fence to micromark; entering fence state on it exempted an unclamped deep run behind a 3-token shield prefix):
<div>+ ``` + 5,000-deep tag run returned unchanged from the clamp and threw theRangeErrorin the rehype-raw pipeline at the unfixed tree)<pre>block: the shield persists to the textual closer (condition-1 blocks end at</pre>, not blank lines — a blank-only latch would re-open the bypass)<pre>x</pre>then a fence is genuine (single-line blocks latch nothing; passes both sides, regression pin)- <div>opens a block inside a list item — openers are detected after stripping the container run)<!--ends at-->; the deep run after the closer is clamped)<x-widget>shields identically)While the block latch is open the inline code-span mask is also inert: a swallowed backtick line is raw bytes to micromark, so it can neither latch nor mask a past-bound open (a masked open would flow through unrewritten — the other half of the same bypass). Block-name tables are copied from micromark's own
micromark-util-html-tag-namelists (verified equal at build: 62 block names, 4 raw names).Without the fix, the deep-nesting, pseudo-fence, deep-
<div>, and html-block-shield tests throwRangeError: Maximum call stack size exceededinside<Markdown>(the 5 shield attack pins fail at the unfixed tree: 5 failed / 24 passed; all 29 pass fixed, suite solo in ~4s). Family scope (every test importing MarkdownRenderer or clampNestingDepth): 2,600 tests across 166 files green. Full local gate mirror also green:tsc -b,eslint src, theme-colors, phantom-classes, i18n-strings, jscpd, full vitest suite.Manual verification
Rendered a 50,000-deep blockquote message through the fixed
MarkdownBlockin the vitest DOM harness: renders as a clamped-depth blockquote chain instead of crashing. A 50-deep message renders exactly as before (passthrough verified byte-identical at the clamp layer).Related Issues
None found — searched open issues and PRs for recursion/depth/stack-overflow reports against MarkdownRenderer before building (7 open MarkdownRenderer PRs checked, all orthogonal).
Pattern harvest
The pattern is recursive tree processing whose recursion depth is input-controlled with no guard at any layer. Harvested across this seam: eight self-recursive walkers inside
MarkdownRenderer.tsxplus the remark/rehype pipeline's own recursive visitors all share the same exposure, and all funnel through one input choke point (MarkdownBlock's pre-parse string). Fixing at the choke point covers every current walker and any walker added later, where per-walker caps would rot. A second harvest from review: an exemption window inside a guard must judge its trigger by the downstream parser's semantics — a looser line-shape match (here, a fence regex that accepted backticks in a backtick fence's info string) turns the guard itself into the bypass. A third, also from review: an input clamp must cover every spelling the pipeline turns into tree depth, not just the one that crashed first —rehype-rawmakes embedded HTML a parse path, so<div>runs were the same attack through a different grammar, and the guard was incomplete until all three spellings (container prefixes, list indent, HTML tags) hit a bound at the same choke point. One adjacent candidate of the same class exists wherever raw markdown is parsed outsideMarkdownBlock(e.g. any preview/export path that parses independently); left out deliberately per one-topic-per-PR — happy to file separately if maintainers want those paths audited too.Rule candidate: when recursion depth is controlled by input, clamp the input at its single entry choke point instead of capping each recursive consumer — and enumerate every grammar the pipeline parses into depth (markdown containers, indent, embedded HTML), because covering one spelling of the attack leaves the guard bypassable through the next.
F2 (source-position drift) — human-writer override rationale
The flagged drift (data-sourcepos coordinates vs unclamped content;
first-duplicate fallback may miscite a selection location) can occur only on
lines the clamp rewrote — input carrying more than 100 leading container
markers on a single line, which does not occur in human-authored content.
For that adversarial class the alternative to approximate citation is no
render at all (the stack overflow this guard exists to stop). Commit 2
narrows the drift further: the rewrite keeps the kept prefix byte-identical
and inserts exactly one byte, so coordinates are exact for all unclamped
lines and for everything before the clamp point on a clamped line.
Adjudication: total=0 uphold=0 (flag not upheld); requesting human-writer
override on that basis.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
Per the template placeholder (CLA text pending): offered under the same terms as my prior merged contributions to this repository (#8835).
Why no screenshot: no visual delta -- this change rewrites pathological input strings (>100
leading container markers per line, >256 indent columns, or >100-deep raw
HTML tag runs per message) before parsing; ordinary content is byte-identical
through the clamp and renders unchanged. UX review confirmed zero visual
delta.