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