|
- # -*- coding: utf-8 -*-
- # Part of Odoo. See LICENSE file for full copyright and licensing details.
-
- """
- safe_eval module - methods intended to provide more restricted alternatives to
- evaluate simple and/or untrusted code.
-
- Methods in this module are typically used as alternatives to eval() to parse
- OpenERP domain strings, conditions and expressions, mostly based on locals
- condition/math builtins.
- """
-
- # Module partially ripped from/inspired by several different sources:
- # - http://code.activestate.com/recipes/286134/
- # - safe_eval in lp:~xrg/openobject-server/optimize-5.0
- # - safe_eval in tryton http://hg.tryton.org/hgwebdir.cgi/trytond/rev/bbb5f73319ad
- import dis
- import functools
- import logging
- import sys
- import types
- from opcode import opmap, opname
- from types import CodeType
-
- import werkzeug
- from psycopg2 import OperationalError
-
- import odoo
-
- unsafe_eval = eval
-
- __all__ = ['test_expr', 'safe_eval', 'const_eval']
-
- # The time module is usually already provided in the safe_eval environment
- # but some code, e.g. datetime.datetime.now() (Windows/Python 2.5.2, bug
- # lp:703841), does import time.
- _ALLOWED_MODULES = ['_strptime', 'math', 'time']
-
- # Mock __import__ function, as called by cpython's import emulator `PyImport_Import` inside
- # timemodule.c, _datetimemodule.c and others.
- # This function does not actually need to do anything, its expected side-effect is to make the
- # imported module available in `sys.modules`. The _ALLOWED_MODULES are imported below to make it so.
- def _import(name, globals=None, locals=None, fromlist=None, level=-1):
- if name not in sys.modules:
- raise ImportError(f'module {name} should be imported before calling safe_eval()')
-
- for module in _ALLOWED_MODULES:
- __import__(module)
-
-
- _UNSAFE_ATTRIBUTES = [
- # Frames
- 'f_builtins', 'f_code', 'f_globals', 'f_locals',
- # Python 2 functions
- 'func_code', 'func_globals',
- # Code object
- 'co_code', '_co_code_adaptive',
- # Method resolution order,
- 'mro',
- # Tracebacks
- 'tb_frame',
- # Generators
- 'gi_code', 'gi_frame', 'g_yieldfrom'
- # Coroutines
- 'cr_await', 'cr_code', 'cr_frame',
- # Coroutine generators
- 'ag_await', 'ag_code', 'ag_frame',
- ]
-
-
- def to_opcodes(opnames, _opmap=opmap):
- for x in opnames:
- if x in _opmap:
- yield _opmap[x]
- # opcodes which absolutely positively must not be usable in safe_eval,
- # explicitly subtracted from all sets of valid opcodes just in case
- _BLACKLIST = set(to_opcodes([
- # can't provide access to accessing arbitrary modules
- 'IMPORT_STAR', 'IMPORT_NAME', 'IMPORT_FROM',
- # could allow replacing or updating core attributes on models & al, setitem
- # can be used to set field values
- 'STORE_ATTR', 'DELETE_ATTR',
- # no reason to allow this
- 'STORE_GLOBAL', 'DELETE_GLOBAL',
- ]))
- # opcodes necessary to build literal values
- _CONST_OPCODES = set(to_opcodes([
- # stack manipulations
- 'POP_TOP', 'ROT_TWO', 'ROT_THREE', 'ROT_FOUR', 'DUP_TOP', 'DUP_TOP_TWO',
- 'LOAD_CONST',
- 'RETURN_VALUE', # return the result of the literal/expr evaluation
- # literal collections
- 'BUILD_LIST', 'BUILD_MAP', 'BUILD_TUPLE', 'BUILD_SET',
- # 3.6: literal map with constant keys https://bugs.python.org/issue27140
- 'BUILD_CONST_KEY_MAP',
- 'LIST_EXTEND', 'SET_UPDATE',
- # 3.11 replace DUP_TOP, DUP_TOP_TWO, ROT_TWO, ROT_THREE, ROT_FOUR
- 'COPY', 'SWAP',
- # Added in 3.11 https://docs.python.org/3/whatsnew/3.11.html#new-opcodes
- 'RESUME',
- # 3.12 https://docs.python.org/3/whatsnew/3.12.html#cpython-bytecode-changes
- 'RETURN_CONST',
- # 3.13
- 'TO_BOOL',
- ])) - _BLACKLIST
-
- # operations which are both binary and inplace, same order as in doc'
- _operations = [
- 'POWER', 'MULTIPLY', # 'MATRIX_MULTIPLY', # matrix operator (3.5+)
- 'FLOOR_DIVIDE', 'TRUE_DIVIDE', 'MODULO', 'ADD',
- 'SUBTRACT', 'LSHIFT', 'RSHIFT', 'AND', 'XOR', 'OR',
- ]
- # operations on literal values
- _EXPR_OPCODES = _CONST_OPCODES.union(to_opcodes([
- 'UNARY_POSITIVE', 'UNARY_NEGATIVE', 'UNARY_NOT', 'UNARY_INVERT',
- *('BINARY_' + op for op in _operations), 'BINARY_SUBSCR',
- *('INPLACE_' + op for op in _operations),
- 'BUILD_SLICE',
- # comprehensions
- 'LIST_APPEND', 'MAP_ADD', 'SET_ADD',
- 'COMPARE_OP',
- # specialised comparisons
- 'IS_OP', 'CONTAINS_OP',
- 'DICT_MERGE', 'DICT_UPDATE',
- # Basically used in any "generator literal"
- 'GEN_START', # added in 3.10 but already removed from 3.11.
- # Added in 3.11, replacing all BINARY_* and INPLACE_*
- 'BINARY_OP',
- 'BINARY_SLICE',
- ])) - _BLACKLIST
-
- _SAFE_OPCODES = _EXPR_OPCODES.union(to_opcodes([
- 'POP_BLOCK', 'POP_EXCEPT',
-
- # note: removed in 3.8
- 'SETUP_LOOP', 'SETUP_EXCEPT', 'BREAK_LOOP', 'CONTINUE_LOOP',
-
- 'EXTENDED_ARG', # P3.6 for long jump offsets.
- 'MAKE_FUNCTION', 'CALL_FUNCTION', 'CALL_FUNCTION_KW', 'CALL_FUNCTION_EX',
- # Added in P3.7 https://bugs.python.org/issue26110
- 'CALL_METHOD', 'LOAD_METHOD',
-
- 'GET_ITER', 'FOR_ITER', 'YIELD_VALUE',
- 'JUMP_FORWARD', 'JUMP_ABSOLUTE', 'JUMP_BACKWARD',
- 'JUMP_IF_FALSE_OR_POP', 'JUMP_IF_TRUE_OR_POP', 'POP_JUMP_IF_FALSE', 'POP_JUMP_IF_TRUE',
- 'SETUP_FINALLY', 'END_FINALLY',
- # Added in 3.8 https://bugs.python.org/issue17611
- 'BEGIN_FINALLY', 'CALL_FINALLY', 'POP_FINALLY',
-
- 'RAISE_VARARGS', 'LOAD_NAME', 'STORE_NAME', 'DELETE_NAME', 'LOAD_ATTR',
- 'LOAD_FAST', 'STORE_FAST', 'DELETE_FAST', 'UNPACK_SEQUENCE',
- 'STORE_SUBSCR',
- 'LOAD_GLOBAL',
-
- 'RERAISE', 'JUMP_IF_NOT_EXC_MATCH',
-
- # Following opcodes were Added in 3.11
- # replacement of opcodes CALL_FUNCTION, CALL_FUNCTION_KW, CALL_METHOD
- 'PUSH_NULL', 'PRECALL', 'CALL', 'KW_NAMES',
- # replacement of POP_JUMP_IF_TRUE and POP_JUMP_IF_FALSE
- 'POP_JUMP_FORWARD_IF_FALSE', 'POP_JUMP_FORWARD_IF_TRUE',
- 'POP_JUMP_BACKWARD_IF_FALSE', 'POP_JUMP_BACKWARD_IF_TRUE',
- # special case of the previous for IS NONE / IS NOT NONE
- 'POP_JUMP_FORWARD_IF_NONE', 'POP_JUMP_BACKWARD_IF_NONE',
- 'POP_JUMP_FORWARD_IF_NOT_NONE', 'POP_JUMP_BACKWARD_IF_NOT_NONE',
- # replacement of JUMP_IF_NOT_EXC_MATCH
- 'CHECK_EXC_MATCH',
- # new opcodes
- 'RETURN_GENERATOR',
- 'PUSH_EXC_INFO',
- 'NOP',
- 'FORMAT_VALUE', 'BUILD_STRING',
- # 3.12 https://docs.python.org/3/whatsnew/3.12.html#cpython-bytecode-changes
- 'END_FOR',
- 'LOAD_FAST_AND_CLEAR', 'LOAD_FAST_CHECK',
- 'POP_JUMP_IF_NOT_NONE', 'POP_JUMP_IF_NONE',
- 'CALL_INTRINSIC_1',
- 'STORE_SLICE',
- # 3.13
- 'CALL_KW', 'LOAD_FAST_LOAD_FAST',
- 'STORE_FAST_STORE_FAST', 'STORE_FAST_LOAD_FAST',
- 'CONVERT_VALUE', 'FORMAT_SIMPLE', 'FORMAT_WITH_SPEC',
- 'SET_FUNCTION_ATTRIBUTE',
- ])) - _BLACKLIST
-
-
- _logger = logging.getLogger(__name__)
-
- def assert_no_dunder_name(code_obj, expr):
- """ assert_no_dunder_name(code_obj, expr) -> None
-
- Asserts that the code object does not refer to any "dunder name"
- (__$name__), so that safe_eval prevents access to any internal-ish Python
- attribute or method (both are loaded via LOAD_ATTR which uses a name, not a
- const or a var).
-
- Checks that no such name exists in the provided code object (co_names).
-
- :param code_obj: code object to name-validate
- :type code_obj: CodeType
- :param str expr: expression corresponding to the code object, for debugging
- purposes
- :raises NameError: in case a forbidden name (containing two underscores)
- is found in ``code_obj``
-
- .. note:: actually forbids every name containing 2 underscores
- """
- for name in code_obj.co_names:
- if "__" in name or name in _UNSAFE_ATTRIBUTES:
- raise NameError('Access to forbidden name %r (%r)' % (name, expr))
-
- def assert_valid_codeobj(allowed_codes, code_obj, expr):
- """ Asserts that the provided code object validates against the bytecode
- and name constraints.
-
- Recursively validates the code objects stored in its co_consts in case
- lambdas are being created/used (lambdas generate their own separated code
- objects and don't live in the root one)
-
- :param allowed_codes: list of permissible bytecode instructions
- :type allowed_codes: set(int)
- :param code_obj: code object to name-validate
- :type code_obj: CodeType
- :param str expr: expression corresponding to the code object, for debugging
- purposes
- :raises ValueError: in case of forbidden bytecode in ``code_obj``
- :raises NameError: in case a forbidden name (containing two underscores)
- is found in ``code_obj``
- """
- assert_no_dunder_name(code_obj, expr)
-
- # set operations are almost twice as fast as a manual iteration + condition
- # when loading /web according to line_profiler
- code_codes = {i.opcode for i in dis.get_instructions(code_obj)}
- if not allowed_codes >= code_codes:
- raise ValueError("forbidden opcode(s) in %r: %s" % (expr, ', '.join(opname[x] for x in (code_codes - allowed_codes))))
-
- for const in code_obj.co_consts:
- if isinstance(const, CodeType):
- assert_valid_codeobj(allowed_codes, const, 'lambda')
-
- def test_expr(expr, allowed_codes, mode="eval", filename=None):
- """test_expr(expression, allowed_codes[, mode[, filename]]) -> code_object
-
- Test that the expression contains only the allowed opcodes.
- If the expression is valid and contains only allowed codes,
- return the compiled code object.
- Otherwise raise a ValueError, a Syntax Error or TypeError accordingly.
-
- :param filename: optional pseudo-filename for the compiled expression,
- displayed for example in traceback frames
- :type filename: string
- """
- try:
- if mode == 'eval':
- # eval() does not like leading/trailing whitespace
- expr = expr.strip()
- code_obj = compile(expr, filename or "", mode)
- except (SyntaxError, TypeError, ValueError):
- raise
- except Exception as e:
- raise ValueError('%r while compiling\n%r' % (e, expr))
- assert_valid_codeobj(allowed_codes, code_obj, expr)
- return code_obj
-
-
- def const_eval(expr):
- """const_eval(expression) -> value
-
- Safe Python constant evaluation
-
- Evaluates a string that contains an expression describing
- a Python constant. Strings that are not valid Python expressions
- or that contain other code besides the constant raise ValueError.
-
- >>> const_eval("10")
- 10
- >>> const_eval("[1,2, (3,4), {'foo':'bar'}]")
- [1, 2, (3, 4), {'foo': 'bar'}]
- >>> const_eval("1+2")
- Traceback (most recent call last):
- ...
- ValueError: opcode BINARY_ADD not allowed
- """
- c = test_expr(expr, _CONST_OPCODES)
- return unsafe_eval(c)
-
- def expr_eval(expr):
- """expr_eval(expression) -> value
-
- Restricted Python expression evaluation
-
- Evaluates a string that contains an expression that only
- uses Python constants. This can be used to e.g. evaluate
- a numerical expression from an untrusted source.
-
- >>> expr_eval("1+2")
- 3
- >>> expr_eval("[1,2]*2")
- [1, 2, 1, 2]
- >>> expr_eval("__import__('sys').modules")
- Traceback (most recent call last):
- ...
- ValueError: opcode LOAD_NAME not allowed
- """
- c = test_expr(expr, _EXPR_OPCODES)
- return unsafe_eval(c)
-
- _BUILTINS = {
- '__import__': _import,
- 'True': True,
- 'False': False,
- 'None': None,
- 'bytes': bytes,
- 'str': str,
- 'unicode': str,
- 'bool': bool,
- 'int': int,
- 'float': float,
- 'enumerate': enumerate,
- 'dict': dict,
- 'list': list,
- 'tuple': tuple,
- 'map': map,
- 'abs': abs,
- 'min': min,
- 'max': max,
- 'sum': sum,
- 'reduce': functools.reduce,
- 'filter': filter,
- 'sorted': sorted,
- 'round': round,
- 'len': len,
- 'repr': repr,
- 'set': set,
- 'all': all,
- 'any': any,
- 'ord': ord,
- 'chr': chr,
- 'divmod': divmod,
- 'isinstance': isinstance,
- 'range': range,
- 'xrange': range,
- 'zip': zip,
- 'Exception': Exception,
- }
- def safe_eval(expr, globals_dict=None, locals_dict=None, mode="eval", nocopy=False, locals_builtins=False, filename=None):
- """safe_eval(expression[, globals[, locals[, mode[, nocopy]]]]) -> result
-
- System-restricted Python expression evaluation
-
- Evaluates a string that contains an expression that mostly
- uses Python constants, arithmetic expressions and the
- objects directly provided in context.
-
- This can be used to e.g. evaluate
- an OpenERP domain expression from an untrusted source.
-
- :param filename: optional pseudo-filename for the compiled expression,
- displayed for example in traceback frames
- :type filename: string
- :throws TypeError: If the expression provided is a code object
- :throws SyntaxError: If the expression provided is not valid Python
- :throws NameError: If the expression provided accesses forbidden names
- :throws ValueError: If the expression provided uses forbidden bytecode
- """
- if type(expr) is CodeType:
- raise TypeError("safe_eval does not allow direct evaluation of code objects.")
-
- # prevent altering the globals/locals from within the sandbox
- # by taking a copy.
- if not nocopy:
- # isinstance() does not work below, we want *exactly* the dict class
- if (globals_dict is not None and type(globals_dict) is not dict) \
- or (locals_dict is not None and type(locals_dict) is not dict):
- _logger.warning(
- "Looks like you are trying to pass a dynamic environment, "
- "you should probably pass nocopy=True to safe_eval().")
- if globals_dict is not None:
- globals_dict = dict(globals_dict)
- if locals_dict is not None:
- locals_dict = dict(locals_dict)
-
- check_values(globals_dict)
- check_values(locals_dict)
-
- if globals_dict is None:
- globals_dict = {}
-
- globals_dict['__builtins__'] = _BUILTINS
- if locals_builtins:
- if locals_dict is None:
- locals_dict = {}
- locals_dict.update(_BUILTINS)
- c = test_expr(expr, _SAFE_OPCODES, mode=mode, filename=filename)
- try:
- return unsafe_eval(c, globals_dict, locals_dict)
- except odoo.exceptions.UserError:
- raise
- except odoo.exceptions.RedirectWarning:
- raise
- except werkzeug.exceptions.HTTPException:
- raise
- except OperationalError:
- # Do not hide PostgreSQL low-level exceptions, to let the auto-replay
- # of serialized transactions work its magic
- raise
- except ZeroDivisionError:
- raise
- except Exception as e:
- raise ValueError('%r while evaluating\n%r' % (e, expr))
- def test_python_expr(expr, mode="eval"):
- try:
- test_expr(expr, _SAFE_OPCODES, mode=mode)
- except (SyntaxError, TypeError, ValueError) as err:
- if len(err.args) >= 2 and len(err.args[1]) >= 4:
- error = {
- 'message': err.args[0],
- 'filename': err.args[1][0],
- 'lineno': err.args[1][1],
- 'offset': err.args[1][2],
- 'error_line': err.args[1][3],
- }
- msg = "%s : %s at line %d\n%s" % (type(err).__name__, error['message'], error['lineno'], error['error_line'])
- else:
- msg = str(err)
- return msg
- return False
-
-
- def check_values(d):
- if not d:
- return d
- for v in d.values():
- if isinstance(v, types.ModuleType):
- raise TypeError(f"""Module {v} can not be used in evaluation contexts
-
- Prefer providing only the items necessary for your intended use.
-
- If a "module" is necessary for backwards compatibility, use
- `odoo.tools.safe_eval.wrap_module` to generate a wrapper recursively
- whitelisting allowed attributes.
-
- Pre-wrapped modules are provided as attributes of `odoo.tools.safe_eval`.
- """)
- return d
-
- class wrap_module:
- def __init__(self, module, attributes):
- """Helper for wrapping a package/module to expose selected attributes
-
- :param module: the actual package/module to wrap, as returned by ``import <module>``
- :param iterable attributes: attributes to expose / whitelist. If a dict,
- the keys are the attributes and the values
- are used as an ``attributes`` in case the
- corresponding item is a submodule
- """
- # builtin modules don't have a __file__ at all
- modfile = getattr(module, '__file__', '(built-in)')
- self._repr = f"<wrapped {module.__name__!r} ({modfile})>"
- for attrib in attributes:
- target = getattr(module, attrib)
- if isinstance(target, types.ModuleType):
- target = wrap_module(target, attributes[attrib])
- setattr(self, attrib, target)
-
- def __repr__(self):
- return self._repr
-
- # dateutil submodules are lazy so need to import them for them to "exist"
- import dateutil
- mods = ['parser', 'relativedelta', 'rrule', 'tz']
- for mod in mods:
- __import__('dateutil.%s' % mod)
- # make sure to patch pytz before exposing
- from odoo._monkeypatches.pytz import patch_pytz # noqa: E402, F401
- patch_pytz()
-
- datetime = wrap_module(__import__('datetime'), ['date', 'datetime', 'time', 'timedelta', 'timezone', 'tzinfo', 'MAXYEAR', 'MINYEAR'])
- dateutil = wrap_module(dateutil, {
- "tz": ["UTC", "tzutc"],
- "parser": ["isoparse", "parse"],
- "relativedelta": ["relativedelta", "MO", "TU", "WE", "TH", "FR", "SA", "SU"],
- "rrule": ["rrule", "rruleset", "rrulestr", "YEARLY", "MONTHLY", "WEEKLY", "DAILY", "HOURLY", "MINUTELY", "SECONDLY", "MO", "TU", "WE", "TH", "FR", "SA", "SU"],
- })
- json = wrap_module(__import__('json'), ['loads', 'dumps'])
- time = wrap_module(__import__('time'), ['time', 'strptime', 'strftime', 'sleep'])
- pytz = wrap_module(__import__('pytz'), [
- 'utc', 'UTC', 'timezone',
- ])
- dateutil.tz.gettz = pytz.timezone
|