Version in base suite: 7.0.30-1+deb13u1 Base version: tryton-server_7.0.30-1+deb13u1 Target version: tryton-server_7.0.30-1+deb13u2 Base file: /srv/ftp-master.debian.org/ftp/pool/main/t/tryton-server/tryton-server_7.0.30-1+deb13u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/t/tryton-server/tryton-server_7.0.30-1+deb13u2.dsc changelog | 27 patches/06_restrict_genshi_evaluation.patch | 184 +++ patches/07_enforce_access_right_on_email_template_records.patch | 546 ++++++++++ patches/08_restrict_weasyprint_protocol.patch | 27 patches/series | 3 5 files changed, 787 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp3un0f23h/tryton-server_7.0.30-1+deb13u1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp3un0f23h/tryton-server_7.0.30-1+deb13u2.dsc: no acceptable signature found diff -Nru tryton-server-7.0.30/debian/changelog tryton-server-7.0.30/debian/changelog --- tryton-server-7.0.30/debian/changelog 2025-11-25 11:32:14.000000000 +0000 +++ tryton-server-7.0.30/debian/changelog 2026-09-02 13:11:59.000000000 +0000 @@ -1,4 +1,31 @@ +tryton-server (7.0.30-1+deb13u2) trixie-security; urgency=high + + * Add 06_restrict_genshi_evaluation.patch. + From https://discuss.tryton.org/t/security-release-for-issue-5160-and-14869: + Security Release for issue #5160 and #14869 + The user titou has discovered that the administrator group can execute + Python code on the server which is hidden inside an uploaded report template. + And Dan Shallom has discovered that the same can also be accomplished by + the marketing group when uploading marketing email templates. + This patch also contains the subsequent fixes from + https://bugs.tryton.org/14928, https://bugs.tryton.org/14932 + * Add 07_enforce_access_right_on_email_template_records.patch. + From https://discuss.tryton.org/t/security-release-for-issue-14907: + Cédric Krier has discovered that access is not enforced when browsing + record instances in templates. + This patch also contains the required patch adding ModelAccessProxy + https://foss.heptapod.net/tryton/tryton/-/merge_requests/3431 + * Add 08_restrict_weasyprint_protocol.patch. + From https://discuss.tryton.org/t/security-release-for-issue-14947: + Cédric Krier has discovered that Tryton does not prevent weasyprint + to access local files when rendering HTML report to PDF. + https://foss.heptapod.net/tryton/tryton/-/work_items/14947 + The weasyprint documentation states that it can be used to access local files. + + -- Mathias Behrle Wed, 02 Sep 2026 15:11:59 +0200 + tryton-server (7.0.30-1+deb13u1) trixie-security; urgency=high + * Add 03_traceback_in_RPC.patch, 04_enforce_access_check_html_editor.patch, 05_enforce_access_check_export_data.patch diff -Nru tryton-server-7.0.30/debian/patches/06_restrict_genshi_evaluation.patch tryton-server-7.0.30/debian/patches/06_restrict_genshi_evaluation.patch --- tryton-server-7.0.30/debian/patches/06_restrict_genshi_evaluation.patch 1970-01-01 00:00:00.000000000 +0000 +++ tryton-server-7.0.30/debian/patches/06_restrict_genshi_evaluation.patch 2026-09-02 10:00:47.000000000 +0000 @@ -0,0 +1,184 @@ +Description: Restrict Genshi evaluation + From https://discuss.tryton.org/t/security-release-for-issue-5160-and-14869: + Security Release for issue #5160 and #14869 + The user titou has discovered that the administrator group can execute + Python code on the server which is hidden inside an uploaded report template. + And Dan Shallom has discovered that the same can also be accomplished by + the marketing group when uploading marketing email templates. + This patch also contains the subsequent fixes from + https://bugs.tryton.org/14928, https://bugs.tryton.org/14932 +Author: Cédric Krier , Adrià Tarroja Caubet +Last-Update: 2026-09-02 +Bug-Upstream: https://bugs.tryton.org/5160, https://bugs.tryton.org/14869, + https://bugs.tryton.org/14928, https://bugs.tryton.org/14932 + +--- a/trytond/__init__.py ++++ b/trytond/__init__.py +@@ -8,6 +8,8 @@ + import __main__ + from lxml import etree, objectify + ++from ._safe_genshi import genshi_patch ++ + try: + from requests import utils as requests_utils + except ImportError: +@@ -33,6 +35,8 @@ + etree.set_default_parser(etree.XMLParser(resolve_entities=False)) + objectify.set_default_parser(objectify.makeparser(resolve_entities=False)) + ++genshi_patch() ++ + + def default_user_agent(name="Tryton"): + return f"{name}/{__version__}" +--- /dev/null ++++ b/trytond/_safe_genshi.py +@@ -0,0 +1,108 @@ ++# 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__', '__url__', '__href__'}): ++ 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) +--- /dev/null ++++ b/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. ++ ++import unittest ++ ++from genshi.template import MarkupTemplate, TextTemplate ++from genshi.template.eval import UndefinedError ++ ++ ++class GenshiTestCase(unittest.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()) diff -Nru tryton-server-7.0.30/debian/patches/07_enforce_access_right_on_email_template_records.patch tryton-server-7.0.30/debian/patches/07_enforce_access_right_on_email_template_records.patch --- tryton-server-7.0.30/debian/patches/07_enforce_access_right_on_email_template_records.patch 1970-01-01 00:00:00.000000000 +0000 +++ tryton-server-7.0.30/debian/patches/07_enforce_access_right_on_email_template_records.patch 2026-09-02 12:58:02.000000000 +0000 @@ -0,0 +1,546 @@ +Description: Enforce access rights on email template records + From https://discuss.tryton.org/t/security-release-for-issue-14907: + Cédric Krier has discovered that access is not enforced when browsing + record instances in templates. + + This patch also contains the required patch adding ModelAccessProxy + https://foss.heptapod.net/tryton/tryton/-/merge_requests/3431 +Author: Cédric Krier +Last-Update: 2026-09-02 +Bug-Upstream: https://bugs.tryton.org/14907 + +--- a/doc/ref/models.rst ++++ b/doc/ref/models.rst +@@ -775,6 +775,15 @@ + the string + + ++ModelAccessProxy ++================ ++ ++.. class:: ModelAccessProxy(record[, context]) ++ ++ A class proxying instance of :class:`ModelStorage` with check access using ++ ``context``. ++ ++ + convert_from + ------------ + +--- a/trytond/ir/model.py ++++ b/trytond/ir/model.py +@@ -530,13 +530,12 @@ + @classmethod + def get_access(cls, models): + 'Return access for models' +- # root user above constraint +- if Transaction().user == 0: +- return defaultdict(lambda: defaultdict(lambda: True)) +- + pool = Pool() + Model = pool.get('ir.model') +- User = pool.get('res.user') ++ try: ++ User = pool.get('res.user') ++ except KeyError: ++ return defaultdict(lambda: defaultdict(lambda: True)) + cursor = Transaction().connection.cursor() + model_access = cls.__table__() + ir_model = Model.__table__() +@@ -627,9 +626,8 @@ + Model = pool.get(model_name) + assert mode in ['read', 'write', 'create', 'delete'], \ + 'Invalid access mode for security' +- transaction = Transaction() +- if (transaction.user == 0 +- or (raise_exception and not transaction.check_access)): ++ ++ if not Transaction().check_access: + return True + + access = cls.get_access([model_name])[model_name][mode] +@@ -749,15 +747,14 @@ + @classmethod + def get_access(cls, models): + 'Return fields access for models' +- # root user above constraint +- if Transaction().user == 0: +- return defaultdict(lambda: defaultdict( +- lambda: defaultdict(lambda: True))) +- + pool = Pool() + Model = pool.get('ir.model') + ModelField = pool.get('ir.model.field') +- User = pool.get('res.user') ++ try: ++ User = pool.get('res.user') ++ except KeyError: ++ return defaultdict(lambda: defaultdict( ++ lambda: defaultdict(lambda: True))) + field_access = cls.__table__() + ir_model = Model.__table__() + model_field = ModelField.__table__() +@@ -807,8 +804,7 @@ + return accesses + + @classmethod +- def check(cls, model_name, fields, mode='read', raise_exception=True, +- access=False): ++ def check(cls, model_name, fields, mode='read', raise_exception=True): + ''' + Check access for fields on model_name. + ''' +@@ -816,17 +812,12 @@ + Model = pool.get(model_name) + assert mode in ('read', 'write', 'create', 'delete'), \ + 'Invalid access mode' +- transaction = Transaction() +- if (transaction.user == 0 +- or (raise_exception and not transaction.check_access)): +- if access: +- return dict((x, True) for x in fields) ++ ++ if not Transaction().check_access: + return True + + accesses = dict((f, a[mode]) + for f, a in cls.get_access([model_name])[model_name].items()) +- if access: +- return accesses + for field in fields: + if not accesses.get(field, True): + if raise_exception: +--- a/trytond/ir/rule.py ++++ b/trytond/ir/rule.py +@@ -7,10 +7,11 @@ + from trytond.cache import Cache + from trytond.i18n import gettext + from trytond.model import Check, Index, ModelSQL, ModelView, fields +-from trytond.model.exceptions import ValidationError ++from trytond.model.exceptions import AccessError, ValidationError + from trytond.pool import Pool + from trytond.pyson import PYSONDecoder +-from trytond.transaction import Transaction, inactive_records ++from trytond.transaction import ( ++ Transaction, inactive_records, without_check_access) + + + class DomainError(ValidationError): +@@ -205,9 +206,6 @@ + model_names = list(model_names) + + cursor = transaction.connection.cursor() +- # root user above constraint +- if transaction.user == 0: +- return {}, {} + cursor.execute(*rule_table.join(rule_group, + condition=rule_group.id == rule_table.rule_group + ).join(model, +@@ -236,8 +234,8 @@ + + clause = defaultdict(lambda: ['OR']) + clause_global = defaultdict(lambda: ['OR']) +- # Use root user without context to prevent recursion +- with transaction.set_user(0), transaction.set_context(user=0): ++ # Without check access to prevent recursion ++ with without_check_access(): + rules = cls.browse(ids) + for rule in rules: + decoder = PYSONDecoder( +@@ -265,9 +263,8 @@ + @classmethod + def domain_get(cls, model_name, mode='read'): + pool = Pool() +- transaction = Transaction() +- # root user above constraint +- if transaction.user == 0 or not transaction.check_access: ++ ++ if not Transaction().check_access: + return [] + + assert mode in cls.modes +@@ -312,6 +309,47 @@ + return Model.search(domain, order=[], query=True) + + @classmethod ++ def check(cls, model_name, ids, mode='read'): ++ pool = Pool() ++ Model = pool.get(model_name) ++ transaction = Transaction() ++ ++ def test_domain(ids, domain): ++ # Use root to prevent infinite recursion ++ with transaction.set_user(0, set_context=True), \ ++ inactive_records(), \ ++ without_check_access(): ++ records = Model.search([ ++ ('id', 'in', ids), ++ domain, ++ ], order=[]) ++ return list(set(ids).difference(map(int, records))) ++ ++ domain = cls.domain_get(model_name, mode=mode) ++ if not domain: ++ return ++ forbidden = test_domain(ids, domain) ++ if forbidden: ++ ids = ', '.join(map(str, forbidden[:5])) ++ if len(forbidden) > 5: ++ ids += '...' ++ rules = [] ++ clause, clause_global = cls.get(model_name, mode=mode) ++ if clause: ++ dom = list(clause.values()) ++ dom.insert(0, 'OR') ++ if test_domain(forbidden, dom): ++ rules.extend(clause.keys()) ++ for rule, dom in clause_global.items(): ++ if test_domain(forbidden, dom): ++ rules.append(rule) ++ raise AccessError(gettext( ++ f'ir.msg_{mode}_rule_error', ++ ids=ids, ++ rules='\n'.join(r.name for r in rules), ++ **Model.__names__())) ++ ++ @classmethod + def delete(cls, rules): + super(Rule, cls).delete(rules) + # Restart the cache on the domain_get method of ir.rule +--- a/trytond/model/__init__.py ++++ b/trytond/model/__init__.py +@@ -9,7 +9,7 @@ + from .model import Model + from .modelsingleton import ModelSingleton + from .modelsql import Check, Exclude, Index, ModelSQL, Unique, convert_from +-from .modelstorage import EvalEnvironment, ModelStorage ++from .modelstorage import EvalEnvironment, ModelAccessProxy, ModelStorage + from .modelview import ModelView + from .multivalue import MultiValueMixin, ValueMixin + from .order import sequence_ordered, sort +@@ -23,4 +23,4 @@ + 'Workflow', 'DictSchemaMixin', 'MatchMixin', 'UnionMixin', 'dualmethod', + 'MultiValueMixin', 'ValueMixin', 'SymbolMixin', 'DigitsMixin', + 'EvalEnvironment', 'sequence_ordered', 'sort', 'DeactivableMixin', 'tree', +- 'sum_tree', 'avatar_mixin'] ++ 'sum_tree', 'avatar_mixin', 'ModelAccessProxy'] +--- a/trytond/model/modelstorage.py ++++ b/trytond/model/modelstorage.py +@@ -27,7 +27,8 @@ + from trytond.tools.domain_inversion import domain_inversion, eval_domain + from trytond.tools.domain_inversion import parse as domain_parse + from trytond.transaction import ( +- Transaction, inactive_records, record_cache_size, without_check_access) ++ Transaction, check_access, inactive_records, record_cache_size, ++ without_check_access) + + from . import fields + from .descriptors import dualmethod +@@ -1682,11 +1683,11 @@ + + if load_eager or multiple_getter: + FieldAccess = Pool().get('ir.model.field.access') +- fread_accesses = {} +- fread_accesses.update(FieldAccess.check(self.__name__, +- list(self._fields.keys()), 'read', access=True)) +- to_remove = set(x for x, y in fread_accesses.items() +- if not y and x != name) ++ fields_access = FieldAccess.get_access( ++ [self.__name__])[self.__name__] ++ to_remove = { ++ f for f, a in fields_access.items() if not a['read']} ++ to_remove.discard(name) + + def not_cached(item): + fname, field = item +@@ -2067,3 +2068,42 @@ + + + _pyson_encoder = PYSONEncoder() ++ ++ ++def ModelAccessProxy(record, context=None): ++ pool = Pool() ++ ModelAccess = pool.get('ir.model.access') ++ FieldAccess = pool.get('ir.model.field.access') ++ Rule = pool.get('ir.rule') ++ ++ model = record.__class__ ++ with Transaction().set_context(context), check_access(): ++ ModelAccess.check(model.__name__) ++ Rule.check(model.__name__, [record.id]) ++ ++ class _ModelAccessProxy: ++ __class__ = model ++ ++ def __init__(self, id): ++ self.id = id ++ ++ def __getattr__(self, name): ++ if name in model._fields: ++ with Transaction().set_context(context), check_access(): ++ FieldAccess.check(model.__name__, [name]) ++ value = getattr(record, name) ++ if isinstance(value, Model): ++ value = ModelAccessProxy(value, context) ++ elif isinstance(value, (list, tuple)): ++ value = [ ++ ModelAccessProxy(r, context) ++ if isinstance(r, Model) else r for r in value] ++ return value ++ ++ def __int__(self): ++ return int(self.id) ++ ++ def __str__(self): ++ return f'{model.__name__},{self.id}' ++ ++ return _ModelAccessProxy(record.id) +--- a/trytond/model/modelview.py ++++ b/trytond/model/modelview.py +@@ -407,10 +407,9 @@ + tree_root = tree.getroottree().getroot() + + # Find field without read access +- fread_accesses = FieldAccess.check(cls.__name__, +- list(cls._fields.keys()), 'read', access=True) +- fields_to_remove = set( +- x for x, y in fread_accesses.items() if not y) ++ fields_access = FieldAccess.get_access([cls.__name__])[cls.__name__] ++ fields_to_remove = { ++ f for f, a in fields_access.items() if not a['read']} + + # Find relation field without read access + for name, field in cls._fields.items(): +@@ -630,8 +629,8 @@ + button_groups = Button.get_groups(cls.__name__, button_name) + if ((button_groups and not groups & button_groups) + or (not button_groups +- and not ModelAccess.check( +- cls.__name__, 'write', raise_exception=False))): ++ and not ModelAccess.get_access( ++ [cls.__name__])[cls.__name__]['write'])): + states = states.copy() + states['readonly'] = True + element.set('states', encoder.encode(states)) +@@ -666,8 +665,8 @@ + action = None + if (not action + or not action.res_model +- or not ModelAccess.check( +- action.res_model, 'read', raise_exception=False)): ++ or not ModelAccess.get_access( ++ [action.res_model])[action.res_model]['read']): + element.tag = 'label' + colspan = element.attrib.get('colspan') + link_name = element.attrib['name'] +--- a/trytond/res/user.py ++++ b/trytond/res/user.py +@@ -634,6 +634,10 @@ + pool = Pool() + UserGroup = pool.get('res.user-res.group') + transaction = Transaction() ++ ++ if '_groups' in transaction.context: ++ return transaction.context['_groups'] ++ + user = transaction.user + groups = cls._get_groups_cache.get(user) + if groups is not None: +--- a/trytond/tests/test_access.py ++++ b/trytond/tests/test_access.py +@@ -3,6 +3,7 @@ + # this repository contains the full copyright notices and license terms. + import unittest + ++from trytond.model import ModelAccessProxy + from trytond.model.exceptions import AccessError + from trytond.pool import Pool + from trytond.tests.test_tryton import activate_module, with_transaction +@@ -354,6 +355,39 @@ + with self.assertRaises(AccessError): + TestAccess.search([], order=[('relate.value', 'ASC')]) + ++ @with_transaction() ++ def test_model_access_proxy(self): ++ "Test model access proxy" ++ pool = Pool() ++ Model = pool.get('ir.model') ++ ModelAccess = pool.get('ir.model.access') ++ TestAccess = pool.get(self.model_name) ++ record, = TestAccess.create([{}]) ++ model, = Model.search([('model', '=', self.model_name)]) ++ ModelAccess.create([{ ++ 'model': model.id, ++ 'perm_read': True, ++ }]) ++ ++ ModelAccessProxy(record, {}) ++ ++ @with_transaction() ++ def test_model_access_proxy_no_access(self): ++ "Test model access proxy without access" ++ pool = Pool() ++ Model = pool.get('ir.model') ++ ModelAccess = pool.get('ir.model.access') ++ TestAccess = pool.get(self.model_name) ++ record, = TestAccess.create([{}]) ++ model, = Model.search([('model', '=', self.model_name)]) ++ ModelAccess.create([{ ++ 'model': model.id, ++ 'perm_read': False, ++ }]) ++ ++ with self.assertRaises(AccessError): ++ ModelAccessProxy(record, {}) ++ + + class ModelAccessWriteTestCase(_ModelAccessTestCase): + _perm = 'perm_write' +@@ -1003,6 +1037,49 @@ + with self.assertRaises(AccessError): + TestAccess.search([('relate', 'child_of', 42, 'parent')]) + ++ @with_transaction() ++ def test_model_access_proxy(self): ++ "Test model access proxy" ++ pool = Pool() ++ Field = pool.get('ir.model.field') ++ FieldAccess = pool.get('ir.model.field.access') ++ TestAccess = pool.get('test.access') ++ record, = TestAccess.create([{'field1': "foo"}]) ++ field, = Field.search([ ++ ('model.model', '=', 'test.access'), ++ ('name', '=', 'field1'), ++ ]) ++ FieldAccess.create([{ ++ 'field': field.id, ++ 'perm_read': True, ++ }]) ++ ++ proxy = ModelAccessProxy(record, {}) ++ ++ self.assertEqual(proxy.field1, 'foo') ++ ++ @with_transaction() ++ def test_model_access_proxy_no_access(self): ++ "Test model access proxy without access" ++ pool = Pool() ++ Field = pool.get('ir.model.field') ++ FieldAccess = pool.get('ir.model.field.access') ++ TestAccess = pool.get('test.access') ++ record, = TestAccess.create([{'field1': "foo"}]) ++ field, = Field.search([ ++ ('model.model', '=', 'test.access'), ++ ('name', '=', 'field1'), ++ ]) ++ FieldAccess.create([{ ++ 'field': field.id, ++ 'perm_read': False, ++ }]) ++ ++ proxy = ModelAccessProxy(record, {}) ++ ++ with self.assertRaises(AccessError): ++ ModelAccessProxy(proxy.field1) ++ + + class ModelFieldAccessWriteTestCase(_ModelFieldAccessTestCase): + _perm = 'perm_write' +--- a/trytond/tests/test_rule.py ++++ b/trytond/tests/test_rule.py +@@ -3,6 +3,7 @@ + import json + import unittest + ++from trytond.model import ModelAccessProxy + from trytond.model.exceptions import AccessError + from trytond.pool import Pool + from trytond.tests.test_tryton import activate_module, with_transaction +@@ -708,3 +709,52 @@ + + with self.assertRaisesRegex(AccessError, "Field different from foo"): + TestRuleModel.read([test.id], ['name']) ++ ++ @with_transaction() ++ def test_model_access_proxy(self): ++ "Test model access proxy" ++ pool = Pool() ++ TestRule = pool.get('test.rule') ++ RuleGroup = pool.get('ir.rule.group') ++ Model = pool.get('ir.model') ++ ++ model, = Model.search([('model', '=', 'test.rule')]) ++ rule_group, = RuleGroup.create([{ ++ 'name': "Field different from foo", ++ 'model': model.id, ++ 'global_p': True, ++ 'perm_read': False, ++ 'rules': [('create', [{ ++ 'domain': json.dumps( ++ [('field', '!=', 'foo')]), ++ }])], ++ }]) ++ record, = TestRule.create([{'field': 'foo'}]) ++ ++ proxy = ModelAccessProxy(record, {}) ++ ++ self.assertEqual(proxy.field, 'foo') ++ ++ @with_transaction() ++ def test_model_access_proxy_no_access(self): ++ "Test model access proxy without access" ++ pool = Pool() ++ TestRule = pool.get('test.rule') ++ RuleGroup = pool.get('ir.rule.group') ++ Model = pool.get('ir.model') ++ ++ model, = Model.search([('model', '=', 'test.rule')]) ++ rule_group, = RuleGroup.create([{ ++ 'name': "Field different from foo", ++ 'model': model.id, ++ 'global_p': True, ++ 'perm_read': True, ++ 'rules': [('create', [{ ++ 'domain': json.dumps( ++ [('field', '!=', 'foo')]), ++ }])], ++ }]) ++ record, = TestRule.create([{'field': 'foo'}]) ++ ++ with self.assertRaises(AccessError): ++ ModelAccessProxy(record, {}) +--- a/trytond/transaction.py ++++ b/trytond/transaction.py +@@ -380,7 +380,9 @@ + + @property + def check_access(self): +- return self.context.get('_check_access', False) ++ return ( ++ self.context.get('_check_access', False) ++ and self.user != 0) + + @property + def active_records(self): +--- a/trytond/ir/email_.py ++++ b/trytond/ir/email_.py +@@ -19,7 +19,8 @@ + + from trytond.config import config + from trytond.i18n import gettext +-from trytond.model import EvalEnvironment, ModelSQL, ModelView, fields ++from trytond.model import ( ++ EvalEnvironment, ModelAccessProxy, ModelSQL, ModelView, fields) + from trytond.model.exceptions import AccessError, ValidationError + from trytond.pool import Pool + from trytond.pyson import Bool, Eval, PYSONDecoder +@@ -418,7 +419,7 @@ + def get(self, record): + pool = Pool() + Model = pool.get(self.model.model) +- record = Model(int(record)) ++ record = ModelAccessProxy(Model(int(record))) + + values = {} + for attr, key in [ diff -Nru tryton-server-7.0.30/debian/patches/08_restrict_weasyprint_protocol.patch tryton-server-7.0.30/debian/patches/08_restrict_weasyprint_protocol.patch --- tryton-server-7.0.30/debian/patches/08_restrict_weasyprint_protocol.patch 1970-01-01 00:00:00.000000000 +0000 +++ tryton-server-7.0.30/debian/patches/08_restrict_weasyprint_protocol.patch 2026-09-02 13:08:28.000000000 +0000 @@ -0,0 +1,27 @@ +Description: Forbid weasyprint to access local files + From https://discuss.tryton.org/t/security-release-for-issue-14947: + Cédric Krier has discovered that Tryton does not prevent weasyprint + to access local files when rendering HTML report to PDF. + + https://foss.heptapod.net/tryton/tryton/-/work_items/14947 + The weasyprint documentation states that it can be used to access local files. + We should probably use a custom fetcher which forbid file:// protocol. +Author: Cédric Krier +Last-Update: 2026-09-02 +Bug-Upstream: https://bugs.tryton.org/14907 +--- a/trytond/report/report.py ++++ b/trytond/report/report.py +@@ -386,7 +386,12 @@ + if (weasyprint + and input_format in {'html', 'xhtml'} + and output_format == 'pdf'): +- return output_format, weasyprint.HTML(string=data).write_pdf() ++ return output_format, weasyprint.HTML( ++ string=data, ++ url_fetcher=weasyprint.URLFetcher( ++ allowed_protocols={'http', 'https'}, ++ ), ++ ).write_pdf() + + if input_format == output_format and output_format in MIMETYPES: + return output_format, data diff -Nru tryton-server-7.0.30/debian/patches/series tryton-server-7.0.30/debian/patches/series --- tryton-server-7.0.30/debian/patches/series 2025-11-25 11:02:13.000000000 +0000 +++ tryton-server-7.0.30/debian/patches/series 2026-09-02 12:59:50.000000000 +0000 @@ -3,3 +3,6 @@ 03_traceback_in_RPC.patch 04_enforce_access_check_html_editor.patch 05_enforce_access_check_export_data.patch +06_restrict_genshi_evaluation.patch +07_enforce_access_right_on_email_template_records.patch +08_restrict_weasyprint_protocol.patch