diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG index 5db892bae3..f77c43658d 100644 --- a/trytond/CHANGELOG +++ b/trytond/CHANGELOG @@ -1,3 +1,4 @@ +* Add size limit for RPC arguments * Store the ignore search limit option of export * Make field_names optional in Model.default_get * Remove check_xml_record from ModelStorage diff --git a/trytond/doc/ref/rpc.rst b/trytond/doc/ref/rpc.rst index 00dd3b0190..5b43090dfd 100644 --- a/trytond/doc/ref/rpc.rst +++ b/trytond/doc/ref/rpc.rst @@ -4,7 +4,7 @@ RPC === -.. class:: RPC([readonly[, instantiate[, [decorator, result[, check_access[, unique[, fresh_session[, cache[, timeout]]]]]]]]]) +.. class:: RPC([readonly[, instantiate[, [decorator, result[, check_access[, unique[, fresh_session[, cache[, timeout[, size_limits]]]]]]]]]]) Define the behavior of Remote Procedure Call. @@ -52,6 +52,11 @@ Instance attributes are: ``0`` or ``None`` means no timeout is applied. Default is ``None``. +.. attribute:: RPC.size_limits + + A dictionary with size limits for the arguments. + The key of the dictionary define the position of the argument when it is an + integer, or a slice of arguments when it is a tuple. RPCCache -------- diff --git a/trytond/doc/topics/configuration.rst b/trytond/doc/topics/configuration.rst index b82436a6f9..2309e8fbde 100644 --- a/trytond/doc/topics/configuration.rst +++ b/trytond/doc/topics/configuration.rst @@ -337,6 +337,15 @@ depending on the parameters. Default: ``60`` +.. _config-request.records_limit: + +records_limit +~~~~~~~~~~~~~ + +The maximal number of records processed by requests. + +Default: ``None`` + .. _config-cache: cache diff --git a/trytond/trytond/model/dictschema.py b/trytond/trytond/model/dictschema.py index bb52dff337..f42426c408 100644 --- a/trytond/trytond/model/dictschema.py +++ b/trytond/trytond/model/dictschema.py @@ -14,6 +14,9 @@ from trytond.rpc import RPC from trytond.tools import slugify from trytond.transaction import Transaction +_request_timeout = config.getint('request', 'timeout', default=0) +_request_records_limit = config.getint('request', 'records_limit') + class DomainError(ValidationError): pass @@ -90,8 +93,16 @@ class DictSchemaMixin(object): def __setup__(cls): super(DictSchemaMixin, cls).__setup__() cls.__rpc__.update({ - 'get_keys': RPC(instantiate=0), - 'search_get_keys': RPC(), + 'get_keys': RPC( + instantiate=0, + size_limits={ + 0: _request_records_limit, + }), + 'search_get_keys': RPC( + size_limits={ + 1: _request_records_limit, + }, + timeout=_request_timeout), }) @staticmethod diff --git a/trytond/trytond/model/model.py b/trytond/trytond/model/model.py index 0587b0868d..2df691b422 100644 --- a/trytond/trytond/model/model.py +++ b/trytond/trytond/model/model.py @@ -56,7 +56,11 @@ class Model(URLMixin, PoolBase, metaclass=ModelMeta): cls.__string__ = humanize(cls.__name__) cls.__rpc__ = { 'default_get': RPC(cache=dict(seconds=5 * 60)), - 'fields_get': RPC(cache=dict(days=1)), + 'fields_get': RPC( + cache=dict(days=1), + size_limits={ + 1: 0, + }), 'pre_validate': RPC(instantiate=0), } cls.__access__ = set() diff --git a/trytond/trytond/model/modelsql.py b/trytond/trytond/model/modelsql.py index d0a2a16af4..b07f014ed0 100644 --- a/trytond/trytond/model/modelsql.py +++ b/trytond/trytond/model/modelsql.py @@ -34,6 +34,8 @@ from .modelstorage import ( ValidationError, is_leaf) from .modelview import ModelView +_request_records_limit = config.getint('request', 'records_limit') + class ForeignKeyError(ValidationError): pass @@ -352,7 +354,10 @@ class ModelSQL(ModelStorage): cls._order = [('id', None)] if issubclass(cls, ModelView): cls.__rpc__.update({ - 'history_revisions': RPC(), + 'history_revisions': RPC( + size_limits={ + 0: _request_records_limit, + }), }) if cls._history: history_table = cls.__table_history__() diff --git a/trytond/trytond/model/modelstorage.py b/trytond/trytond/model/modelstorage.py index 5583144dd1..e7a8e9ccaa 100644 --- a/trytond/trytond/model/modelstorage.py +++ b/trytond/trytond/model/modelstorage.py @@ -41,6 +41,7 @@ _cache_count_timeout = config.getint( _cache_count_clear = config.getint( 'cache', 'count_clear', default=1000) _request_timeout = config.getint('request', 'timeout', default=0) +_request_records_limit = config.getint('request', 'records_limit') def local_cache(Model, transaction=None): @@ -132,26 +133,74 @@ class ModelStorage(Model): super(ModelStorage, cls).__setup__() if issubclass(cls, ModelView): cls.__rpc__.update({ - 'create': RPC(readonly=False, + 'create': RPC( + readonly=False, + size_limits={ + 0: _request_records_limit, + }, result=lambda r: list(map(int, r))), - 'read': RPC(timeout=_request_timeout), - 'write': RPC(readonly=False, - instantiate=slice(0, None, 2)), - 'delete': RPC(readonly=False, instantiate=0), - 'copy': RPC(readonly=False, instantiate=0, unique=False, + 'read': RPC( + timeout=_request_timeout, + size_limits={ + 0: _request_records_limit, + }), + 'write': RPC( + readonly=False, + instantiate=slice(0, None, 2), + size_limits={ + (0, None, 2), _request_records_limit, + }), + 'delete': RPC( + readonly=False, + instantiate=0, + size_limits={ + (0, None, 2): _request_records_limit, + }), + 'copy': RPC( + readonly=False, + instantiate=0, + unique=False, + size_limits={ + 0: _request_records_limit, + }, result=lambda r: list(map(int, r))), 'search': RPC( result=lambda r: list(map(int, r)), + size_limits={ + 2: _request_records_limit, + }, timeout=_request_timeout), - 'search_count': RPC(timeout=_request_timeout), - 'search_read': RPC(timeout=_request_timeout), + 'search_count': RPC( + timeout=_request_timeout, + size_limits={ + 2: _request_records_limit, + }), + 'search_read': RPC( + timeout=_request_timeout, + size_limits={ + 2: _request_records_limit, + }), 'resources': RPC( - instantiate=0, unique=False, + instantiate=0, + unique=False, timeout=_request_timeout), - 'export_data_domain': RPC(timeout=_request_timeout), + 'export_data_domain': RPC( + timeout=_request_timeout, + size_limits={ + 4: _request_records_limit, + }), 'export_data': RPC( - instantiate=0, unique=False, timeout=_request_timeout), - 'import_data': RPC(readonly=False), + instantiate=0, + unique=False, + size_limits={ + 0: _request_records_limit, + }, + timeout=_request_timeout), + 'import_data': RPC( + readonly=False, + size_limits={ + 1: _request_records_limit, + }), }) if cls._log is None: cls._log = not cls.__name__.startswith('ir.') diff --git a/trytond/trytond/model/modelview.py b/trytond/trytond/model/modelview.py index c334fea285..4e52fb0fc9 100644 --- a/trytond/trytond/model/modelview.py +++ b/trytond/trytond/model/modelview.py @@ -6,6 +6,7 @@ from functools import wraps from lxml import etree from trytond.cache import Cache +from trytond.config import config from trytond.exceptions import UserError from trytond.i18n import gettext from trytond.pool import Pool @@ -19,6 +20,7 @@ from .fields import on_change_result from .model import Model __all__ = ['ModelView'] +_request_records_limit = config.getint('request', 'records_limit') class AccessButtonError(UserError): @@ -84,15 +86,24 @@ class ModelView(Model): @classmethod def __setup__(cls): super(ModelView, cls).__setup__() - cls.__rpc__['fields_view_get'] = RPC(cache=dict(days=1)) - cls.__rpc__['view_toolbar_get'] = RPC(cache=dict(days=1)) - cls.__rpc__['on_change'] = RPC(instantiate=0, result=on_change_result) - cls.__rpc__['on_change_with'] = RPC( - instantiate=0, result=on_change_result) - cls.__rpc__['on_change_notify'] = RPC(instantiate=0) - cls.__rpc__['on_scan_code'] = RPC( - instantiate=0, result=on_change_result) - cls.__rpc__['autocomplete'] = RPC() + cls.__rpc__.update({ + 'fields_view_get': RPC( + cache=dict(days=1), + size_limits={ + 2: 1, + }), + 'view_toolbar_get': RPC(cache=dict(days=1)), + 'on_change': RPC(instantiate=0, result=on_change_result), + 'on_change_with': RPC( + instantiate=0, result=on_change_result), + 'on_change_notify': RPC(instantiate=0), + 'on_scan_code': RPC( + instantiate=0, result=on_change_result), + 'autocomplete': RPC( + size_limits={ + 2: _request_records_limit, + }), + }) cls._buttons = {} fields_ = {} diff --git a/trytond/trytond/rpc.py b/trytond/trytond/rpc.py index 727f8f1f91..6d08ba1009 100644 --- a/trytond/trytond/rpc.py +++ b/trytond/trytond/rpc.py @@ -2,8 +2,10 @@ # this repository contains the full copyright notices and license terms. import copy import datetime as dt +from collections.abc import Sized from trytond.exceptions import TrytonException +from trytond.protocols.wrappers import HTTPStatus, abort from trytond.transaction import Transaction @@ -17,16 +19,19 @@ class RPC(object): check_access: If access right must be checked fresh_session: If a fresh session is required unique: Check instances are unique + timeout: The timeout in second + size_limits: A dictionary with size limits ''' __slots__ = ( 'readonly', 'instantiate', 'decorator', 'result', - 'check_access', 'fresh_session', 'unique', 'cache', 'timeout') + 'check_access', 'fresh_session', 'unique', 'cache', 'timeout', + 'size_limits') def __init__( self, readonly=True, instantiate=None, decorator=None, result=None, check_access=True, fresh_session=False, unique=True, cache=None, - timeout=None): + timeout=None, size_limits=None): self.readonly = readonly self.instantiate = instantiate self.decorator = decorator @@ -42,6 +47,7 @@ class RPC(object): cache = RPCCache(**cache) self.cache = cache self.timeout = timeout + self.size_limits = size_limits def convert(self, obj, *args, **kwargs): args = list(args) @@ -87,8 +93,34 @@ class RPC(object): args[self.instantiate] = instance(data) if self.check_access: context['_check_access'] = True + self._check_size_limits(args, kwargs) return args, kwargs, context, timestamp + def _check_size_limits(self, args, kwargs): + if not self.size_limits: + return + for arg_spec, limit in self.size_limits.items(): + if not limit: + continue + if isinstance(arg_spec, int): + try: + selection = [args[arg_spec]] + except IndexError: + continue + else: + selection = args[slice(*arg_spec)] + test = 0 + for arg in selection: + if isinstance(arg, Sized): + test += len(arg) + else: + try: + test += arg + except TypeError: + pass + if test > limit: + abort(HTTPStatus.REQUEST_ENTITY_TOO_LARGE) + def decorate(self, func): if self.decorator: func = self.decorator(func) diff --git a/trytond/trytond/tests/test_rpc.py b/trytond/trytond/tests/test_rpc.py index 4191eb3c87..c317e1b85d 100644 --- a/trytond/trytond/tests/test_rpc.py +++ b/trytond/trytond/tests/test_rpc.py @@ -2,6 +2,7 @@ # this repository contains the full copyright notices and license terms. from unittest.mock import DEFAULT, Mock, call +from trytond.protocols.wrappers import exceptions from trytond.rpc import RPC from trytond.tests.test_tryton import ( TestCase, activate_module, with_transaction) @@ -142,3 +143,47 @@ class RPCTestCase(TestCase): self.assertEqual( rpc_with_access.convert(None, {}), ([], {}, {'_check_access': True}, None)) + + @with_transaction() + def test_size_limits_integer(self): + "Test size_limits integer" + obj = Mock() + rpc = RPC(size_limits={0: 42}) + + rpc.convert(obj, list(range(10)), {}) + + with self.assertRaises(exceptions.RequestEntityTooLarge): + rpc.convert(obj, list(range(43)), {}) + + @with_transaction() + def test_size_limits_slice(self): + "Test size_limits slice" + obj = Mock() + rpc = RPC(size_limits={(0, None, 2): 42}) + + rpc.convert(obj, list(range(10)), None, list(range(10)), {}) + + with self.assertRaises(exceptions.RequestEntityTooLarge): + rpc.convert(obj, list(range(30)), None, list(range(30)), {}) + + @with_transaction() + def test_size_limits_integer_number(self): + "Test size_limits integer number" + obj = Mock() + rpc = RPC(size_limits={0: 42}) + + rpc.convert(obj, 10, {}) + + with self.assertRaises(exceptions.RequestEntityTooLarge): + rpc.convert(obj, 43, {}) + + @with_transaction() + def test_size_limits_slice_number(self): + "Test size_limits slice" + obj = Mock() + rpc = RPC(size_limits={(0, None, 2): 42}) + + rpc.convert(obj, 10, None, 10, {}) + + with self.assertRaises(exceptions.RequestEntityTooLarge): + rpc.convert(obj, 30, None, 30, {})