mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-09-09 06:40:17 +00:00
Manually backport the three upstream security fixes for CVE-2026-32640 to the Scarthgap simpleeval 0.9.13 recipe [1][2][3]. Include the required unhashable-container correction [4], which prevents the recursive security checks from raising TypeError on legitimate list and tuple values. Harden the recursive callback-argument validation to inspect sets, frozensets, and dictionary keys, and safely handle cyclic containers. Add regression coverage for each of these cases. Do not include the separate generator/coroutine hardening or the optional performance follow-up. Omit the new ModuleWrapper API so this stable-branch fix adds no unrelated public feature. [1] https://github.com/danthedeckie/simpleeval/commit/9cb4a7b99498 [2] https://github.com/danthedeckie/simpleeval/commit/1654cbf02193 [3] https://github.com/danthedeckie/simpleeval/commit/cffa9f68cee5 [4] https://github.com/danthedeckie/simpleeval/commit/d1e4569db678 Signed-off-by: Hetvi Thakar <hthakar@cisco.com> Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
486 lines
17 KiB
Diff
486 lines
17 KiB
Diff
From 030eb59004303dd4b88fedd6d80b7cb3e9d261a7 Mon Sep 17 00:00:00 2001
|
|
From: Daniel Fairhead <daniel@dev.ngo>
|
|
Date: Fri, 13 Mar 2026 06:35:27 +0000
|
|
Subject: [PATCH 3/4] Much stricter lockdown via _check_disallowed_items plus
|
|
adding ModuleWrapper
|
|
|
|
CVE: CVE-2026-32640
|
|
Upstream-Status: Backport [https://github.com/danthedeckie/simpleeval/commit/cffa9f68cee54404a2ef43d949a8ae8a3311c503]
|
|
|
|
Backport Changes:
|
|
- Omit the new ModuleWrapper API and its tests and documentation so the Scarthgap backport does not add an unrelated public feature; raw modules remain fully blocked.
|
|
- Retain only the module-access prohibition documentation because simpleeval 0.9.13 does not contain the later allowed_attrs API or its documentation context.
|
|
- Extend _check_disallowed_items to traverse sets, frozensets, and dictionary keys, with regression coverage, so dangerous callback arguments cannot bypass the recursive check through those containers.
|
|
- Track visited container identities during each recursive check and add cyclic list and dictionary tests to prevent unbounded recursion without weakening forbidden-item detection.
|
|
|
|
(cherry picked from commit cffa9f68cee54404a2ef43d949a8ae8a3311c503)
|
|
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
|
|
---
|
|
README.rst | 1 +
|
|
simpleeval.py | 34 +++-
|
|
test_simpleeval.py | 384 +++++++++++++++++++++++++++++++++++++++++++++
|
|
3 files changed, 418 insertions(+), 1 deletion(-)
|
|
|
|
diff --git a/README.rst b/README.rst
|
|
index ed7c655..6ac327b 100644
|
|
--- a/README.rst
|
|
+++ b/README.rst
|
|
@@ -416,6 +416,7 @@ A few builtin functions are listed in ``simpleeval.DISALLOW_FUNCTIONS``. ``type
|
|
If you need to give access to this kind of functionality to your expressions, then be very
|
|
careful. You'd be better wrapping the functions in your own safe wrappers.
|
|
|
|
+Accessing modules as attributes is disallowed too.
|
|
The initial idea came from J.F. Sebastian on Stack Overflow
|
|
( http://stackoverflow.com/a/9558001/1973500 ) with modifications and many improvements,
|
|
see the head of the main file for contributors list.
|
|
diff --git a/simpleeval.py b/simpleeval.py
|
|
index 69aed83..9f3003e 100644
|
|
--- a/simpleeval.py
|
|
+++ b/simpleeval.py
|
|
@@ -424,6 +424,36 @@ class SimpleEval(object): # pylint: disable=too-few-public-methods
|
|
def __del__(self):
|
|
self.nodes = None
|
|
|
|
+ def _check_disallowed_items(self, item, visited=None):
|
|
+ """Check if item contains disallowed functions or modules.
|
|
+ Recursively checks containers (dict, list, tuple, set, frozenset).
|
|
+ Raises FeatureNotAvailable if forbidden content found.
|
|
+ """
|
|
+ if isinstance(item, types.ModuleType):
|
|
+ raise FeatureNotAvailable("Sorry, modules are not allowed")
|
|
+ if isinstance(item, Hashable) and item in DISALLOW_FUNCTIONS:
|
|
+ raise FeatureNotAvailable("This function is forbidden")
|
|
+
|
|
+ if not isinstance(item, (dict, list, tuple, set, frozenset)):
|
|
+ return
|
|
+
|
|
+ if visited is None:
|
|
+ visited = set()
|
|
+
|
|
+ item_id = id(item)
|
|
+ if item_id in visited:
|
|
+ return
|
|
+ visited.add(item_id)
|
|
+
|
|
+ if isinstance(item, dict):
|
|
+ for key in item.keys():
|
|
+ self._check_disallowed_items(key, visited)
|
|
+ for value in item.values():
|
|
+ self._check_disallowed_items(value, visited)
|
|
+ else:
|
|
+ for element in item:
|
|
+ self._check_disallowed_items(element, visited)
|
|
+
|
|
@staticmethod
|
|
def parse(expr):
|
|
"""parse an expression into a node tree"""
|
|
@@ -458,7 +488,9 @@ class SimpleEval(object): # pylint: disable=too-few-public-methods
|
|
"Sorry, {0} is not available in this " "evaluator".format(type(node).__name__)
|
|
)
|
|
|
|
- return handler(node)
|
|
+ result = handler(node)
|
|
+ self._check_disallowed_items(result)
|
|
+ return result
|
|
|
|
def _eval_expr(self, node):
|
|
return self._eval(node.value)
|
|
diff --git a/test_simpleeval.py b/test_simpleeval.py
|
|
index c60ec51..f3c9a60 100644
|
|
--- a/test_simpleeval.py
|
|
+++ b/test_simpleeval.py
|
|
@@ -618,6 +618,390 @@ class TestTryingToBreakOut(DRYTest):
|
|
with self.assertRaises(FeatureNotAvailable):
|
|
s.eval("thing.p('exit')")
|
|
|
|
+ def test_breakout_forbidden_function_in_list(self):
|
|
+ """Disallowed functions in lists should be blocked"""
|
|
+ s = SimpleEval(names={"funcs": [exec, eval]})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("funcs[0]('exit')")
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("funcs[1]('1+1')")
|
|
+
|
|
+ def test_breakout_module_in_list(self):
|
|
+ """Modules in lists should be blocked"""
|
|
+ import os.path
|
|
+
|
|
+ s = SimpleEval(names={"things": [os.path, os.system]})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("things[0].os.popen('id').read()")
|
|
+
|
|
+ def test_breakout_forbidden_function_in_dict_value(self):
|
|
+ """Disallowed functions as dict values should be blocked"""
|
|
+ s = SimpleEval(names={"funcs": {"bad": exec, "evil": eval}})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("funcs['bad']('exit')")
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("funcs['evil']('1+1')")
|
|
+
|
|
+ def test_breakout_module_in_dict_value(self):
|
|
+ """Modules as dict values should be blocked"""
|
|
+ import os.path
|
|
+
|
|
+ s = SimpleEval(names={"things": {"p": os.path, "s": os.system}})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("things['p'].os.popen('id').read()")
|
|
+
|
|
+ def test_breakout_forbidden_function_in_set(self):
|
|
+ """Disallowed functions in sets should be blocked"""
|
|
+
|
|
+ def invoke(items):
|
|
+ return next(iter(items))("40+2")
|
|
+
|
|
+ s = SimpleEval(names={"funcs": {eval}}, functions={"invoke": invoke})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("invoke(funcs)")
|
|
+
|
|
+ def test_breakout_forbidden_function_in_frozenset(self):
|
|
+ """Disallowed functions in frozensets should be blocked"""
|
|
+
|
|
+ def invoke(items):
|
|
+ return next(iter(items))("40+2")
|
|
+
|
|
+ s = SimpleEval(names={"funcs": frozenset({eval})}, functions={"invoke": invoke})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("invoke(funcs)")
|
|
+
|
|
+ def test_breakout_forbidden_function_in_dict_key(self):
|
|
+ """Disallowed functions as dict keys should be blocked"""
|
|
+
|
|
+ def invoke(items):
|
|
+ return next(iter(items))("40+2")
|
|
+
|
|
+ s = SimpleEval(names={"funcs": {eval: "value"}}, functions={"invoke": invoke})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("invoke(funcs)")
|
|
+
|
|
+ def test_cyclic_list(self):
|
|
+ """Cyclic lists should be checked without unbounded recursion"""
|
|
+ value = []
|
|
+ value.append(value)
|
|
+
|
|
+ self.assertIs(SimpleEval(names={"value": value}).eval("value"), value)
|
|
+
|
|
+ def test_cyclic_dict(self):
|
|
+ """Cyclic dicts should be checked without unbounded recursion"""
|
|
+ value = {}
|
|
+ value["self"] = value
|
|
+
|
|
+ self.assertIs(SimpleEval(names={"value": value}).eval("value"), value)
|
|
+
|
|
+ def test_cyclic_container_with_forbidden_function(self):
|
|
+ """A cycle should not hide other forbidden container members"""
|
|
+ value = [eval]
|
|
+ value.append(value)
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ SimpleEval(names={"value": value}).eval("value")
|
|
+
|
|
+ def test_breakout_function_returning_forbidden_function(self):
|
|
+ """Functions returning disallowed functions should be blocked"""
|
|
+
|
|
+ def get_evil():
|
|
+ return exec
|
|
+
|
|
+ s = SimpleEval(names={}, functions={"get_evil": get_evil})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("get_evil()('exit')")
|
|
+
|
|
+ def test_breakout_function_returning_module(self):
|
|
+ """Functions returning modules should be blocked"""
|
|
+ import os.path
|
|
+
|
|
+ def get_module():
|
|
+ return os.path
|
|
+
|
|
+ s = SimpleEval(names={}, functions={"get_module": get_module})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("get_module().os.popen('id').read()")
|
|
+
|
|
+ def test_dunder_all_in_module(self):
|
|
+ """__all__ should be blocked (starts with _)"""
|
|
+ import os
|
|
+
|
|
+ s = SimpleEval(names={"os": os})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("os.__all__")
|
|
+
|
|
+ def test_dunder_dict_in_module(self):
|
|
+ """__dict__ should be blocked (starts with _)"""
|
|
+ import os
|
|
+
|
|
+ s = SimpleEval(names={"os": os})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("os.__dict__")
|
|
+
|
|
+ def test_forbidden_method_in_tuple(self):
|
|
+ """Disallowed functions in tuples should be blocked"""
|
|
+ s = SimpleEval(names={"funcs": (exec, eval)})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("funcs[0]('exit')")
|
|
+
|
|
+ def test_module_in_tuple(self):
|
|
+ """Modules in tuples should be blocked"""
|
|
+ import os
|
|
+
|
|
+ s = SimpleEval(names={"mods": (os.path, os.system)})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("mods[0].os.popen('id').read()")
|
|
+
|
|
+ def test_breakout_via_nested_container_forbidden_func(self):
|
|
+ """Disallowed functions nested in containers should be blocked"""
|
|
+ s = SimpleEval(names={"data": {"nested": {"funcs": [exec]}}})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("data['nested']['funcs'][0]('exit')")
|
|
+
|
|
+ def test_breakout_via_nested_container_module(self):
|
|
+ """Modules nested in containers should be blocked"""
|
|
+ import os
|
|
+
|
|
+ s = SimpleEval(names={"data": {"mods": {"p": os.path}}})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("data['mods']['p'].os.popen('id').read()")
|
|
+
|
|
+ def test_forbidden_methods_on_allowed_attrs(self):
|
|
+ """Disallowed methods listed in DISALLOW_METHODS should be
|
|
+ blocked"""
|
|
+ s = SimpleEval()
|
|
+
|
|
+ # format and format_map are in DISALLOW_METHODS
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("'test {0}'.format")
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("'test'.format_map({0: 'x'})")
|
|
+
|
|
+ # __mro__ is in DISALLOW_METHODS
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("'test'.mro")
|
|
+
|
|
+ def test_function_returning_forbidden_method(self):
|
|
+ """Functions returning disallowed methods should be blocked"""
|
|
+
|
|
+ def get_exec_module():
|
|
+ import os
|
|
+
|
|
+ return os
|
|
+
|
|
+ s = SimpleEval(names={}, functions={"get_os": get_exec_module})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("get_os().__name__")
|
|
+
|
|
+ def test_compound_module_submodule_access(self):
|
|
+ """Accessing submodules of a passed module should be blocked"""
|
|
+ import os.path
|
|
+
|
|
+ s = SimpleEval(names={"path": os.path})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("path.os")
|
|
+
|
|
+ def test_forbidden_func_via_class_method(self):
|
|
+ """Accessing forbidden functions via class methods should be
|
|
+ blocked"""
|
|
+
|
|
+ class Container:
|
|
+ @staticmethod
|
|
+ def get_exec():
|
|
+ return exec
|
|
+
|
|
+ s = SimpleEval(names={"c": Container()})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("c.get_exec()('exit')")
|
|
+
|
|
+ def test_module_via_class_method(self):
|
|
+ """Accessing modules via class methods should be blocked"""
|
|
+ import os
|
|
+
|
|
+ class Container:
|
|
+ @staticmethod
|
|
+ def get_os():
|
|
+ return os
|
|
+
|
|
+ s = SimpleEval(names={"c": Container()})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("c.get_os().popen('id').read()")
|
|
+
|
|
+ def test_forbidden_func_via_property(self):
|
|
+ """Accessing forbidden functions via properties should be
|
|
+ blocked"""
|
|
+
|
|
+ class Container:
|
|
+ @property
|
|
+ def evil(self):
|
|
+ return exec
|
|
+
|
|
+ s = SimpleEval(names={"c": Container()})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("c.evil('exit')")
|
|
+
|
|
+ def test_module_via_property(self):
|
|
+ """Accessing modules via properties should be blocked"""
|
|
+ import os
|
|
+
|
|
+ class Container:
|
|
+ @property
|
|
+ def mod(self):
|
|
+ return os
|
|
+
|
|
+ s = SimpleEval(names={"c": Container()})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("c.mod.popen('id').read()")
|
|
+
|
|
+ def test_forbidden_function_direct_from_names(self):
|
|
+ """Forbidden functions passed directly in names should
|
|
+ be blocked when accessed"""
|
|
+ s = SimpleEval(names={"evil": exec})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("evil")
|
|
+
|
|
+ def test_module_direct_from_names(self):
|
|
+ """Modules passed directly in names should be blocked
|
|
+ when accessed"""
|
|
+ import os
|
|
+
|
|
+ s = SimpleEval(names={"m": os})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("m")
|
|
+
|
|
+ def test_forbidden_function_via_callable_name_handler(self):
|
|
+ """Forbidden functions from callable name handlers should
|
|
+ be blocked"""
|
|
+
|
|
+ def name_handler(node):
|
|
+ if node.id == "evil":
|
|
+ return exec
|
|
+ raise simpleeval.NameNotDefined(node.id, "")
|
|
+
|
|
+ s = SimpleEval(names=name_handler)
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("evil")
|
|
+
|
|
+ def test_module_via_callable_name_handler(self):
|
|
+ """Modules from callable name handlers should be blocked"""
|
|
+ import os
|
|
+
|
|
+ def name_handler(node):
|
|
+ if node.id == "m":
|
|
+ return os
|
|
+ raise simpleeval.NameNotDefined(node.id, "")
|
|
+
|
|
+ s = SimpleEval(names=name_handler)
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("m")
|
|
+
|
|
+ def test_forbidden_function_passed_to_custom_function(self):
|
|
+ """Passing forbidden functions to custom functions should be
|
|
+ blocked - they can be executed by the custom function"""
|
|
+
|
|
+ def evil_caller(func):
|
|
+ return func("print('pwned')")
|
|
+
|
|
+ s = SimpleEval(names={"evil": exec}, functions={"evil_caller": evil_caller})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("evil_caller(evil)")
|
|
+
|
|
+ def test_module_passed_to_custom_function(self):
|
|
+ """Passing modules to custom functions should be blocked - they
|
|
+ can be used by the custom function"""
|
|
+ import os
|
|
+
|
|
+ def os_caller(mod):
|
|
+ return mod.system("id")
|
|
+
|
|
+ s = SimpleEval(names={"m": os}, functions={"os_caller": os_caller})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("os_caller(m)")
|
|
+
|
|
+ def test_forbidden_function_in_list_passed_to_custom_function(self):
|
|
+ """Forbidden functions in containers passed to custom functions
|
|
+ should be blocked"""
|
|
+
|
|
+ def extract_and_call(items):
|
|
+ return items[0]("print('pwned')")
|
|
+
|
|
+ s = SimpleEval(names={"funcs": [exec, eval]}, functions={"extract": extract_and_call})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("extract(funcs)")
|
|
+
|
|
+ def test_module_in_list_passed_to_custom_function(self):
|
|
+ """Modules in containers passed to custom functions should be
|
|
+ blocked"""
|
|
+ import os
|
|
+
|
|
+ def extract_and_use(items):
|
|
+ return items[0].system("id")
|
|
+
|
|
+ s = SimpleEval(names={"mods": [os.path, os]}, functions={"extract": extract_and_use})
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("extract(mods)")
|
|
+
|
|
+ def test_forbidden_function_in_dict_passed_to_custom_function(self):
|
|
+ """Forbidden functions in dicts passed to custom functions should
|
|
+ be blocked"""
|
|
+
|
|
+ def extract_and_call(d):
|
|
+ return d["bad"]("print('pwned')")
|
|
+
|
|
+ s = SimpleEval(
|
|
+ names={"funcs": {"bad": exec, "good": print}}, functions={"extract": extract_and_call}
|
|
+ )
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("extract(funcs)")
|
|
+
|
|
+ def test_module_in_dict_passed_to_custom_function(self):
|
|
+ """Modules in dicts passed to custom functions should be blocked"""
|
|
+ import os
|
|
+
|
|
+ def extract_and_use(d):
|
|
+ return d["m"].system("id")
|
|
+
|
|
+ s = SimpleEval(
|
|
+ names={"mods": {"m": os, "p": os.path}}, functions={"extract": extract_and_use}
|
|
+ )
|
|
+
|
|
+ with self.assertRaises(FeatureNotAvailable):
|
|
+ s.eval("extract(mods)")
|
|
+
|
|
|
|
class TestCompoundTypes(DRYTest):
|
|
"""Test the compound-types edition of the library"""
|
|
--
|
|
2.35.6
|
|
|