Add size limit for RPC arguments

Closes #10301
This commit is contained in:
Cédric Krier
2024-11-28 09:31:43 +01:00
parent 65d3f86149
commit 611198f9f1
10 changed files with 200 additions and 28 deletions
+1
View File
@@ -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
+6 -1
View File
@@ -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
--------
+9
View File
@@ -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
+13 -2
View File
@@ -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
+5 -1
View File
@@ -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()
+6 -1
View File
@@ -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__()
+61 -12
View File
@@ -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.')
+20 -9
View File
@@ -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_ = {}
+34 -2
View File
@@ -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)
+45
View File
@@ -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, {})