utils: add module to evaluate condition expressions safely (#35302)

This commit is contained in:
Benjamin Dauvergne 2019-08-09 10:33:52 +02:00
parent 5cb84716c8
commit 94486a726b
2 changed files with 255 additions and 0 deletions

View File

@ -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 <http://www.gnu.org/licenses/>.
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())

View File

@ -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 <http://www.gnu.org/licenses/>.
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