From 3349b8b2bbb43fa20e58ef7b14c1785792a9e8b3 Mon Sep 17 00:00:00 2001 From: cr-sbarbouche Date: Thu, 6 Aug 2026 22:14:48 +0100 Subject: [PATCH 1/5] fix(python): eliminate ReDoS in chat parser role-boundary regex Unquoted, unterminated attributes let the value class in the role-boundary regex overlap with the `,` separator and closing `]`, giving the backtracking engine an exponential number of equivalent splits to try on a failing match. Rewrite the attribute group as a quoted/unquoted alternation with no shared characters between branches, plus possessive quantifiers (Python 3.11+), matching the fix already applied to the Java parser in #444. Fixes #446 --- .../python/prompty/prompty/parsers/prompty.py | 6 ++++- runtime/python/prompty/tests/test_parsers.py | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/runtime/python/prompty/prompty/parsers/prompty.py b/runtime/python/prompty/prompty/parsers/prompty.py index afc97f111..e4152ac71 100644 --- a/runtime/python/prompty/prompty/parsers/prompty.py +++ b/runtime/python/prompty/prompty/parsers/prompty.py @@ -28,9 +28,13 @@ # Role boundary regex — matches lines like ``system:`` or ``user[name="Alice"]:`` _ROLE_NAMES = "|".join(sorted(ROLES)) +# Possessive quantifiers + a quoted/unquoted alternation (no shared chars between +# branches) keep this linear-time — a plain `\w+\s*=\s*"?[^"]*"?` value class lets +# unquoted attributes overlap with the separator/`]`, causing catastrophic +# backtracking on malformed input. See issue #446. _BOUNDARY_RE = re.compile( r"(?im)^\s*#?\s*(" + _ROLE_NAMES + r")" - r"(\[((\w+\s*=\s*\"?[^\"]*\"?\s*,?\s*)+)\])?\s*:\s*$" + r"(\[(?:\w++\s*+=\s*+(?:\"[^\"]*\"|[^\",\]]*+)\s*+,?+\s*+)+\])?\s*:\s*$" ) diff --git a/runtime/python/prompty/tests/test_parsers.py b/runtime/python/prompty/tests/test_parsers.py index dfda9c8b0..628c94973 100644 --- a/runtime/python/prompty/tests/test_parsers.py +++ b/runtime/python/prompty/tests/test_parsers.py @@ -2,11 +2,14 @@ from __future__ import annotations +import time + import pytest from prompty.core.types import Message, TextPart from prompty.model import Prompty from prompty.parsers import PromptyChatParser +from prompty.parsers.prompty import _BOUNDARY_RE # --------------------------------------------------------------------------- # Helpers @@ -257,6 +260,28 @@ def test_no_images_all_text(self): assert isinstance(result[0].parts[0], TextPart) +# --------------------------------------------------------------------------- +# ReDoS regression (issue #446) +# --------------------------------------------------------------------------- + + +class TestRoleBoundaryReDoS: + """The role-boundary regex must stay linear-time on adversarial input. + + Unquoted, unterminated attributes used to let the value class overlap + with the `,` separator and closing `]`, giving the backtracking engine + an exponential number of equivalent splits to try before failing. + """ + + def test_unquoted_unterminated_attrs_stay_fast(self): + payload = "user[" + "a=b," * 24 + "!" + start = time.perf_counter() + result = _BOUNDARY_RE.match(payload) + elapsed = time.perf_counter() - start + assert result is None + assert elapsed < 0.5, f"role-boundary regex took {elapsed:.3f}s — possible ReDoS regression" + + # --------------------------------------------------------------------------- # Async # --------------------------------------------------------------------------- From 3c820644234f7dbee3065283b0152853f7dcc8b5 Mon Sep 17 00:00:00 2001 From: cr-sbarbouche Date: Thu, 6 Aug 2026 22:54:30 +0100 Subject: [PATCH 2/5] fix(python): address ReDoS test review feedback Test through the public parse() API instead of importing the module-private _BOUNDARY_RE, and replace the fixed wall-clock assertion with a doubling-input comparison so the test can't flake on a slow CI runner while still catching a regression to exponential behavior. --- runtime/python/prompty/tests/test_parsers.py | 28 ++++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/runtime/python/prompty/tests/test_parsers.py b/runtime/python/prompty/tests/test_parsers.py index 628c94973..a568383c6 100644 --- a/runtime/python/prompty/tests/test_parsers.py +++ b/runtime/python/prompty/tests/test_parsers.py @@ -9,7 +9,6 @@ from prompty.core.types import Message, TextPart from prompty.model import Prompty from prompty.parsers import PromptyChatParser -from prompty.parsers.prompty import _BOUNDARY_RE # --------------------------------------------------------------------------- # Helpers @@ -266,20 +265,33 @@ def test_no_images_all_text(self): class TestRoleBoundaryReDoS: - """The role-boundary regex must stay linear-time on adversarial input. + """Parsing must stay roughly linear in input size on adversarial input. Unquoted, unterminated attributes used to let the value class overlap with the `,` separator and closing `]`, giving the backtracking engine an exponential number of equivalent splits to try before failing. + Comparing runtime at two input sizes — rather than an absolute + wall-clock budget — keeps this robust on slow/contended CI runners + while still catching a regression back to exponential behavior. """ - def test_unquoted_unterminated_attrs_stay_fast(self): - payload = "user[" + "a=b," * 24 + "!" + def setup_method(self): + self.parser = PromptyChatParser() + self.agent = _make_agent() + + def _time_adversarial_parse(self, reps: int) -> float: + payload = "user[" + "a=b," * reps + "!" start = time.perf_counter() - result = _BOUNDARY_RE.match(payload) - elapsed = time.perf_counter() - start - assert result is None - assert elapsed < 0.5, f"role-boundary regex took {elapsed:.3f}s — possible ReDoS regression" + self.parser.parse(self.agent, payload) + return time.perf_counter() - start + + def test_doubling_input_does_not_blow_up_runtime(self): + small = self._time_adversarial_parse(20) + large = self._time_adversarial_parse(40) + # Exponential (catastrophic-backtracking) behavior would roughly + # square the runtime when the repeat count doubles; linear-time + # matching keeps it within a small constant factor. + assert large < max(small * 20, 0.1) # --------------------------------------------------------------------------- From e7d62f18d4f7c242bc4b00c34fdcf8580d0f2fbd Mon Sep 17 00:00:00 2001 From: cr-sbarbouche Date: Fri, 7 Aug 2026 00:42:29 +0100 Subject: [PATCH 3/5] docs(python): clarify the ReDoS test's absolute floor is a noise guard The docstring said the assertion avoids an absolute wall-clock budget, but it does include a 0.1s floor. Clarify that the floor only keeps the relative comparison from collapsing to noise on a very fast run and is not itself the enforced budget. --- runtime/python/prompty/tests/test_parsers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/runtime/python/prompty/tests/test_parsers.py b/runtime/python/prompty/tests/test_parsers.py index a568383c6..a3bd00564 100644 --- a/runtime/python/prompty/tests/test_parsers.py +++ b/runtime/python/prompty/tests/test_parsers.py @@ -270,9 +270,11 @@ class TestRoleBoundaryReDoS: Unquoted, unterminated attributes used to let the value class overlap with the `,` separator and closing `]`, giving the backtracking engine an exponential number of equivalent splits to try before failing. - Comparing runtime at two input sizes — rather than an absolute - wall-clock budget — keeps this robust on slow/contended CI runners - while still catching a regression back to exponential behavior. + The assertion is mostly relative (runtime at 40 reps vs. 20 reps) so it + stays robust on a slow/contended CI runner; the small absolute floor + only keeps the relative bound from collapsing to noise when `small` + itself measures near zero on a very fast run — it is not the budget + being enforced. """ def setup_method(self): From e967c0a42ec50a4950b6e377ac0164d917e7cb6a Mon Sep 17 00:00:00 2001 From: cr-sbarbouche Date: Fri, 7 Aug 2026 01:00:33 +0100 Subject: [PATCH 4/5] docs(python): state the Python 3.11+ floor next to the possessive regex The automated reviewer keeps flagging the possessive quantifiers as a pre-3.11 risk because it only sees this diff, not pyproject.toml. Say the constraint explicitly at the point of use so it's obvious without cross-referencing another file. --- runtime/python/prompty/prompty/parsers/prompty.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/runtime/python/prompty/prompty/parsers/prompty.py b/runtime/python/prompty/prompty/parsers/prompty.py index e4152ac71..8a07c7f32 100644 --- a/runtime/python/prompty/prompty/parsers/prompty.py +++ b/runtime/python/prompty/prompty/parsers/prompty.py @@ -32,6 +32,8 @@ # branches) keep this linear-time — a plain `\w+\s*=\s*"?[^"]*"?` value class lets # unquoted attributes overlap with the separator/`]`, causing catastrophic # backtracking on malformed input. See issue #446. +# Possessive quantifiers (`++`, `*+`, `?+`) require Python 3.11+, which pyproject.toml +# already sets as this package's floor (`requires-python = ">=3.11"`). _BOUNDARY_RE = re.compile( r"(?im)^\s*#?\s*(" + _ROLE_NAMES + r")" r"(\[(?:\w++\s*+=\s*+(?:\"[^\"]*\"|[^\",\]]*+)\s*+,?+\s*+)+\])?\s*:\s*$" From ac431eac257cb28d5d41ce6177631cf82b828c19 Mon Sep 17 00:00:00 2001 From: cr-sbarbouche Date: Fri, 7 Aug 2026 01:19:04 +0100 Subject: [PATCH 5/5] test(python): use best-of-20 timing in the ReDoS regression test A single sample per size is susceptible to a scheduler blip or first-run JIT/cache noise. Take the minimum of 20 samples instead so an occasional slow tick can't cause a false failure, without weakening what the test actually catches (exponential blowup still dwarfs this noise floor by orders of magnitude). --- runtime/python/prompty/tests/test_parsers.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/runtime/python/prompty/tests/test_parsers.py b/runtime/python/prompty/tests/test_parsers.py index a3bd00564..fb75280d1 100644 --- a/runtime/python/prompty/tests/test_parsers.py +++ b/runtime/python/prompty/tests/test_parsers.py @@ -281,11 +281,15 @@ def setup_method(self): self.parser = PromptyChatParser() self.agent = _make_agent() - def _time_adversarial_parse(self, reps: int) -> float: + def _time_adversarial_parse(self, reps: int, samples: int = 20) -> float: + """Best-of-`samples` timing to smooth out scheduler/CPU jitter.""" payload = "user[" + "a=b," * reps + "!" - start = time.perf_counter() - self.parser.parse(self.agent, payload) - return time.perf_counter() - start + best = float("inf") + for _ in range(samples): + start = time.perf_counter() + self.parser.parse(self.agent, payload) + best = min(best, time.perf_counter() - start) + return best def test_doubling_input_does_not_blow_up_runtime(self): small = self._time_adversarial_parse(20)