From 030eb59004303dd4b88fedd6d80b7cb3e9d261a7 Mon Sep 17 00:00:00 2001 From: Daniel Fairhead 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 --- 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