From bf7d5c05015cdbf8af0d298f286e76e9a6abad7c Mon Sep 17 00:00:00 2001 From: Daniel Fairhead Date: Thu, 12 Mar 2026 09:37:22 +0000 Subject: [PATCH 2/4] Disallow module access & disallowed function access via attributes. CVE: CVE-2026-32640 Upstream-Status: Backport [https://github.com/danthedeckie/simpleeval/commit/1654cbf0219345f707c79664b8657be6b8d23e33] Backport Changes: - Import only Hashable from typing because simpleeval 0.9.13 does not contain the later allowed_attrs type API and its Type, Dict, Set, and Union imports. - Adapt TestDisallowedFunctions for 0.9.13's Python-2-compatible DISALLOWED setup: remove its two PYTHON3/dynamic-exec blocks; the upstream baseline already listed exec directly. (cherry picked from commit 1654cbf0219345f707c79664b8657be6b8d23e33) Signed-off-by: Hetvi Thakar --- simpleeval.py | 31 +++++++++++++------ test_simpleeval.py | 77 ++++++++++++++++++++++++++++++---------------- 2 files changed, 73 insertions(+), 35 deletions(-) diff --git a/simpleeval.py b/simpleeval.py index 91ed582..69aed83 100644 --- a/simpleeval.py +++ b/simpleeval.py @@ -100,8 +100,10 @@ import ast import operator as op import os import sys +import types import warnings from random import random +from typing import Hashable PYTHON3 = sys.version_info[0] == 3 PYTHON35 = PYTHON3 and sys.version_info > (3, 5) @@ -240,6 +242,10 @@ class MultipleExpressions(UserWarning): pass +# Sentinal used during attr access +_ATTR_NOT_FOUND = object() + + ######################################## # Default simple functions to include: @@ -603,18 +609,25 @@ class SimpleEval(object): # pylint: disable=too-few-public-methods # eval node node_evaluated = self._eval(node.value) + item = _ATTR_NOT_FOUND + # Maybe the base object is an actual object, not just a dict try: - return getattr(node_evaluated, node.attr) + item = getattr(node_evaluated, node.attr) except (AttributeError, TypeError): - pass - - # TODO: is this a good idea? Try and look for [x] if .x doesn't work? - if self.ATTR_INDEX_FALLBACK: - try: - return node_evaluated[node.attr] - except (KeyError, TypeError): - pass + # TODO: is this a good idea? Try and look for [x] if .x doesn't work? + if self.ATTR_INDEX_FALLBACK: + try: + item = node_evaluated[node.attr] + except (KeyError, TypeError): + pass + + if item is not _ATTR_NOT_FOUND: + if isinstance(item, types.ModuleType): + raise FeatureNotAvailable("Sorry, modules are not allowed in attribute access") + if isinstance(item, Hashable) and item in DISALLOW_FUNCTIONS: + raise FeatureNotAvailable("This function is forbidden") + return item # If it is neither, raise an exception raise AttributeDoesNotExist(node.attr, self.expr) diff --git a/test_simpleeval.py b/test_simpleeval.py index bc83d50..c60ec51 100644 --- a/test_simpleeval.py +++ b/test_simpleeval.py @@ -590,6 +590,34 @@ class TestTryingToBreakOut(DRYTest): simpleeval.DISALLOW_PREFIXES = dis + def test_breakout_via_module_access(self): + import os.path + + s = SimpleEval(names={"path": os.path}) + + with self.assertRaises(FeatureNotAvailable): + s.eval("path.os.popen('id').read()") + + def test_breakout_via_module_access_attr(self): + import os.path + + class Foo: + p = os.path + + s = SimpleEval(names={"thing": Foo()}) + + with self.assertRaises(FeatureNotAvailable): + s.eval("thing.p.os.popen('id').read()") + + def test_breakout_via_disallowed_functions_as_attrs(self): + class Foo: + p = exec + + s = SimpleEval(names={"thing": Foo()}) + + with self.assertRaises(FeatureNotAvailable): + s.eval("thing.p('exit')") + class TestCompoundTypes(DRYTest): """Test the compound-types edition of the library""" @@ -1199,40 +1227,37 @@ class TestShortCircuiting(DRYTest): class TestDisallowedFunctions(DRYTest): - def test_functions_are_disallowed_at_init(self): - DISALLOWED = [type, isinstance, eval, getattr, setattr, help, repr, compile, open] - if simpleeval.PYTHON3: - # pylint: disable=exec-used - exec("DISALLOWED.append(exec)") # exec is not a function in Python2... - - for f in simpleeval.DISALLOW_FUNCTIONS: - assert f in DISALLOWED + def test_functions_in_disallowed_functions_list(self): + # a bit of double-entry testing. probably pointless. + assert simpleeval.DISALLOW_FUNCTIONS.issuperset( + { + type, + isinstance, + eval, + getattr, + setattr, + help, + repr, + compile, + open, + exec, + os.popen, + os.system, + } + ) - for x in DISALLOWED: + def test_functions_are_disallowed_at_init(self): + for dangerous_function in simpleeval.DISALLOW_FUNCTIONS: with self.assertRaises(FeatureNotAvailable): - SimpleEval(functions={"foo": x}) + SimpleEval(functions={"foo": dangerous_function}) def test_functions_are_disallowed_in_expressions(self): - DISALLOWED = [type, isinstance, eval, getattr, setattr, help, repr, compile, open] - - if simpleeval.PYTHON3: - # pylint: disable=exec-used - exec("DISALLOWED.append(exec)") # exec is not a function in Python2... - - for f in simpleeval.DISALLOW_FUNCTIONS: - assert f in DISALLOWED - - DF = simpleeval.DEFAULT_FUNCTIONS.copy() - - for x in DISALLOWED: - simpleeval.DEFAULT_FUNCTIONS = DF.copy() + for dangerous_function in simpleeval.DISALLOW_FUNCTIONS: with self.assertRaises(FeatureNotAvailable): s = SimpleEval() - s.functions["foo"] = x + s.functions["foo"] = dangerous_function s.eval("foo(42)") - simpleeval.DEFAULT_FUNCTIONS = DF.copy() - @unittest.skipIf(simpleeval.PYTHON3 is not True, "Python2 fails - but it's not supported anyway.") @unittest.skipIf(platform.python_implementation() == "PyPy", "GC set_debug not available in PyPy") -- 2.35.6