You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@plc4x.apache.org by sr...@apache.org on 2026/08/24 14:48:19 UTC
(plc4x) 03/24: fix(plc4py/spi): bound how deeply a message may nest its types
This is an automated email from the ASF dual-hosted git repository.
sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git
commit 6dad603431f219030a2650a4b907376e43597502
Author: Sebastian Rühl <sr...@apache.org>
AuthorDate: Mon Aug 24 11:04:25 2026 +0200
fix(plc4py/spi): bound how deeply a message may nest its types
The byte read buffer treated push_context as something it had no use for, since
it keeps no context of its own. It is still the one place every generated parser
passes on its way into a type, which makes it the only place the nesting can be
counted - and several mspec types contain themselves, so the depth of a message
is the sender's to choose and one level can cost a single byte on the wire.
Count the nesting there and refuse it past 1024 levels, before the recursion goes
any deeper, so a self-nesting message is reported as the parse failure it is
rather than as a RecursionError raised from wherever the interpreter happened to
run out. PLC4X_MAX_NESTING_DEPTH moves the bound and means the same thing here as
in the other bindings.
None of the three drivers this binding ships today carries a self-recursive type,
so this is the guard being in place before one arrives rather than a defect being
closed.
---
plc4py/plc4py/spi/generation/ReadBuffer.py | 62 ++++++++++++++++++++++--
plc4py/tests/unit/plc4py/spi/test_read_buffer.py | 42 +++++++++++++++-
2 files changed, 99 insertions(+), 5 deletions(-)
diff --git a/plc4py/plc4py/spi/generation/ReadBuffer.py b/plc4py/plc4py/spi/generation/ReadBuffer.py
index 9f573ef679..3eb436f2c7 100644
--- a/plc4py/plc4py/spi/generation/ReadBuffer.py
+++ b/plc4py/plc4py/spi/generation/ReadBuffer.py
@@ -15,6 +15,8 @@
# specific language governing permissions and limitations
# under the License.
import struct
+import logging
+import os
import types
from abc import ABC, abstractmethod
from dataclasses import dataclass
@@ -28,6 +30,46 @@ from plc4py.api.exceptions.exceptions import SerializationException, ParseExcept
from plc4py.api.messages.PlcMessage import PlcMessage
from plc4py.utils.GenericTypes import ByteOrder, ByteOrderAware
+# How deeply a message may nest its types. Every type opens a context on the way in
+# and closes it on the way out, so the depth of that stack is the depth of the
+# message, and a type that contains itself turns the sender's byte count into our
+# call depth. Several mspec types do contain themselves - BACnet constructed data,
+# an OPC UA variant, the ADS data type table - so the bound keeps the nesting a
+# parse error rather than a RecursionError raised from somewhere arbitrary.
+DEFAULT_MAX_NESTING_DEPTH = 1024
+
+# Names the environment variable that moves the bound for the device whose messages
+# genuinely nest deeper than anything we have seen. It carries the same meaning as
+# in plc4j and plc4go: a positive depth, and anything else leaves the default in
+# place.
+MAX_NESTING_DEPTH_ENV = "PLC4X_MAX_NESTING_DEPTH"
+
+_logger = logging.getLogger(__name__)
+
+
+def resolve_max_nesting_depth(configured: Union[str, None]) -> int:
+ if configured is None or not configured.strip():
+ return DEFAULT_MAX_NESTING_DEPTH
+ try:
+ depth = int(configured.strip())
+ except ValueError:
+ _logger.warning(
+ "%s is not a number (%s), keeping the maximum nesting depth of %d",
+ MAX_NESTING_DEPTH_ENV,
+ configured,
+ DEFAULT_MAX_NESTING_DEPTH,
+ )
+ return DEFAULT_MAX_NESTING_DEPTH
+ if depth < 1:
+ _logger.warning(
+ "%s must be positive but was %d, keeping the maximum nesting depth of %d",
+ MAX_NESTING_DEPTH_ENV,
+ depth,
+ DEFAULT_MAX_NESTING_DEPTH,
+ )
+ return DEFAULT_MAX_NESTING_DEPTH
+ return depth
+
class PositionAware:
@abstractmethod
@@ -171,6 +213,10 @@ class ReadBufferByteBased(ReadBuffer):
)
self.byte_order = byte_order
self.position = 0
+ self.depth = 0
+ self.max_depth = resolve_max_nesting_depth(
+ os.environ.get(MAX_NESTING_DEPTH_ENV)
+ )
def get_bytes(self) -> memoryview:
return memoryview(self.bb)
@@ -179,12 +225,20 @@ class ReadBufferByteBased(ReadBuffer):
return self.position
def push_context(self, logical_name: str, **kwargs) -> None:
- # byte buffer need no context handling
- pass
+ # A byte buffer needs no context of its own, but this is the one place every
+ # generated parser passes on its way into a type, so it is where the nesting
+ # gets counted.
+ if self.depth >= self.max_depth:
+ raise ParseException(
+ f"Maximum nesting depth of {self.max_depth} exceeded at {logical_name}"
+ )
+ self.depth += 1
def pop_context(self, logical_name: str, **kwargs) -> None:
- # Byte buffer doesn't need context handling
- pass
+ # Byte buffer doesn't need context handling beyond releasing the nesting
+ # budget the matching push_context took.
+ if self.depth > 0:
+ self.depth -= 1
def read_bit(self, logical_name: str = "", **kwargs) -> bool:
result: bool = bool(self.bb[self.position])
diff --git a/plc4py/tests/unit/plc4py/spi/test_read_buffer.py b/plc4py/tests/unit/plc4py/spi/test_read_buffer.py
index 424578984c..490d102c7b 100644
--- a/plc4py/tests/unit/plc4py/spi/test_read_buffer.py
+++ b/plc4py/tests/unit/plc4py/spi/test_read_buffer.py
@@ -20,7 +20,12 @@
import pytest
from bitarray import bitarray
-from plc4py.spi.generation.ReadBuffer import ReadBufferByteBased
+from plc4py.api.exceptions.exceptions import ParseException
+from plc4py.spi.generation.ReadBuffer import (
+ DEFAULT_MAX_NESTING_DEPTH,
+ MAX_NESTING_DEPTH_ENV,
+ ReadBufferByteBased,
+)
from plc4py.spi.generation.WriteBuffer import WriteBufferByteBased
from plc4py.utils.GenericTypes import ByteOrder
@@ -151,3 +156,38 @@ def test_read_buffer_read_float_big_endian(mocker) -> None:
bb: bitarray = bitarray("1100 0000 0000 0000 0000 0000 0000 0000")
rb: ReadBufferByteBased = ReadBufferByteBased(bb, ByteOrder.BIG_ENDIAN)
assert rb.read_float(logical_name="Byte") == -2.0
+
+
+def test_read_buffer_bounds_nesting_depth() -> None:
+ bb: bytearray = bytearray(b"\x01")
+ rb = ReadBufferByteBased(bb, ByteOrder.BIG_ENDIAN)
+ for _ in range(rb.max_depth):
+ rb.push_context("level")
+ with pytest.raises(ParseException, match="nesting depth"):
+ rb.push_context("one_too_deep")
+
+
+def test_read_buffer_leaving_a_context_makes_room_again() -> None:
+ bb: bytearray = bytearray(b"\x01")
+ rb = ReadBufferByteBased(bb, ByteOrder.BIG_ENDIAN)
+ for _ in range(rb.max_depth):
+ rb.push_context("level")
+ rb.pop_context("level")
+ rb.push_context("back_in_budget")
+
+
+def test_read_buffer_nesting_depth_from_the_environment(monkeypatch) -> None:
+ monkeypatch.setenv(MAX_NESTING_DEPTH_ENV, "8")
+ bb: bytearray = bytearray(b"\x01")
+ rb = ReadBufferByteBased(bb, ByteOrder.BIG_ENDIAN)
+ assert rb.max_depth == 8
+
+
+@pytest.mark.parametrize("value", ["", " ", "plenty", "0", "-1"])
+def test_read_buffer_unusable_environment_value_keeps_the_default(
+ monkeypatch, value
+) -> None:
+ monkeypatch.setenv(MAX_NESTING_DEPTH_ENV, value)
+ bb: bytearray = bytearray(b"\x01")
+ rb = ReadBufferByteBased(bb, ByteOrder.BIG_ENDIAN)
+ assert rb.max_depth == DEFAULT_MAX_NESTING_DEPTH