From fa79da416c2df426c05f1e36fccc2e4f89e0f2e2 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Fri, 9 Aug 2019 10:33:52 +0200 Subject: [PATCH 3/4] utils: add module to evaluate condition expressions safely (#35302) --- src/authentic2/utils/evaluate.py | 182 +++++++++++++++++++++++++++++++ tests/test_utils_evaluate.py | 73 +++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/authentic2/utils/evaluate.py create mode 100644 tests/test_utils_evaluate.py diff --git a/src/authentic2/utils/evaluate.py b/src/authentic2/utils/evaluate.py new file mode 100644 index 00000000..de092323 --- /dev/null +++ b/src/authentic2/utils/evaluate.py @@ -0,0 +1,182 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import sys + +from django.core.exceptions import ValidationError +try: + from functools import lru_cache +except ImportError: + from django.utils.lru_cache import lru_cache +from django.utils.translation import ugettext as _ +from django.utils import six + + +import ast + + +class Unparse(ast.NodeVisitor): + def visit_Name(self, node): + return node.id + + +class ExpressionError(ValidationError): + colummn = None + node = None + text = None + + def __init__(self, message, code=None, params=None, node=None, column=None, text=None): + super(ExpressionError, self).__init__(message, code=code, params=params) + if hasattr(node, 'col_offset'): + self.set_node(node) + if column is not None: + self.column = column + if text is not None: + self.text = text + + def set_node(self, node): + assert hasattr(node, 'col_offset'), 'only node with col_offset attribute' + self.node = node + self.column = node.col_offset + self.text = Unparse().visit(node) + + +class BaseExpressionValidator(ast.NodeVisitor): + authorized_nodes = [] + forbidden_nodes = [] + + def __init__(self, authorized_nodes=None, forbidden_nodes=None): + if authorized_nodes is not None: + self.authorized_nodes = authorized_nodes + if forbidden_nodes is not None: + self.forbidden_nodes = forbidden_nodes + + def generic_visit(self, node): + # generic node class checks + ok = False + if not isinstance(node, ast.Expression): + for klass in self.authorized_nodes: + if isinstance(node, klass): + ok = True + break + for klass in self.forbidden_nodes: + if isinstance(node, klass): + ok = False + else: + ok = True + if not ok: + raise ExpressionError(_('expression is forbidden'), node=node, code='forbidden-expression') + + # specific node class check + node_name = node.__class__.__name__ + check_method = getattr(self, 'check_' + node_name, None) + if check_method: + check_method(node) + + # now recurse on subnodes + try: + return super(BaseExpressionValidator, self).generic_visit(node) + except ExpressionError as e: + # for errors in non expr nodes (so without a col_offset attribute, + # set the nearer expr node as the node of the error + if e.node is None and hasattr(node, 'col_offset'): + e.set_node(node) + six.reraise(*sys.exc_info()) + + @lru_cache(maxsize=1024) + def __call__(self, expression): + try: + tree = ast.parse(expression, mode='eval') + except SyntaxError as e: + raise ExpressionError(_('could not parse expression') % e, + code='parsing-error', + column=e.offset, + text=expression) + try: + self.visit(tree) + except ExpressionError as e: + if e.text is None: + e.text = expression + six.reraise(*sys.exc_info()) + return compile(tree, expression, mode='eval') + + +class ConditionValidator(BaseExpressionValidator): + ''' + Only authorize : + - direct variable references, without underscore in them, + - num and str constants, + - boolean expressions with all operators, + - unary operator expressions with all operators, + - if expressions (x if y else z), + - compare expressions with all operators. + + Are implicitely forbidden: + - binary expressions (so no "'aaa' * 99999999999" or 233333333333333233**2232323233232323 bombs), + - lambda, + - literal list, tuple, dict and sets, + - comprehensions (list, dict and set), + - generators, + - yield, + - call, + - Repr node (i dunno what it is), + - attribute access, + - subscript. + ''' + authorized_nodes = [ + ast.Load, + ast.Name, + ast.Num, + ast.Str, + ast.BoolOp, + ast.UnaryOp, + ast.IfExp, + ast.boolop, + ast.cmpop, + ast.Compare, + ] + + def check_Name(self, node): + if node.id.startswith('_'): + raise ExpressionError(_('name must not start with a _'), code='invalid-variable', node=node) + + +validate_condition = ConditionValidator() + +condition_safe_globals = { + '__builtins__': { + 'True': True, + 'False': False, + } +} + + +def evaluate_condition(expression, ctx=None, validator=None, on_raise=None): + try: + code = (validator or validate_condition)(expression) + try: + return eval(code, condition_safe_globals, ctx or {}) + except NameError as e: + # NameError does not report the column of the name reference :/ + raise ExpressionError( + _('variable is not defined: %s') % e, + code='undefined-variable', + text=expression, + column=0) + except Exception: + if on_raise is not None: + return on_raise + six.reraise(*sys.exc_info()) diff --git a/tests/test_utils_evaluate.py b/tests/test_utils_evaluate.py new file mode 100644 index 00000000..6e238ccb --- /dev/null +++ b/tests/test_utils_evaluate.py @@ -0,0 +1,73 @@ +# authentic2 - versatile identity manager +# Copyright (C) 2010-2019 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import ast + +import pytest + +from authentic2.utils.evaluate import ( + BaseExpressionValidator, ConditionValidator, ExpressionError, + evaluate_condition) + + +def test_base(): + v = BaseExpressionValidator() + +# assert v('1')[0] is False +# assert v('\'a\'')[0] is False +# assert v('x')[0] is False + + v = BaseExpressionValidator(authorized_nodes=[ast.Num, ast.Str]) + + assert v('1') + assert v('\'a\'') + + # code object is cached + assert v('1') is v('1') + assert v('\'a\'') is v('\'a\'') + with pytest.raises(ExpressionError): + assert v('x') + + +def test_condition_validator(): + v = ConditionValidator() + assert v('x < 2 and y == \'u\' or \'a\' in z') + with pytest.raises(ExpressionError) as raised: + v('a and _b') + assert raised.value.code == 'invalid-variable' + assert raised.value.text == '_b' + + with pytest.raises(ExpressionError) as raised: + v('a + b') + + with pytest.raises(ExpressionError) as raised: + v('1 + 2') + + +def test_evaluate_condition(): + v = ConditionValidator() + + assert evaluate_condition('False', validator=v) is False + assert evaluate_condition('True', validator=v) is True + assert evaluate_condition('True and False', validator=v) is False + assert evaluate_condition('True or False', validator=v) is True + assert evaluate_condition('a or b', ctx=dict(a=True, b=False), validator=v) is True + assert evaluate_condition('a < 1', ctx=dict(a=0), validator=v) is True + with pytest.raises(ExpressionError) as exc_info: + evaluate_condition('a < 1', validator=v) + assert exc_info.value.code == 'undefined-variable' + assert evaluate_condition('a < 1', validator=v, on_raise=False) is False -- 2.22.0