details:   https://code.tryton.org/tryton/commit/101aaf12c6bb
branch:    default
user:      Cédric Krier <[email protected]>
date:      Mon Jun 08 23:44:10 2026 +0200
description:
        Restrict Genshi evaluation

        Closes #14869 #5160
diffstat:

 trytond/CHANGELOG                    |    1 +
 trytond/trytond/__init__.py          |    4 +
 trytond/trytond/_safe_genshi.py      |  107 +++++++++++++++++++++++++++++++++++
 trytond/trytond/tests/test_genshi.py |   36 +++++++++++
 4 files changed, 148 insertions(+), 0 deletions(-)

diffs (180 lines):

diff -r a1cb164af115 -r 101aaf12c6bb trytond/CHANGELOG
--- a/trytond/CHANGELOG Thu Jun 25 19:03:12 2026 +0200
+++ b/trytond/CHANGELOG Mon Jun 08 23:44:10 2026 +0200
@@ -1,3 +1,4 @@
+* Restrict Genshi evaluation (issue14869, issue5160)
 * Add BrowseList for Model instances
 * Remove cursor_dict
 * Add row factory to cursor
diff -r a1cb164af115 -r 101aaf12c6bb trytond/trytond/__init__.py
--- a/trytond/trytond/__init__.py       Thu Jun 25 19:03:12 2026 +0200
+++ b/trytond/trytond/__init__.py       Mon Jun 08 23:44:10 2026 +0200
@@ -12,6 +12,8 @@
 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 @@
 
 decimal.DefaultContext.prec = int(os.environ.get('TRYTOND_DECIMAL_PREC', 28))
 
+genshi_patch()
+
 
 class _RequestPatchFinder:
     def find_spec(self, fullname, path, target=None):
diff -r a1cb164af115 -r 101aaf12c6bb trytond/trytond/_safe_genshi.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/trytond/_safe_genshi.py   Mon Jun 08 23:44:10 2026 +0200
@@ -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 -r a1cb164af115 -r 101aaf12c6bb trytond/trytond/tests/test_genshi.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/trytond/tests/test_genshi.py      Mon Jun 08 23:44:10 2026 +0200
@@ -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("<?python import os?>").generate())
+
+    def test_no_import_from(self):
+        "Test no import from"
+        with self.assertRaisesRegex(ValueError, r"invalid import from"):
+            str(MarkupTemplate("<?python from os import popen?>").generate())

Reply via email to