471 lines
17 KiB
Python
471 lines
17 KiB
Python
# -*- 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 HAVE_ARGUMENT, opmap, opname
|
|
from types import CodeType
|
|
|
|
import werkzeug
|
|
from psycopg2 import OperationalError
|
|
|
|
from .misc import ustr
|
|
|
|
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',
|
|
])) - _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',
|
|
])) - _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',
|
|
|
|
])) - _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('"%s" while compiling\n%r' % (ustr(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('%s: "%s" while evaluating\n%r' % (ustr(type(e)), ustr(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 = ustr(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)
|
|
datetime = wrap_module(__import__('datetime'), ['date', 'datetime', 'time', 'timedelta', 'timezone', 'tzinfo', 'MAXYEAR', 'MINYEAR'])
|
|
dateutil = wrap_module(dateutil, {
|
|
mod: getattr(dateutil, mod).__all__
|
|
for mod in mods
|
|
})
|
|
json = wrap_module(__import__('json'), ['loads', 'dumps'])
|
|
time = wrap_module(__import__('time'), ['time', 'strptime', 'strftime', 'sleep'])
|
|
pytz = wrap_module(__import__('pytz'), [
|
|
'utc', 'UTC', 'timezone',
|
|
])
|