Coverage for adhoc-cicd-odoo-odoo / odoo / tools / safe_eval.py: 79%
121 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
4"""
5safe_eval module - methods intended to provide more restricted alternatives to
6 evaluate simple and/or untrusted code.
8Methods in this module are typically used as alternatives to eval() to parse
9OpenERP domain strings, conditions and expressions, mostly based on locals
10condition/math builtins.
11"""
13# Module partially ripped from/inspired by several different sources:
14# - http://code.activestate.com/recipes/286134/
15# - safe_eval in lp:~xrg/openobject-server/optimize-5.0
16# - safe_eval in tryton http://hg.tryton.org/hgwebdir.cgi/trytond/rev/bbb5f73319ad
17import dis
18import functools
19import logging
20import sys
21import types
22import typing
23from opcode import opmap, opname
24from types import CodeType
26import werkzeug
27from psycopg2 import OperationalError
29import odoo.exceptions
31unsafe_eval = eval
33__all__ = ['const_eval', 'safe_eval']
35# The time module is usually already provided in the safe_eval environment
36# but some code, e.g. datetime.datetime.now() (Windows/Python 2.5.2, bug
37# lp:703841), does import time.
38_ALLOWED_MODULES = ['_strptime', 'math', 'time']
40# Mock __import__ function, as called by cpython's import emulator `PyImport_Import` inside
41# timemodule.c, _datetimemodule.c and others.
42# This function does not actually need to do anything, its expected side-effect is to make the
43# imported module available in `sys.modules`. The _ALLOWED_MODULES are imported below to make it so.
44def _import(name, globals=None, locals=None, fromlist=None, level=-1):
45 if name not in sys.modules: 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true
46 raise ImportError(f'module {name} should be imported before calling safe_eval()')
48for module in _ALLOWED_MODULES:
49 __import__(module)
52_UNSAFE_ATTRIBUTES = [
53 # Frames
54 'f_builtins', 'f_code', 'f_globals', 'f_locals',
55 # Python 2 functions
56 'func_code', 'func_globals',
57 # Code object
58 'co_code', '_co_code_adaptive',
59 # Method resolution order,
60 'mro',
61 # Tracebacks
62 'tb_frame',
63 # Generators
64 'gi_code', 'gi_frame', 'gi_yieldfrom',
65 # Coroutines
66 'cr_await', 'cr_code', 'cr_frame',
67 # Coroutine generators
68 'ag_await', 'ag_code', 'ag_frame',
69]
72def to_opcodes(opnames, _opmap=opmap):
73 for x in opnames:
74 if x in _opmap:
75 yield _opmap[x]
76# opcodes which absolutely positively must not be usable in safe_eval,
77# explicitly subtracted from all sets of valid opcodes just in case
78_BLACKLIST = set(to_opcodes([
79 # can't provide access to accessing arbitrary modules
80 'IMPORT_STAR', 'IMPORT_NAME', 'IMPORT_FROM',
81 # could allow replacing or updating core attributes on models & al, setitem
82 # can be used to set field values
83 'STORE_ATTR', 'DELETE_ATTR',
84 # no reason to allow this
85 'STORE_GLOBAL', 'DELETE_GLOBAL',
86]))
87# opcodes necessary to build literal values
88_CONST_OPCODES = set(to_opcodes([
89 # stack manipulations
90 'POP_TOP', 'ROT_TWO', 'ROT_THREE', 'ROT_FOUR', 'DUP_TOP', 'DUP_TOP_TWO',
91 'LOAD_CONST',
92 'RETURN_VALUE', # return the result of the literal/expr evaluation
93 # literal collections
94 'BUILD_LIST', 'BUILD_MAP', 'BUILD_TUPLE', 'BUILD_SET',
95 # 3.6: literal map with constant keys https://bugs.python.org/issue27140
96 'BUILD_CONST_KEY_MAP',
97 'LIST_EXTEND', 'SET_UPDATE',
98 # 3.11 replace DUP_TOP, DUP_TOP_TWO, ROT_TWO, ROT_THREE, ROT_FOUR
99 'COPY', 'SWAP',
100 # Added in 3.11 https://docs.python.org/3/whatsnew/3.11.html#new-opcodes
101 'RESUME',
102 # 3.12 https://docs.python.org/3/whatsnew/3.12.html#cpython-bytecode-changes
103 'RETURN_CONST',
104 # 3.13
105 'TO_BOOL',
106])) - _BLACKLIST
108# operations which are both binary and inplace, same order as in doc'
109_operations = [
110 'POWER', 'MULTIPLY', # 'MATRIX_MULTIPLY', # matrix operator (3.5+)
111 'FLOOR_DIVIDE', 'TRUE_DIVIDE', 'MODULO', 'ADD',
112 'SUBTRACT', 'LSHIFT', 'RSHIFT', 'AND', 'XOR', 'OR',
113]
114# operations on literal values
115_EXPR_OPCODES = _CONST_OPCODES.union(to_opcodes([
116 'UNARY_POSITIVE', 'UNARY_NEGATIVE', 'UNARY_NOT', 'UNARY_INVERT',
117 *('BINARY_' + op for op in _operations), 'BINARY_SUBSCR',
118 *('INPLACE_' + op for op in _operations),
119 'BUILD_SLICE',
120 # comprehensions
121 'LIST_APPEND', 'MAP_ADD', 'SET_ADD',
122 'COMPARE_OP',
123 # specialised comparisons
124 'IS_OP', 'CONTAINS_OP',
125 'DICT_MERGE', 'DICT_UPDATE',
126 # Basically used in any "generator literal"
127 'GEN_START', # added in 3.10 but already removed from 3.11.
128 # Added in 3.11, replacing all BINARY_* and INPLACE_*
129 'BINARY_OP',
130 'BINARY_SLICE',
131])) - _BLACKLIST
133_SAFE_OPCODES = _EXPR_OPCODES.union(to_opcodes([
134 'POP_BLOCK', 'POP_EXCEPT',
136 # note: removed in 3.8
137 'SETUP_LOOP', 'SETUP_EXCEPT', 'BREAK_LOOP', 'CONTINUE_LOOP',
139 'EXTENDED_ARG', # P3.6 for long jump offsets.
140 'MAKE_FUNCTION', 'CALL_FUNCTION', 'CALL_FUNCTION_KW', 'CALL_FUNCTION_EX',
141 # Added in P3.7 https://bugs.python.org/issue26110
142 'CALL_METHOD', 'LOAD_METHOD',
144 'GET_ITER', 'FOR_ITER', 'YIELD_VALUE',
145 'JUMP_FORWARD', 'JUMP_ABSOLUTE', 'JUMP_BACKWARD',
146 'JUMP_IF_FALSE_OR_POP', 'JUMP_IF_TRUE_OR_POP', 'POP_JUMP_IF_FALSE', 'POP_JUMP_IF_TRUE',
147 'SETUP_FINALLY', 'END_FINALLY',
148 # Added in 3.8 https://bugs.python.org/issue17611
149 'BEGIN_FINALLY', 'CALL_FINALLY', 'POP_FINALLY',
151 'RAISE_VARARGS', 'LOAD_NAME', 'STORE_NAME', 'DELETE_NAME', 'LOAD_ATTR',
152 'LOAD_FAST', 'STORE_FAST', 'DELETE_FAST', 'UNPACK_SEQUENCE',
153 'STORE_SUBSCR',
154 'LOAD_GLOBAL',
156 'RERAISE', 'JUMP_IF_NOT_EXC_MATCH',
158 # Following opcodes were Added in 3.11
159 # replacement of opcodes CALL_FUNCTION, CALL_FUNCTION_KW, CALL_METHOD
160 'PUSH_NULL', 'PRECALL', 'CALL', 'KW_NAMES',
161 # replacement of POP_JUMP_IF_TRUE and POP_JUMP_IF_FALSE
162 'POP_JUMP_FORWARD_IF_FALSE', 'POP_JUMP_FORWARD_IF_TRUE',
163 'POP_JUMP_BACKWARD_IF_FALSE', 'POP_JUMP_BACKWARD_IF_TRUE',
164 # special case of the previous for IS NONE / IS NOT NONE
165 'POP_JUMP_FORWARD_IF_NONE', 'POP_JUMP_BACKWARD_IF_NONE',
166 'POP_JUMP_FORWARD_IF_NOT_NONE', 'POP_JUMP_BACKWARD_IF_NOT_NONE',
167 # replacement of JUMP_IF_NOT_EXC_MATCH
168 'CHECK_EXC_MATCH',
169 # new opcodes
170 'RETURN_GENERATOR',
171 'PUSH_EXC_INFO',
172 'NOP',
173 'FORMAT_VALUE', 'BUILD_STRING',
174 # 3.12 https://docs.python.org/3/whatsnew/3.12.html#cpython-bytecode-changes
175 'END_FOR',
176 'LOAD_FAST_AND_CLEAR', 'LOAD_FAST_CHECK',
177 'POP_JUMP_IF_NOT_NONE', 'POP_JUMP_IF_NONE',
178 'CALL_INTRINSIC_1',
179 'STORE_SLICE',
180 # 3.13
181 'CALL_KW', 'LOAD_FAST_LOAD_FAST',
182 'STORE_FAST_STORE_FAST', 'STORE_FAST_LOAD_FAST',
183 'CONVERT_VALUE', 'FORMAT_SIMPLE', 'FORMAT_WITH_SPEC',
184 'SET_FUNCTION_ATTRIBUTE',
185])) - _BLACKLIST
188_logger = logging.getLogger(__name__)
190def assert_no_dunder_name(code_obj, expr):
191 """ assert_no_dunder_name(code_obj, expr) -> None
193 Asserts that the code object does not refer to any "dunder name"
194 (__$name__), so that safe_eval prevents access to any internal-ish Python
195 attribute or method (both are loaded via LOAD_ATTR which uses a name, not a
196 const or a var).
198 Checks that no such name exists in the provided code object (co_names).
200 :param code_obj: code object to name-validate
201 :type code_obj: CodeType
202 :param str expr: expression corresponding to the code object, for debugging
203 purposes
204 :raises NameError: in case a forbidden name (containing two underscores)
205 is found in ``code_obj``
207 .. note:: actually forbids every name containing 2 underscores
208 """
209 for name in code_obj.co_names:
210 if "__" in name or name in _UNSAFE_ATTRIBUTES: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 raise NameError('Access to forbidden name %r (%r)' % (name, expr))
213def assert_valid_codeobj(allowed_codes, code_obj, expr):
214 """ Asserts that the provided code object validates against the bytecode
215 and name constraints.
217 Recursively validates the code objects stored in its co_consts in case
218 lambdas are being created/used (lambdas generate their own separated code
219 objects and don't live in the root one)
221 :param allowed_codes: list of permissible bytecode instructions
222 :type allowed_codes: set(int)
223 :param code_obj: code object to name-validate
224 :type code_obj: CodeType
225 :param str expr: expression corresponding to the code object, for debugging
226 purposes
227 :raises ValueError: in case of forbidden bytecode in ``code_obj``
228 :raises NameError: in case a forbidden name (containing two underscores)
229 is found in ``code_obj``
230 """
231 assert_no_dunder_name(code_obj, expr)
233 # set operations are almost twice as fast as a manual iteration + condition
234 # when loading /web according to line_profiler
235 code_codes = {i.opcode for i in dis.get_instructions(code_obj)}
236 if not allowed_codes >= code_codes: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true
237 raise ValueError("forbidden opcode(s) in %r: %s" % (expr, ', '.join(opname[x] for x in (code_codes - allowed_codes))))
239 for const in code_obj.co_consts:
240 if isinstance(const, CodeType):
241 assert_valid_codeobj(allowed_codes, const, 'lambda')
244def compile_codeobj(expr: str, /, filename: str = '<unknown>', mode: typing.Literal['eval', 'exec'] = 'eval'):
245 """
246 :param str filename: optional pseudo-filename for the compiled expression,
247 displayed for example in traceback frames
248 :param str mode: 'eval' if single expression
249 'exec' if sequence of statements
250 :return: compiled code object
251 :rtype: types.CodeType
252 """
253 assert mode in ('eval', 'exec')
254 try:
255 if mode == 'eval':
256 expr = expr.strip() # eval() does not like leading/trailing whitespace
257 code_obj = compile(expr, filename or '', mode)
258 except (SyntaxError, TypeError, ValueError):
259 raise
260 except Exception as e:
261 raise ValueError('%r while compiling\n%r' % (e, expr))
262 return code_obj
265def const_eval(expr):
266 """const_eval(expression) -> value
268 Safe Python constant evaluation
270 Evaluates a string that contains an expression describing
271 a Python constant. Strings that are not valid Python expressions
272 or that contain other code besides the constant raise ValueError.
274 >>> const_eval("10")
275 10
276 >>> const_eval("[1,2, (3,4), {'foo':'bar'}]")
277 [1, 2, (3, 4), {'foo': 'bar'}]
278 >>> const_eval("1+2")
279 Traceback (most recent call last):
280 ...
281 ValueError: opcode BINARY_ADD not allowed
282 """
283 c = compile_codeobj(expr)
284 assert_valid_codeobj(_CONST_OPCODES, c, expr)
285 return unsafe_eval(c)
287def expr_eval(expr):
288 """expr_eval(expression) -> value
290 Restricted Python expression evaluation
292 Evaluates a string that contains an expression that only
293 uses Python constants. This can be used to e.g. evaluate
294 a numerical expression from an untrusted source.
296 >>> expr_eval("1+2")
297 3
298 >>> expr_eval("[1,2]*2")
299 [1, 2, 1, 2]
300 >>> expr_eval("__import__('sys').modules")
301 Traceback (most recent call last):
302 ...
303 ValueError: opcode LOAD_NAME not allowed
304 """
305 c = compile_codeobj(expr)
306 assert_valid_codeobj(_EXPR_OPCODES, c, expr)
307 return unsafe_eval(c)
309_BUILTINS = {
310 '__import__': _import,
311 'True': True,
312 'False': False,
313 'None': None,
314 'bytes': bytes,
315 'str': str,
316 'unicode': str,
317 'bool': bool,
318 'int': int,
319 'float': float,
320 'enumerate': enumerate,
321 'dict': dict,
322 'list': list,
323 'tuple': tuple,
324 'map': map,
325 'abs': abs,
326 'min': min,
327 'max': max,
328 'sum': sum,
329 'reduce': functools.reduce,
330 'filter': filter,
331 'sorted': sorted,
332 'round': round,
333 'len': len,
334 'repr': repr,
335 'set': set,
336 'all': all,
337 'any': any,
338 'ord': ord,
339 'chr': chr,
340 'divmod': divmod,
341 'isinstance': isinstance,
342 'range': range,
343 'xrange': range,
344 'zip': zip,
345 'Exception': Exception,
346}
349_BUBBLEUP_EXCEPTIONS = (
350 odoo.exceptions.UserError,
351 odoo.exceptions.RedirectWarning,
352 werkzeug.exceptions.HTTPException,
353 OperationalError, # let auto-replay of serialized transactions work its magic
354 ZeroDivisionError,
355)
358def safe_eval(expr, /, context=None, *, mode="eval", filename=None):
359 """System-restricted Python expression evaluation
361 Evaluates a string that contains an expression that mostly
362 uses Python constants, arithmetic expressions and the
363 objects directly provided in context.
365 This can be used to e.g. evaluate
366 a domain expression from an untrusted source.
368 :param expr: The Python expression (or block, if ``mode='exec'``) to evaluate.
369 :type expr: string | bytes
370 :param context: Namespace available to the expression.
371 This dict will be mutated with any variables created during
372 evaluation
373 :type context: dict
374 :param mode: ``exec`` or ``eval``
375 :type mode: str
376 :param filename: optional pseudo-filename for the compiled expression,
377 displayed for example in traceback frames
378 :type filename: string
379 :throws TypeError: If the expression provided is a code object
380 :throws SyntaxError: If the expression provided is not valid Python
381 :throws NameError: If the expression provided accesses forbidden names
382 :throws ValueError: If the expression provided uses forbidden bytecode
383 """
384 if type(expr) is CodeType: 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true
385 raise TypeError("safe_eval does not allow direct evaluation of code objects.")
387 assert context is None or type(context) is dict, "Context must be a dict"
389 check_values(context)
391 globals_dict = dict(context or {}, __builtins__=dict(_BUILTINS))
393 c = compile_codeobj(expr, filename=filename, mode=mode)
394 assert_valid_codeobj(_SAFE_OPCODES, c, expr)
395 try:
396 # empty locals dict makes the eval behave like top-level code
397 return unsafe_eval(c, globals_dict, None)
399 except _BUBBLEUP_EXCEPTIONS:
400 raise
402 except Exception as e:
403 raise ValueError('%r while evaluating\n%r' % (e, expr))
405 finally:
406 if context is not None:
407 del globals_dict['__builtins__']
408 context.update(globals_dict)
411def test_python_expr(expr, mode="eval"):
412 try:
413 c = compile_codeobj(expr, mode=mode)
414 assert_valid_codeobj(_SAFE_OPCODES, c, expr)
415 except (SyntaxError, TypeError, ValueError) as err:
416 if len(err.args) >= 2 and len(err.args[1]) >= 4:
417 error = {
418 'message': err.args[0],
419 'filename': err.args[1][0],
420 'lineno': err.args[1][1],
421 'offset': err.args[1][2],
422 'error_line': err.args[1][3],
423 }
424 msg = "%s : %s at line %d\n%s" % (type(err).__name__, error['message'], error['lineno'], error['error_line'])
425 else:
426 msg = str(err)
427 return msg
428 return False
431def check_values(d):
432 if not d:
433 return d
434 for v in d.values():
435 if isinstance(v, types.ModuleType): 435 ↛ 436line 435 didn't jump to line 436 because the condition on line 435 was never true
436 raise TypeError(f"""Module {v} can not be used in evaluation contexts
438Prefer providing only the items necessary for your intended use.
440If a "module" is necessary for backwards compatibility, use
441`odoo.tools.safe_eval.wrap_module` to generate a wrapper recursively
442whitelisting allowed attributes.
444Pre-wrapped modules are provided as attributes of `odoo.tools.safe_eval`.
445""")
446 return d
448class wrap_module:
449 def __init__(self, module, attributes):
450 """Helper for wrapping a package/module to expose selected attributes
452 :param module: the actual package/module to wrap, as returned by ``import <module>``
453 :param iterable attributes: attributes to expose / whitelist. If a dict,
454 the keys are the attributes and the values
455 are used as an ``attributes`` in case the
456 corresponding item is a submodule
457 """
458 # builtin modules don't have a __file__ at all
459 modfile = getattr(module, '__file__', '(built-in)')
460 self._repr = f"<wrapped {module.__name__!r} ({modfile})>"
461 for attrib in attributes:
462 target = getattr(module, attrib)
463 if isinstance(target, types.ModuleType):
464 target = wrap_module(target, attributes[attrib])
465 setattr(self, attrib, target)
467 def __repr__(self):
468 return self._repr
470# dateutil submodules are lazy so need to import them for them to "exist"
471import dateutil
472mods = ['parser', 'relativedelta', 'rrule', 'tz']
473for mod in mods:
474 __import__('dateutil.%s' % mod)
475# make sure to patch pytz before exposing
476from odoo._monkeypatches.pytz import patch_module as patch_pytz # noqa: E402, F401
477patch_pytz()
479datetime = wrap_module(__import__('datetime'), ['date', 'datetime', 'time', 'timedelta', 'timezone', 'tzinfo', 'MAXYEAR', 'MINYEAR'])
480dateutil = wrap_module(dateutil, {
481 "tz": ["UTC", "tzutc"],
482 "parser": ["isoparse", "parse"],
483 "relativedelta": ["relativedelta", "MO", "TU", "WE", "TH", "FR", "SA", "SU"],
484 "rrule": ["rrule", "rruleset", "rrulestr", "YEARLY", "MONTHLY", "WEEKLY", "DAILY", "HOURLY", "MINUTELY", "SECONDLY", "MO", "TU", "WE", "TH", "FR", "SA", "SU"],
485})
486json = wrap_module(__import__('json'), ['loads', 'dumps'])
487time = wrap_module(__import__('time'), ['time', 'strptime', 'strftime', 'sleep'])
488pytz = wrap_module(__import__('pytz'), [
489 'utc', 'UTC', 'timezone',
490])
491dateutil.tz.gettz = pytz.timezone