CVE-2023-26145: Improper Neutralization of Special Elements used in a Command ('Command Injection') (CWE-77)
value: This affects versions of the package pydash before 6.0.0. A number of pydash methods such as pydash.objects.invoke() and pydash.collections.invoke_map() accept dotted paths (Deep Path Strings) to target a nested Python object, relative to the original source object. These paths can be used to target internal class attributes and dict items, to retrieve, modify or invoke nested Python objects.**Note:**The pydash.objects.invoke() method is vulnerable to Command Injection when the following prerequisites are satisfied:1) The source object (argument 1) is not a built-in object such as list/dict (otherwise, the __init__.__globals__ path is not accessible)2) The attacker has control over argument 2 (the path string) and argument 3 (the argument to pass to the invoked method)The pydash.collections.invoke_map() method is also vulnerable, but is harder to exploit as the attacker does not have direct control over the argument to be passed to the invoked function.
CWE description: The product constructs all or part of a command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended command when it is sent to a downstream component.
CVSS v3 base score: 8.1
Published: 2023-09-28T05:15Z
Repository: https://github.com/dgilland/pydash
Language: Python
Files changed: src/pydash/helpers.py, tests/test_objects.py
Commit date: 2023-01-28 20:30:52 -0500
Commit message: fix: don't allow object paths that reference dunder-method attributes for functions like get()

Diff: diff --git a/src/pydash/helpers.py b/src/pydash/helpers.py
index 0467c9c..a6033f9 100644
--- a/src/pydash/helpers.py
+++ b/src/pydash/helpers.py
@@ -176,18 +176,26 @@ def _base_get_item(obj, key, default=UNSET):
 
 
 def _base_get_object(obj, key, default=UNSET):
     value = _base_get_item(obj, key, default=UNSET)
     if value is UNSET:
+        _raise_if_restricted_key(key)
         value = default
         try:
             value = getattr(obj, key)
         except Exception:
             pass
     return value
 
 
+def _raise_if_restricted_key(key):
+    # Prevent access to dunder-methods since this could expose access to globals through leaky
+    # attributes such as obj.__init__.__globals__.
+    if len(key) > 4 and key.isascii() and key.startswith("__") and key.endswith("__"):
+        raise KeyError(f"access to restricted key {key!r} is not allowed")
+
+
 def base_set(obj, key, value, allow_override=True):
     """
     Set an object's `key` to `value`. If `obj` is a ``list`` and the `key` is the next available
     index position, append to list; otherwise, pad the list of ``None`` and then append to the list.
 
@@ -211,10 +219,11 @@ def base_set(obj, key, value, allow_override=True):
                 # Pad list object with None values up to the index key so we can append the value
                 # into the key index.
                 obj[:] = (obj + [None] * key)[:key]
             obj.append(value)
     elif (allow_override or not hasattr(obj, key)) and obj is not None:
+        _raise_if_restricted_key(key)
         setattr(obj, key, value)
 
     return obj
 
 
diff --git a/tests/test_objects.py b/tests/test_objects.py
index ae239e7..a21d75d 100644
--- a/tests/test_objects.py
+++ b/tests/test_objects.py
@@ -378,10 +378,34 @@ def test_get__should_not_populate_defaultdict():
     data = defaultdict(list)
     _.get(data, "a")
     assert data == {}
 
 
+@parametrize(
+    "obj,path",
+    [
+        (helpers.Object(), "__init__"),
+        (helpers.Object(subobj=helpers.Object()), "subobj.__init__"),
+        (namedtuple("a", ["a"])(a=1), "__len__"),
+    ],
+)
+def test_get__raises_for_objects_when_path_restricted(obj, path):
+    with pytest.raises(KeyError, match="access to restricted key"):
+        _.get(obj, path)
+
+
+@parametrize(
+    "obj,path",
+    [
+        ({}, "__init__"),
+        ([], "__contains__"),
+    ],
+)
+def test_get__does_not_raise_for_dict_or_list_when_path_restricted(obj, path):
+    assert _.get(obj, path) is None
+
+
 @parametrize(
     "case,expected",
     [
         (({"a": 1, "b": 2, "c": 3}, "b"), True),
         (([1, 2, 3], 0), True),

Fixed code:         _raise_if_restricted_key(key)
def _raise_if_restricted_key(key):
    # Prevent access to dunder-methods since this could expose access to globals through leaky
    # attributes such as obj.__init__.__globals__.
    if len(key) > 4 and key.isascii() and key.startswith("__") and key.endswith("__"):
        raise KeyError(f"access to restricted key {key!r} is not allowed")


        _raise_if_restricted_key(key)
@parametrize(
    "obj,path",
    [
        (helpers.Object(), "__init__"),
        (helpers.Object(subobj=helpers.Object()), "subobj.__init__"),
        (namedtuple("a", ["a"])(a=1), "__len__"),
    ],
)
def test_get__raises_for_objects_when_path_restricted(obj, path):
    with pytest.raises(KeyError, match="access to restricted key"):
        _.get(obj, path)


@parametrize(
    "obj,path",
    [
        ({}, "__init__"),
        ([], "__contains__"),
    ],
)
def test_get__does_not_raise_for_dict_or_list_when_path_restricted(obj, path):
    assert _.get(obj, path) is None