Skip to content

fix(mrml-core): bound parser recursion depth - #694

Open
jdrouet wants to merge 1 commit into
mainfrom
fix/bound-parser-recursion
Open

jdrouet wants to merge 1 commit into
mainfrom
fix/bound-parser-recursion

Conversation

@jdrouet

@jdrouet jdrouet commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Breaking — for 7.0. Adds a variant to prelude::parser::Error and marks it #[non_exhaustive].

Two unbounded recursions let an untrusted template abort the process. Both end in fatal runtime error: stack overflow, aborting — SIGABRT, not a catchable panic, so a service rendering user-supplied MJML cannot defend itself:

  • Element nesting. 20,000 nested tags, about 220KB of input. 5,000 was fine, so the cliff sat between.
  • mj-include cycles. a.mjml and b.mjml including each other, or a template including itself. No depth counter and no visited-set existed anywhere.

Both now return Error::DepthLimitExceeded, on the sync and async paths alike.

Two limits, not one

My first attempt used a single counter and had to tune it to the tightest measured cliff, which landed at 20 — and that rejected 15 nested <div>s inside an mj-text, which is ordinary WYSIWYG output. Trading a DoS on adversarial input for a hard failure on real input is the worse bug.

The two recursions have different legitimate ranges and different per-level stack costs, so they get their own limits:

MAX_ELEMENT_DEPTH = 64   // measured debug cliff for this shape: 126
MAX_INCLUDE_DEPTH = 8    // measured debug cliff, async cycle: 41

element_depth tracks real call-stack depth and is inherited across includes rather than reset, so the two cannot be combined to escape the bound — 8 chained includes each nested 60 deep errors cleanly instead of aborting. Inside a fully structured template the effective budget for HTML nesting is about 57 levels; realistic templates use roughly 10.

Limits are consts, not configuration. If someone turns up with a legitimate template that needs more, that is the moment to make it configurable.

Also

mrml-wasm matched the error enum exhaustively, so #[non_exhaustive] broke it. It gains a matching variant plus an Unknown { message } fallback carrying the core error's Display — deliberately not unreachable!(), since a future core variant panicking inside a browser is the worst available failure mode.

Five regression tests in tests/depth-limit.rs, covering both recursion shapes on both paths plus one pinning that ~60 levels of legitimate nesting still parse — that last one is what would have caught the bad limit. All pass in debug and release. Existing tests unchanged at 621.

Signed-off-by: Jeremie Drouet <jeremie.drouet@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

📈 This is how your metrics evolved 📉


This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant