Skip to content

Parser: limit body part nesting depth and bound boundary searches - #367

Merged
vincent-richard merged 1 commit into
masterfrom
366-deeply-nested-multipart-quadratic-parse-cost-and-sigsegv-from-unguarded-recursion
Oct 23, 2026
Merged

vincent-richard merged 1 commit into
masterfrom
366-deeply-nested-multipart-quadratic-parse-cost-and-sigsegv-from-unguarded-recursion

Conversation

@vincent-richard

Copy link
Copy Markdown
Member

A deeply nested multipart message could exhaust the stack, as each level of nesting recurses into body::parseImpl(), and the parsing time grew with the square of the nesting depth.

Stack exhaustion:

  • Body parts are no longer split beyond a maximum nesting depth, which defaults to 100 and can be changed with parsingContext::setMaxNestingDepth(). A multipart part at this depth keeps its body contents as is, like a non-multipart part, so no data is lost.

Parsing time:

  • component::parse() wrapped the input stream in a new parserInputStreamAdapter even if it already was one, so each level of nesting added indirections to all the stream accesses of the levels below it.

  • The boundary search did not stop at the end of the enclosing part, and was run once more after the closing delimiter: each level scanned the rest of the message, up to the end of the stream.

  • Each match rejected by findNextBoundaryPosition() (not at the beginning of a line, or prefix of another boundary) restarted a search which read 4 KiB ahead. The search is now for "\n--boundary", and parserInputStreamAdapter::findNext() starts with small reads, so that its cost depends on the distance to the match. With boundaries "a", "aa", "aaa", etc. nested 100 levels deep, parsing a 1 MiB message took 150 s.

parserInputStreamAdapter::findNext() takes an optional end position. It no longer searches the bytes of its buffer twice, nor returns wrong positions with streams which return fewer bytes than requested.

Behaviour change: the boundary of a multipart part is now only searched for within this part. If its closing delimiter is missing, its last part extends to the end of the enclosing part; before, it became epilog text if the boundary appeared again further in the message.

Fixes #366. Reported by @DrNbnonono.

A deeply nested multipart message could exhaust the stack, as each
level of nesting recurses into body::parseImpl(), and the parsing time
grew with the square of the nesting depth.

Stack exhaustion:

- Body parts are no longer split beyond a maximum nesting depth, which
  defaults to 100 and can be changed with
  parsingContext::setMaxNestingDepth(). A multipart part at this depth
  keeps its body contents as is, like a non-multipart part, so no data
  is lost.

Parsing time:

- component::parse() wrapped the input stream in a new
  parserInputStreamAdapter even if it already was one, so each level of
  nesting added indirections to all the stream accesses of the levels
  below it.

- The boundary search did not stop at the end of the enclosing part,
  and was run once more after the closing delimiter: each level scanned
  the rest of the message, up to the end of the stream.

- Each match rejected by findNextBoundaryPosition() (not at the
  beginning of a line, or prefix of another boundary) restarted a
  search which read 4 KiB ahead. The search is now for "\n--boundary",
  and parserInputStreamAdapter::findNext() starts with small reads, so
  that its cost depends on the distance to the match. With boundaries
  "a", "aa", "aaa", etc. nested 100 levels deep, parsing a 1 MiB
  message took 150 s.

parserInputStreamAdapter::findNext() takes an optional end position.
It no longer searches the bytes of its buffer twice, nor returns wrong
positions with streams which return fewer bytes than requested.

Behaviour change: the boundary of a multipart part is now only searched
for within this part. If its closing delimiter is missing, its last
part extends to the end of the enclosing part; before, it became epilog
text if the boundary appeared again further in the message.

Fixes #366. Reported by @DrNbnonono.
@vincent-richard
vincent-richard merged commit 6405645 into master Oct 23, 2026
8 checks passed
@vincent-richard
vincent-richard deleted the 366-deeply-nested-multipart-quadratic-parse-cost-and-sigsegv-from-unguarded-recursion branch October 23, 2026 11:36
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.

Deeply nested multipart: quadratic parse cost and SIGSEGV from unguarded recursion

1 participant