max / pter
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 | } |