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:15 +0000

1# -*- coding: utf-8 -*- 

2# Part of Odoo. See LICENSE file for full copyright and licensing details. 

3 

4""" 

5safe_eval module - methods intended to provide more restricted alternatives to 

6 evaluate simple and/or untrusted code. 

7 

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""" 

12 

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 

25 

26import werkzeug 

27from psycopg2 import OperationalError 

28 

29import odoo.exceptions 

30 

31unsafe_eval = eval 

32 

33__all__ = ['const_eval', 'safe_eval'] 

34 

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'] 

39 

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()') 

47 

48for module in _ALLOWED_MODULES: 

49 __import__(module) 

50 

51 

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] 

70 

71 

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 

107 

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 

132 

133_SAFE_OPCODES = _EXPR_OPCODES.union(to_opcodes([ 

134 'POP_BLOCK', 'POP_EXCEPT', 

135 

136 # note: removed in 3.8 

137 'SETUP_LOOP', 'SETUP_EXCEPT', 'BREAK_LOOP', 'CONTINUE_LOOP', 

138 

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', 

143 

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', 

150 

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', 

155 

156 'RERAISE', 'JUMP_IF_NOT_EXC_MATCH', 

157 

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 

186 

187 

188_logger = logging.getLogger(__name__) 

189 

190def assert_no_dunder_name(code_obj, expr): 

191 """ assert_no_dunder_name(code_obj, expr) -> None 

192 

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). 

197 

198 Checks that no such name exists in the provided code object (co_names). 

199 

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`` 

206 

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)) 

212 

213def assert_valid_codeobj(allowed_codes, code_obj, expr): 

214 """ Asserts that the provided code object validates against the bytecode 

215 and name constraints. 

216 

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) 

220 

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) 

232 

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)))) 

238 

239 for const in code_obj.co_consts: 

240 if isinstance(const, CodeType): 

241 assert_valid_codeobj(allowed_codes, const, 'lambda') 

242 

243 

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 

263 

264 

265def const_eval(expr): 

266 """const_eval(expression) -> value 

267 

268 Safe Python constant evaluation 

269 

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. 

273 

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) 

286 

287def expr_eval(expr): 

288 """expr_eval(expression) -> value 

289 

290 Restricted Python expression evaluation 

291 

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. 

295 

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) 

308 

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} 

347 

348 

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) 

356 

357 

358def safe_eval(expr, /, context=None, *, mode="eval", filename=None): 

359 """System-restricted Python expression evaluation 

360 

361 Evaluates a string that contains an expression that mostly 

362 uses Python constants, arithmetic expressions and the 

363 objects directly provided in context. 

364 

365 This can be used to e.g. evaluate 

366 a domain expression from an untrusted source. 

367 

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.") 

386 

387 assert context is None or type(context) is dict, "Context must be a dict" 

388 

389 check_values(context) 

390 

391 globals_dict = dict(context or {}, __builtins__=dict(_BUILTINS)) 

392 

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) 

398 

399 except _BUBBLEUP_EXCEPTIONS: 

400 raise 

401 

402 except Exception as e: 

403 raise ValueError('%r while evaluating\n%r' % (e, expr)) 

404 

405 finally: 

406 if context is not None: 

407 del globals_dict['__builtins__'] 

408 context.update(globals_dict) 

409 

410 

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 

429 

430 

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 

437 

438Prefer providing only the items necessary for your intended use. 

439 

440If a "module" is necessary for backwards compatibility, use 

441`odoo.tools.safe_eval.wrap_module` to generate a wrapper recursively 

442whitelisting allowed attributes. 

443 

444Pre-wrapped modules are provided as attributes of `odoo.tools.safe_eval`. 

445""") 

446 return d 

447 

448class wrap_module: 

449 def __init__(self, module, attributes): 

450 """Helper for wrapping a package/module to expose selected attributes 

451 

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) 

466 

467 def __repr__(self): 

468 return self._repr 

469 

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() 

478 

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