diff --git a/src/authentic2/utils/evaluate.py b/src/authentic2/utils/evaluate.py
new file mode 100644
index 000000000..de0923239
--- /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 000000000..6e238ccbe
--- /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