Skip to content

Commit

Permalink
Initial opt-in allowed_attrs for additional safety.
Browse files Browse the repository at this point in the history
  • Loading branch information
danthedeckie committed Oct 28, 2024
1 parent 5663fc2 commit 1beabd4
Show file tree
Hide file tree
Showing 3 changed files with 286 additions and 8 deletions.
51 changes: 47 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -432,10 +432,8 @@ version that disallows method invocation on objects:
and then use ``EvalNoMethods`` instead of the ``SimpleEval`` class.

Other...
--------

The library supports Python 3.9 and higher.
Limiting Attribute Access
-------------------------

Object attributes that start with ``_`` or ``func_`` are disallowed by default.
If you really need that (BE CAREFUL!), then modify the module global
Expand All @@ -445,6 +443,51 @@ 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.

There is an additional layer of protection you can add in by passing in ``allowed_attrs``, which
makes all attribute access based opt-in rather than opt-out - which is a lot safer design:

.. code-block:: pycon
>>> simpleeval("' hello '.strip()", allowed_attrs={})
will throw FeatureNotAvailable - as we've now disabled all attribute access. You can enable some
reasonably sensible defaults with BASIC_ALLOWED_ATTRS:

.. code-block:: pycon
>>> from simpleeval import simpleeval, BASIC_ALLOWED_ATTRS
>>> simpleeval("' hello '.strip()", allowed_attrs=BASIC_ALLOWED_ATTRS)
is fine - ``strip()`` should be safe on strings.

It is recommended to add ``allowed_attrs=BASIC_ALLOWED_ATTRS`` whenever possible, and it will
be the default for 2.x.

You can add your own classes & limit access to attrs:

.. code-block:: pycon
>>> from simpleeval import simpleeval, BASIC_ALLOWED_ATTRS
>>> class Foo:
>>> bar = 42
>>> hidden = "secret"
>>>
>>> our_attributes = BASIC_ALLOWED_ATTRS.copy()
>>> our_attributes[Foo] = {'bar'}
>>> simpleeval("foo.bar", names={"foo": Foo()}, allowed_attrs=our_attributes)
42
>>> simpleeval("foo.hidden", names={"foo": Foo()}, allowed_attrs=our_attributes)
simpleeval.FeatureNotAvailable: Sorry, 'hidden' access not allowed on 'Foo'
will now allow access to `foo.bar` but not allow anything else.


Other...
--------

The library supports Python 3.9 and higher.

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.
Expand Down
170 changes: 166 additions & 4 deletions simpleeval.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,147 @@
# PyInstaller environment doesn't include this module.
DISALLOW_FUNCTIONS.add(help)

# Opt-in type safety experiment. Will be opt-out in 2.x

BASIC_ALLOWED_ATTRS = {
int: {
"as_integer_ratio",
"bit_length",
"conjugate",
"denominator",
"from_bytes",
"imag",
"numerator",
"real",
"to_bytes",
},
float: {
"as_integer_ratio",
"conjugate",
"fromhex",
"hex",
"imag",
"is_integer",
"real",
},
str: {
"capitalize",
"casefold",
"center",
"count",
"encode",
"endswith",
"expandtabs",
"find",
"format",
"format_map",
"index",
"isalnum",
"isalpha",
"isascii",
"isdecimal",
"isdigit",
"isidentifier",
"islower",
"isnumeric",
"isprintable",
"isspace",
"istitle",
"isupper",
"join",
"ljust",
"lower",
"lstrip",
"maketrans",
"partition",
"removeprefix",
"removesuffix",
"replace",
"rfind",
"rindex",
"rjust",
"rpartition",
"rsplit",
"rstrip",
"split",
"splitlines",
"startswith",
"strip",
"swapcase",
"title",
"translate",
"upper",
"zfill",
},
bool: {
"as_integer_ratio",
"bit_length",
"conjugate",
"denominator",
"from_bytes",
"imag",
"numerator",
"real",
"to_bytes",
},
None: {},
dict: {
"clear",
"copy",
"fromkeys",
"get",
"items",
"keys",
"pop",
"popitem",
"setdefault",
"update",
"values",
},
list: {
"pop",
"append",
"index",
"reverse",
"count",
"sort",
"copy",
"extend",
"clear",
"insert",
"remove",
},
set: {
"pop",
"intersection_update",
"intersection",
"issubset",
"symmetric_difference_update",
"discard",
"isdisjoint",
"difference_update",
"issuperset",
"add",
"copy",
"union",
"clear",
"update",
"symmetric_difference",
"difference",
"remove",
},
tuple: {"index", "count"},
}


########################################
# Exceptions:


class TypeNotSpecified(Exception):
pass


class InvalidExpression(Exception):
"""Generic Exception"""

Expand Down Expand Up @@ -344,7 +480,7 @@ class SimpleEval(object): # pylint: disable=too-few-public-methods

expr = ""

def __init__(self, operators=None, functions=None, names=None):
def __init__(self, operators=None, functions=None, names=None, allowed_attrs=None):
"""
Create the evaluator instance. Set up valid operators (+,-, etc)
functions (add, random, get_val, whatever) and names."""
Expand All @@ -359,6 +495,7 @@ def __init__(self, operators=None, functions=None, names=None):
self.operators = operators
self.functions = functions
self.names = names
self.allowed_attrs = allowed_attrs

self.nodes = {
ast.Expr: self._eval_expr,
Expand Down Expand Up @@ -587,6 +724,8 @@ def _eval_subscript(self, node):
return container[key]

def _eval_attribute(self, node):
# DISALLOW_PREFIXES & DISALLOW_METHODS are global, there's never any access to
# attrs with these names, so we can bail early:
for prefix in DISALLOW_PREFIXES:
if node.attr.startswith(prefix):
raise FeatureNotAvailable(
Expand All @@ -598,9 +737,27 @@ def _eval_attribute(self, node):
raise FeatureNotAvailable(
"Sorry, this method is not available. " "({0})".format(node.attr)
)
# eval node

# Evaluate "node" - the thing that we're trying to access an attr of first:
node_evaluated = self._eval(node.value)

# If we've opted in to the 'allowed_attrs' checking per type, then since we now
# know what kind of node we've got, we can check if we're permitted to access this
# attr name on this node:
if self.allowed_attrs is not None:
type_to_check = type(node_evaluated)

allowed_attrs = self.allowed_attrs.get(type_to_check, TypeNotSpecified)
if allowed_attrs == TypeNotSpecified:
raise FeatureNotAvailable(
f"Sorry, attribute access not allowed on '{type_to_check}'"
f" (attempted to access `.{node.attr}`)"
)
if node.attr not in allowed_attrs:
raise FeatureNotAvailable(
f"Sorry, '.{node.attr}' access not allowed on '{type_to_check}'"
)

# Maybe the base object is an actual object, not just a dict
try:
return getattr(node_evaluated, node.attr)
Expand Down Expand Up @@ -762,7 +919,12 @@ def do_generator(gi=0):
return to_return


def simple_eval(expr, operators=None, functions=None, names=None):
def simple_eval(expr, operators=None, functions=None, names=None, allowed_attrs=None):
"""Simply evaluate an expresssion"""
s = SimpleEval(operators=operators, functions=functions, names=names)
s = SimpleEval(
operators=operators,
functions=functions,
names=names,
allowed_attrs=allowed_attrs,
)
return s.eval(expr)
73 changes: 73 additions & 0 deletions test_simpleeval.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import simpleeval
from simpleeval import (
BASIC_ALLOWED_ATTRS,
AttributeDoesNotExist,
EvalWithCompoundTypes,
FeatureNotAvailable,
Expand Down Expand Up @@ -1351,5 +1352,77 @@ def test_no_operators(self):
s.eval("~ 2")


class TestAllowedAttributes(DRYTest):
def setUp(self):
self.saved_disallow_methods = simpleeval.DISALLOW_METHODS
simpleeval.DISALLOW_METHODS = []
super().setUp()

def tearDown(self) -> None:
simpleeval.DISALLOW_METHODS = self.saved_disallow_methods
return super().tearDown()

def test_allowed_attrs_(self):
self.s.allowed_attrs = BASIC_ALLOWED_ATTRS
self.t("5 + 5", 10)
self.t('" hello ".strip()', "hello")

def test_allowed_extra_attr(self):
class Foo:
def bar(self):
return 42

assert Foo().bar() == 42

extended_attrs = BASIC_ALLOWED_ATTRS.copy()
extended_attrs[Foo] = {"bar"}

simple_eval("foo.bar()", names={"foo": Foo()}, allowed_attrs=extended_attrs)

def test_disallowed_extra_attr(self):
class Foo:
bar = 42
hidden = 100

assert Foo().bar == 42

extended_attrs = BASIC_ALLOWED_ATTRS.copy()
extended_attrs[Foo] = {"bar"}

self.assertEqual(
simple_eval("foo.bar", names={"foo": Foo()}, allowed_attrs=extended_attrs), 42
)
with self.assertRaisesRegex(FeatureNotAvailable, r".*'\.hidden' access not allowed.*"):
self.assertEqual(
simple_eval("foo.hidden", names={"foo": Foo()}, allowed_attrs=extended_attrs), 42
)

def test_disallowed_types(self):
class Foo:
bar = 42

assert Foo().bar == 42

with self.assertRaises(FeatureNotAvailable):
simple_eval("foo.bar", names={"foo": Foo()}, allowed_attrs=BASIC_ALLOWED_ATTRS)

def test_breakout_via_generator(self):
# Thanks decorator-factory
class Foo:
def bar(self):
yield "Hello, world!"

# Test the generator does work - also adds the `yield` to codecov...
assert list(Foo().bar()) == ["Hello, world!"]

evil = "foo.bar().gi_frame.f_globals['__builtins__'].exec('raise RuntimeError(\"Oh no\")')"

extended_attrs = BASIC_ALLOWED_ATTRS.copy()
extended_attrs[Foo] = {"bar"}

with self.assertRaisesRegex(FeatureNotAvailable, r".*attempted to access `\.gi_frame`.*"):
simple_eval(evil, names={"foo": Foo()}, allowed_attrs=extended_attrs)


if __name__ == "__main__": # pragma: no cover
unittest.main()

0 comments on commit 1beabd4

Please sign in to comment.