From b044addc53630d1dee48f287bdb8c62992137a02 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 1 Aug 2026 18:54:31 +0200 Subject: [PATCH] Add: [system] max_vevent_rrule_entries option to catch DoS by problematic RRULE early enough --- CHANGELOG.md | 1 + DOCUMENTATION.md | 9 +++++++++ config | 3 +++ radicale/__main__.py | 3 ++- radicale/app/__init__.py | 3 +++ radicale/app/base.py | 1 + radicale/app/put.py | 10 ++++++++-- radicale/config.py | 4 ++++ radicale/item/__init__.py | 5 +++-- radicale/storage/multifilesystem/base.py | 1 + radicale/storage/multifilesystem/get.py | 2 +- 11 files changed, 36 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aec3e5b6..8eae6c92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Fix: sharing/proppatch: reject in case of write-access but 'p' is in permissions * Fix: sharing/by-map: catch collection path without trailing / (supporting "pimsync") * Add: [report] max_expand_occurrence option to separate from max_freebusy_occurrence +* Add: [system] max_vevent_rrule_entries option to catch DoS by problematic RRULE early enough ## 3.7.7 * Fix: web plugin helpers httputils.serve_resource/serve_folder ignored their mimetypes and fallback_mimetype parameters and always used the built-in mapping, so custom web plugins could not serve additional file types with a correct Content-Type diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 40b5f605..bcbecf38 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -866,6 +866,15 @@ Limited to 80% of max_content_length to cover plain base64 encoded payload. Announced to clients requesting "max-resource-size" via PROPFIND. +##### max_vevent_rrule_entries + +_(>= 3.7.8)_ + +The maximum of generated entries of a rrule. + +Default: `10000` + + ##### timeout Socket timeout. (seconds) diff --git a/config b/config index 9f513301..be4f1e24 100644 --- a/config +++ b/config @@ -33,6 +33,9 @@ # Announced to clients requesting "max-resource-size" via PROPFIND #max_resource_size = 10000000 +# Max entries of a RRULE, limit the number to prevent DoS attacks. +#max_vevent_rrule_entries = 10000 + # Socket timeout (seconds) #timeout = 30 diff --git a/radicale/__main__.py b/radicale/__main__.py index de11db6c..910ec311 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -200,9 +200,10 @@ def run() -> None: if args_ns.verify_item: encoding = configuration.get("encoding", "stock") + max_vevent_rrule_entries = configuration.get("server", "max_vevent_rrule_entries") logger.info("Item verification start using 'stock' encoding: %s", encoding) try: - if not item.verify(args_ns.verify_item[0], encoding): + if not item.verify(args_ns.verify_item[0], encoding, max_vevent_rrule_entries): logger.critical("Item verification failed") sys.exit(1) except Exception as e: diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index 2e9bb96d..dab90c70 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -79,6 +79,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, _internal_server: bool _max_content_length: int _max_resource_size: int + _max_vevent_rrule_entries: int _auth_realm: str _auth_type: str _web_type: str @@ -120,6 +121,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead, self._max_resource_size = max_resource_size_limited else: logger.info("max_resource_size set to: %d bytes (%sbytes)", self._max_resource_size, utils.format_unit(self._max_resource_size, binary=True)) + self._max_vevent_rrule_entries = configuration.get("server", "max_vevent_rrule_entries") + logger.info("max_vevent_rrule_entries set to: %d", self._max_vevent_rrule_entries) self._bad_put_request_content = configuration.get("logging", "bad_put_request_content") logger.info("log bad put request content: %s", self._bad_put_request_content) self._request_header_on_debug = configuration.get("logging", "request_header_on_debug") diff --git a/radicale/app/base.py b/radicale/app/base.py index 70b23346..a0f79db3 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -122,6 +122,7 @@ class ApplicationBase: _sharing: sharing.BaseSharing _encoding: str _max_resource_size: int + _max_vevent_rrule_entries: int _permit_delete_collection: bool _permit_overwrite_collection: bool _strict_preconditions: bool diff --git a/radicale/app/put.py b/radicale/app/put.py index 1536c127..61bdfd4f 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -47,6 +47,7 @@ PRODID = u"-//Radicale//NONSGML Version " + utils.package_version("radicale") + def prepare(vobject_items: List[vobject.base.Component], path: str, content_type: str, permission: bool, parent_permission: bool, max_resource_size: int, + max_vevent_rrule_entries: int, tag: Optional[str] = None, write_whole_collection: Optional[bool] = None) -> Tuple[ Iterator[radicale_item.Item], # items @@ -73,7 +74,9 @@ def prepare(vobject_items: List[vobject.base.Component], path: str, try: if tag and write_whole_collection is not None: radicale_item.check_and_sanitize_items( - vobject_items, is_collection=write_whole_collection, tag=tag) + vobject_items, + max_vevent_rrule_entries=max_vevent_rrule_entries, + is_collection=write_whole_collection, tag=tag) if write_whole_collection and tag == "VCALENDAR": vobject_components: List[vobject.base.Component] = [] vobject_item, = vobject_items @@ -224,7 +227,9 @@ class ApplicationPartPut(ApplicationBase): vobject_items, path, content_type, bool(rights.intersect(access.permissions, "Ww")), bool(rights.intersect(access.parent_permissions, "w")), - self._max_resource_size) + self._max_resource_size, + self._max_vevent_rrule_entries, + ) with self._storage.acquire_lock("w", user, path=path, request="PUT"): item = next(iter(self._storage.discover(path)), None) @@ -289,6 +294,7 @@ class ApplicationPartPut(ApplicationBase): bool(rights.intersect(access.permissions, "Ww")), bool(rights.intersect(access.parent_permissions, "w")), self._max_resource_size, + self._max_vevent_rrule_entries, tag, write_whole_collection) props = prepared_props if prepared_exc_info: diff --git a/radicale/config.py b/radicale/config.py index 197eb2fc..4d84cc4e 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -254,6 +254,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "10000000", "help": "maximum size of resource (default: 10 Mbyte)", "type": positive_int}), + ("max_vevent_rrule_entries", { + "value": "10000", + "help": "maximum of RRULE entries (default: 10000)", + "type": positive_int}), ("timeout", { "value": "30", "help": "socket timeout", diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 11076d10..9aa92799 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -103,6 +103,7 @@ def predict_tag_of_whole_collection( def check_and_sanitize_items( vobject_items: List[vobject.base.Component], + max_vevent_rrule_entries: int, is_collection: bool = False, tag: str = "") -> None: """Check vobject items for common errors and add missing UIDs. @@ -387,7 +388,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str return math.floor(start.timestamp()), math.ceil(end.timestamp()) -def verify(file: str, encoding: str): +def verify(file: str, encoding: str, max_vevent_rrule_entries: int): logger.info("Verifying item: %s", file) with open(file, "rb") as f: content_raw = f.read() @@ -407,7 +408,7 @@ def verify(file: str, encoding: str): try: tag = radicale_item.predict_tag_of_whole_collection(vobject_items) if tag is not None: - radicale_item.check_and_sanitize_items(vobject_items, tag=tag) + radicale_item.check_and_sanitize_items(vobject_items, tag=tag, max_vevent_rrule_entries=max_vevent_rrule_entries) else: raise ValueError("collection tag cannot be predicted") except Exception as e: diff --git a/radicale/storage/multifilesystem/base.py b/radicale/storage/multifilesystem/base.py index a17209a0..37bf9525 100644 --- a/radicale/storage/multifilesystem/base.py +++ b/radicale/storage/multifilesystem/base.py @@ -106,6 +106,7 @@ class StorageBase(storage.BaseStorage): "logging", "storage_cache_actions_on_debug") self._max_resource_size = configuration.get( "server", "max_resource_size") + self._max_vevent_rrule_entries = configuration.get("server", "max_vevent_rrule_entries") def _get_collection_root_folder(self) -> str: return os.path.join(self._filesystem_folder, "collection-root") diff --git a/radicale/storage/multifilesystem/get.py b/radicale/storage/multifilesystem/get.py index f256885b..967d4e2e 100644 --- a/radicale/storage/multifilesystem/get.py +++ b/radicale/storage/multifilesystem/get.py @@ -124,7 +124,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock, vobject_items = radicale_item.read_components( raw_text.decode(self._encoding)) radicale_item.check_and_sanitize_items( - vobject_items, tag=self.tag) + vobject_items, tag=self.tag, max_vevent_rrule_entries=self._storage._max_vevent_rrule_entries) vobject_item, = vobject_items temp_item = radicale_item.Item( collection=self, vobject_item=vobject_item)