From 6136b844368398c65f7dbe18c69a6d5d4b34f3f5 Mon Sep 17 00:00:00 2001 From: Conner Webber Date: Fri, 3 Apr 2026 18:39:30 -0500 Subject: [PATCH] Fix uncontrolled recursion DoS in parser.py (CWE-674) Adds a configurable maximum nesting depth (default 100) to prevent RecursionError when parsing deeply nested arrays or inline tables. When the limit is exceeded, a ParseError is raised instead of allowing unbounded recursion that crashes the process. Fixes #459 Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_recursion_fix.py | 72 +++++++++++++++++++++++++++++++++++++ tomlkit/parser.py | 22 ++++++++++-- 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 tests/test_recursion_fix.py diff --git a/tests/test_recursion_fix.py b/tests/test_recursion_fix.py new file mode 100644 index 0000000..a717fb5 --- /dev/null +++ b/tests/test_recursion_fix.py @@ -0,0 +1,72 @@ +""" +Test for recursion depth fix in tomlkit parser. +Verifies that deeply nested input raises ParseError instead of RecursionError. +""" +import sys +sys.path.insert(0, ".") # Use local patched version if available + +import tomlkit +from tomlkit.exceptions import TOMLKitError + +def test_deeply_nested_arrays(): + """Deeply nested arrays should raise ParseError, not RecursionError.""" + payload = "x = " + "[" * 500 + "1" + "]" * 500 + try: + tomlkit.parse(payload) + print("[FAIL] No exception raised for 500-deep nested arrays") + except RecursionError: + print("[FAIL] RecursionError — fix not applied") + except TOMLKitError as e: + print(f"[PASS] ParseError raised: {e}") + except Exception as e: + print(f"[????] Unexpected: {type(e).__name__}: {e}") + +def test_deeply_nested_inline_tables(): + """Deeply nested inline tables should raise ParseError, not RecursionError.""" + payload = "x = " + "{a = " * 200 + "1" + "}" * 200 + try: + tomlkit.parse(payload) + print("[FAIL] No exception raised for 200-deep nested inline tables") + except RecursionError: + print("[FAIL] RecursionError — fix not applied") + except TOMLKitError as e: + print(f"[PASS] ParseError raised: {e}") + except Exception as e: + print(f"[????] Unexpected: {type(e).__name__}: {e}") + +def test_normal_nesting_still_works(): + """Reasonable nesting depth should still parse fine.""" + # 10 levels deep — well within limit + payload = "x = " + "[" * 10 + "1" + "]" * 10 + try: + doc = tomlkit.parse(payload) + print(f"[PASS] 10-deep arrays parse OK") + except Exception as e: + print(f"[FAIL] Normal nesting broken: {e}") + + # 5 levels inline tables + payload2 = 'x = {a = {b = {c = {d = {e = 1}}}}}' + try: + doc2 = tomlkit.parse(payload2) + print(f"[PASS] 5-deep inline tables parse OK") + except Exception as e: + print(f"[FAIL] Normal inline tables broken: {e}") + +def test_mixed_nesting(): + """Mixed arrays and inline tables at depth.""" + payload = "x = " + "[{a = " * 50 + "1" + "}]" * 50 + try: + tomlkit.parse(payload) + print("[PASS] 50-deep mixed nesting parsed (within default limit)") + except RecursionError: + print("[FAIL] RecursionError on mixed nesting") + except TOMLKitError as e: + print(f"[PASS] ParseError on mixed nesting: {e}") + +if __name__ == "__main__": + print("=== tomlkit recursion depth fix tests ===\n") + test_normal_nesting_still_works() + test_deeply_nested_arrays() + test_deeply_nested_inline_tables() + test_mixed_nesting() + print("\nDone") diff --git a/tomlkit/parser.py b/tomlkit/parser.py index 538ed03..230bb54 100644 --- a/tomlkit/parser.py +++ b/tomlkit/parser.py @@ -63,11 +63,16 @@ class Parser: Parser for TOML documents. """ - def __init__(self, string: str | bytes) -> None: + # Default maximum nesting depth for arrays/inline tables + DEFAULT_MAX_NESTING_DEPTH = 100 + + def __init__(self, string: str | bytes, max_nesting_depth: int | None = None) -> None: # Input to parse self._src = Source(decode(string)) self._aot_stack: list[Key] = [] + self._nesting_depth = 0 + self._max_nesting_depth = max_nesting_depth if max_nesting_depth is not None else self.DEFAULT_MAX_NESTING_DEPTH @property def _state(self) -> _StateHandler: @@ -582,6 +587,11 @@ def _parse_bool(self, style: BoolType) -> Bool: def _parse_array(self) -> Array: # Consume opening bracket, EOF here is an issue (middle of array) self.inc(exception=UnexpectedEofError) + self._nesting_depth += 1 + if self._nesting_depth > self._max_nesting_depth: + raise self.parse_error( + InternalParserError, "Maximum nesting depth exceeded" + ) elems: list[Item] = [] prev_value = None @@ -637,8 +647,10 @@ def _parse_array(self) -> Array: try: res = Array(elems, Trivia()) except ValueError: - pass + self._nesting_depth -= 1 + raise else: + self._nesting_depth -= 1 return res raise self.parse_error(ParseError, "Failed to parse array") @@ -646,6 +658,11 @@ def _parse_array(self) -> Array: def _parse_inline_table(self) -> InlineTable: # consume opening bracket, EOF here is an issue (middle of array) self.inc(exception=UnexpectedEofError) + self._nesting_depth += 1 + if self._nesting_depth > self._max_nesting_depth: + raise self.parse_error( + InternalParserError, "Maximum nesting depth exceeded" + ) elems = Container(True) expect_key = True @@ -685,6 +702,7 @@ def _parse_inline_table(self) -> InlineTable: self.inc(exception=UnexpectedEofError) expect_key = True + self._nesting_depth -= 1 return InlineTable(elems, Trivia()) def _parse_number(self, raw: str, trivia: Trivia) -> Item | None: