diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG index fd28d83ec9..c0bc3a488a 100644 --- a/trytond/CHANGELOG +++ b/trytond/CHANGELOG @@ -1,3 +1,4 @@ +* Restrict Genshi evaluation (issue14869, issue5160) * Add BrowseList for Model instances * Remove cursor_dict * Add row factory to cursor diff --git a/trytond/trytond/__init__.py b/trytond/trytond/__init__.py index a25ef8a28d..9ce4b664eb 100644 --- a/trytond/trytond/__init__.py +++ b/trytond/trytond/__init__.py @@ -12,6 +12,8 @@ from email import charset import __main__ from lxml import etree, objectify +from ._safe_genshi import genshi_patch + __version__ = "8.1.0" __series__ = '.'.join(__version__.split('.')[:2]) @@ -36,6 +38,8 @@ objectify.set_default_parser(objectify.makeparser(resolve_entities=False)) decimal.DefaultContext.prec = int(os.environ.get('TRYTOND_DECIMAL_PREC', 28)) +genshi_patch() + class _RequestPatchFinder: def find_spec(self, fullname, path, target=None): diff --git a/trytond/trytond/_safe_genshi.py b/trytond/trytond/_safe_genshi.py new file mode 100644 index 0000000000..ece068fbf0 --- /dev/null +++ b/trytond/trytond/_safe_genshi.py @@ -0,0 +1,107 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +from genshi.template.astutil import ASTTransformer +from genshi.template.eval import ( + BUILTINS, Code, ExpressionASTTransformer, TemplateASTTransformer) + + +class _SafeASTTransformer(ASTTransformer): + + def visit_Attribute(self, node): + if (node.attr.startswith('_') + and node.attr not in {'__class__', '__name__'}): + raise ValueError(f"invalid attribute {node.attr!r}") + return super().visit_Attribute(node) + + def visit_Import(self, node): + raise ValueError("invalid import") + + def visit_ImportFrom(self, node): + raise ValueError("invalid import from") + + def visit_Name(self, node): + if (node.id.startswith('_') + and not node.id.startswith('__relatorio_') + and node.id not in {'_'}): + raise ValueError(f"invalid name {node.id!r}") + return super().visit_Name(node) + + +class SafeExpressionASTTransformer( + _SafeASTTransformer, ExpressionASTTransformer): + pass + + +class SafeTemplateASTTransformer(_SafeASTTransformer, TemplateASTTransformer): + pass + + +ALLOWED_BUILTINS = { + 'False', + 'True', + 'None', + 'abs', + 'all', + 'any', + 'ascii', + 'bin', + 'bool', + 'bytearray', + 'bytes', + 'chr', + 'complex', + 'dict', + 'dir', + 'divmod', + 'enumerate', + 'filter', + 'float', + 'format', + 'frozenset', + 'hasattr', + 'hash', + 'hex', + 'int', + 'iter', + 'len', + 'list', + 'map', + 'max', + 'min', + 'next', + 'oct', + 'ord', + 'pow', + 'range', + 'repr', + 'reversed', + 'round', + 'set' + 'slice', + 'sorted', + 'str', + 'sum', + 'tuple', + 'zip', + } + + +def genshi_patch(): + + code__init__ = Code.__init__ + + def patched_code__init__( + self, source, filename=None, lineno=-1, lookup='strict', + xform=None): + if self.mode == 'eval': + xform = SafeExpressionASTTransformer + else: + xform = SafeTemplateASTTransformer + code__init__( + self, source, filename=filename, lineno=lineno, lookup=lookup, + xform=xform) + Code.__init__ = patched_code__init__ + + for name in BUILTINS.keys() - ALLOWED_BUILTINS: + BUILTINS.pop(name) diff --git a/trytond/trytond/tests/test_genshi.py b/trytond/trytond/tests/test_genshi.py new file mode 100644 index 0000000000..5c0ff3b05a --- /dev/null +++ b/trytond/trytond/tests/test_genshi.py @@ -0,0 +1,36 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +from genshi.template import MarkupTemplate, TextTemplate +from genshi.template.eval import UndefinedError + +from trytond.tests.test_tryton import TestCase + + +class GenshiTestCase(TestCase): + + def test_no_builtins(self): + "Test no builtins" + with self.assertRaises(UndefinedError): + str(TextTemplate("${open('%s').read()}" % __file__).generate()) + + def test_no_private_name(self): + "Test no private name" + with self.assertRaisesRegex(ValueError, r"invalid name '__import__'"): + str(TextTemplate("${__import__('os')}").generate()) + + def test_no_private_attribute(self): + "Test no private attribute" + with self.assertRaisesRegex( + ValueError, r"invalid attribute '__getattribute__'"): + str(TextTemplate("${True.__getattribute__}").generate()) + + def test_no_import(self): + "Test no import" + with self.assertRaisesRegex(ValueError, r"invalid import"): + str(MarkupTemplate("").generate()) + + def test_no_import_from(self): + "Test no import from" + with self.assertRaisesRegex(ValueError, r"invalid import from"): + str(MarkupTemplate("").generate())