Skip to content

Commit cffa9f6

Browse files
committed
Much stricter lockdown via _check_disallowed_items plus adding ModuleWrapper
1 parent 4e7f4b8 commit cffa9f6

3 files changed

Lines changed: 612 additions & 3 deletions

File tree

README.rst

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,11 @@ A few builtin functions are listed in ``simpleeval.DISALLOW_FUNCTIONS``. ``type
443443
If you need to give access to this kind of functionality to your expressions, then be very
444444
careful. You'd be better wrapping the functions in your own safe wrappers.
445445

446+
Accessing modules as attributes is disallowed too.
447+
448+
Allowlist recommendation
449+
------------------------
450+
446451
There is an additional layer of protection you can add in by passing in ``allowed_attrs``, which
447452
makes all attribute access based opt-in rather than opt-out - which is a lot safer design:
448453

@@ -460,8 +465,8 @@ reasonably sensible defaults with BASIC_ALLOWED_ATTRS:
460465
461466
is fine - ``strip()`` should be safe on strings.
462467

463-
It is recommended to add ``allowed_attrs=BASIC_ALLOWED_ATTRS`` whenever possible, and it will
464-
be the default for 2.x.
468+
It is strongly recommended to add ``allowed_attrs=BASIC_ALLOWED_ATTRS`` whenever possible,
469+
and it will be the default for 2.x.
465470

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

@@ -482,6 +487,48 @@ You can add your own classes & limit access to attrs:
482487
483488
will now allow access to `foo.bar` but not allow anything else.
484489

490+
Module Access
491+
-------------
492+
493+
By default, module access is not allowed in simpleeval to prevent accidental or
494+
malicious access to dangerous functions. However, if you need to expose modules,
495+
(eg. `numpy` or similar) you can use ``ModuleWrapper`` to do so safely.
496+
497+
``ModuleWrapper`` allows explicit opt-in to module access while still enforcing
498+
restrictions on dangerous methods and private attributes:
499+
500+
.. code-block:: pycon
501+
502+
>>> from simpleeval import SimpleEval, ModuleWrapper
503+
>>> import os.path
504+
>>> s = SimpleEval(names={'path': ModuleWrapper(os.path)})
505+
>>> s.eval("path.exists('/etc/passwd')")
506+
True
507+
508+
You can also restrict which attributes are accessible by passing an
509+
``allowed_attrs`` set:
510+
511+
.. code-block:: pycon
512+
513+
>>> s = SimpleEval(names={
514+
... 'path': ModuleWrapper(os.path, allowed_attrs={'exists', 'join'})
515+
... })
516+
>>> s.eval("path.exists('/etc/passwd')")
517+
True
518+
>>> s.eval("path.dirname('/etc/passwd')") # Not in allowed_attrs
519+
simpleeval.FeatureNotAvailable: Access to 'dirname' is not allowed...
520+
521+
Private attributes (starting with ``_``) and methods in ``DISALLOW_METHODS``
522+
are always blocked, even if not using an allowlist:
523+
524+
.. code-block:: pycon
525+
526+
>>> s = SimpleEval(names={'path': ModuleWrapper(os.path)})
527+
>>> s.eval("path.__file__")
528+
simpleeval.FeatureNotAvailable: Access to private attribute '__file__'...
529+
530+
If you really really need that - you can make your own wrappers and overrides.
531+
But I advise against it.
485532

486533
Other...
487534
--------

simpleeval.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,50 @@ class MultipleExpressions(UserWarning):
390390
_ATTR_NOT_FOUND = object()
391391

392392

393+
class ModuleWrapper:
394+
"""Wraps a module to safely expose it in expressions.
395+
396+
By default, modules are not allowed in simpleeval names to prevent
397+
accidental or malicious access to dangerous functions. ModuleWrapper
398+
allows explicit opt-in to module access while still enforcing
399+
restrictions on dangerous methods and functions.
400+
401+
Example:
402+
>>> from simpleeval import SimpleEval, ModuleWrapper
403+
>>> import os.path
404+
>>> s = SimpleEval(names={'path': ModuleWrapper(os.path)})
405+
>>> s.eval('path.exists("/etc/passwd")') # Works
406+
"""
407+
408+
def __init__(self, module, allowed_attrs=None):
409+
"""
410+
Args:
411+
module: The module to wrap
412+
allowed_attrs: Optional set of allowed attribute names.
413+
If None, all public attributes are allowed
414+
(but still subject to DISALLOW_METHODS checks).
415+
"""
416+
if not isinstance(module, types.ModuleType):
417+
raise TypeError(f"ModuleWrapper requires a module, got {type(module)}")
418+
self._module = module
419+
self._allowed_attrs = allowed_attrs
420+
421+
def __getattr__(self, name):
422+
# Block private/magic attributes
423+
if name.startswith("_"):
424+
raise FeatureNotAvailable(f"Access to private attribute '{name}' is not allowed")
425+
426+
# Check if attribute is in disallowed methods list
427+
if name in DISALLOW_METHODS:
428+
raise FeatureNotAvailable(f"Method '{name}' is not allowed on modules")
429+
430+
# Check allowed_attrs whitelist if specified
431+
if self._allowed_attrs is not None and name not in self._allowed_attrs:
432+
raise FeatureNotAvailable(f"Access to '{name}' is not allowed on this wrapped module")
433+
434+
return getattr(self._module, name)
435+
436+
393437
########################################
394438
# Default simple functions to include:
395439

@@ -567,6 +611,28 @@ def __init__(self, operators=None, functions=None, names=None, allowed_attrs=Non
567611
def __del__(self):
568612
self.nodes = None
569613

614+
def _check_disallowed_items(self, item):
615+
"""Check if item contains disallowed functions or modules.
616+
Recursively checks containers (list, dict, tuple).
617+
Raises FeatureNotAvailable if forbidden content found.
618+
ModuleWrapper instances are allowed (explicit opt-in to module access).
619+
"""
620+
# Allow ModuleWrapper (explicit opt-in to module access)
621+
if isinstance(item, ModuleWrapper):
622+
return
623+
624+
if isinstance(item, types.ModuleType):
625+
raise FeatureNotAvailable("Sorry, modules are not allowed")
626+
if isinstance(item, Hashable) and item in DISALLOW_FUNCTIONS:
627+
raise FeatureNotAvailable("This function is forbidden")
628+
629+
if isinstance(item, (list, tuple)):
630+
for element in item:
631+
self._check_disallowed_items(element)
632+
elif isinstance(item, dict):
633+
for value in item.values():
634+
self._check_disallowed_items(value)
635+
570636
@staticmethod
571637
def parse(expr):
572638
"""parse an expression into a node tree"""
@@ -601,7 +667,9 @@ def _eval(self, node):
601667
"Sorry, {0} is not available in this evaluator".format(type(node).__name__)
602668
)
603669

604-
return handler(node)
670+
result = handler(node)
671+
self._check_disallowed_items(result)
672+
return result
605673

606674
def _eval_expr(self, node):
607675
return self._eval(node.value)

0 commit comments

Comments
 (0)