Skip to main content

max / pter

Cap DOM recursion depth in HTML-to-markdown conversion walk_children/handle_element recurse once per DOM nesting level with no bound; html5ever imposes no depth limit, so a flat but deeply-nested body (<div> x500k) would overflow the stack -> an uncatchable abort. Add a MAX_DEPTH (512) guard on the tree walk. Fixes the hostile-email persistent-DoS reachable via GoingsOn's IMAP sync, where the abort fired before the watermark persisted and every subsequent sync re-crashed on the same message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 14:15 UTC
Commit: 61ce0de7fa18e17bb98911dd91071c527dcb87a1
Parent: b4b621f
1 file changed, +34 insertions, -0 deletions
@@ -26,9 +26,19 @@ pub fn convert(html: &str) -> String {
26 26 whitespace::normalize(&ctx.output)
27 27 }
28 28
29 + /// Maximum DOM nesting depth walked before children are dropped. html5ever
30 + /// imposes no depth limit of its own, so a flat but deeply-nested body (e.g.
31 + /// `<div>`×500k) would otherwise recurse `walk_children`/`handle_element` once
32 + /// per level and overflow the stack — an uncatchable `abort()`. 512 is the
33 + /// browser-conventional nesting cap; no legitimate email nests remotely that
34 + /// deep, and 512 frames stay well within even a 2 MB worker stack.
35 + const MAX_DEPTH: u32 = 512;
36 +
29 37 /// Conversion state threaded through the tree walk.
30 38 struct Context {
31 39 output: String,
40 + /// Current DOM recursion depth (guards against pathological nesting).
41 + depth: u32,
32 42 /// Current list nesting depth (for indentation).
33 43 list_depth: u32,
34 44 /// Whether we're inside a <pre> block (preserve whitespace).
@@ -49,6 +59,7 @@ impl Context {
49 59 fn new() -> Self {
50 60 Self {
51 61 output: String::with_capacity(4096),
62 + depth: 0,
52 63 list_depth: 0,
53 64 in_pre: false,
54 65 in_link: false,
@@ -92,6 +103,14 @@ impl Context {
92 103
93 104 /// Walk all children of a node, converting each to markdown.
94 105 fn walk_children(parent: ElementRef, ctx: &mut Context) {
106 + // Bound recursion depth. Beyond MAX_DEPTH we stop descending — the content
107 + // is either pathological or hostile, and dropping it is vastly preferable to
108 + // a stack-overflow abort that would wedge the caller (a hostile email would
109 + // otherwise re-crash on every subsequent sync).
110 + if ctx.depth >= MAX_DEPTH {
111 + return;
112 + }
113 + ctx.depth += 1;
95 114 for child in parent.children() {
96 115 match child.value() {
97 116 Node::Text(text) => {
@@ -105,6 +124,7 @@ fn walk_children(parent: ElementRef, ctx: &mut Context) {
105 124 _ => {}
106 125 }
107 126 }
127 + ctx.depth -= 1;
108 128 }
109 129
110 130 /// Handle a text node.
@@ -449,6 +469,20 @@ mod tests {
449 469 }
450 470
451 471 #[test]
472 + fn pathological_nesting_does_not_overflow() {
473 + // A flat but deeply-nested body (far past MAX_DEPTH) must not recurse
474 + // the tree walk into a stack-overflow abort — it returns bounded output.
475 + // Guards the hostile-email DoS: without the depth cap this aborts the
476 + // process and every subsequent sync re-crashes on the same message.
477 + let n = (MAX_DEPTH as usize) + 5_000;
478 + let deep = format!("text{}{}", "<div>".repeat(n), "</div>".repeat(n));
479 + let md = convert(&deep);
480 + // Reaching this line at all proves no stack-overflow abort; the shallow
481 + // "text" (depth 1, within the cap) still renders.
482 + assert!(md.contains("text"), "content within the cap still renders");
483 + }
484 +
485 + #[test]
452 486 fn paragraph() {
453 487 assert_eq!(convert("<p>one</p><p>two</p>"), "one\n\ntwo");
454 488 }