diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eae6c92..59d65adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +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 +* Add: [system] max_vevent_rrule_occurrence option to catch DoS by problematic RRULE early enough (workaround for missing protection in current vobject version) ## 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 70c8bdc7..66f0ac7b 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -866,12 +866,12 @@ 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 +##### max_vevent_rrule_occurrence _(>= 3.7.8)_ -The maximum of generated entries of an rrule of an vevent. -Large time frames of RRULE by COUNT or UNTIL could +The maximum of occurrence by an rrule of a vevent. +Large time frames defined in RRULE by COUNT or UNTIL could generate a lot of occurrences based on the time frame supplied. This setting limits the lookup to prevent potential denial of service attacks on large time frames. If the limit is reached, an HTTP error diff --git a/config b/config index be4f1e24..8a8024c7 100644 --- a/config +++ b/config @@ -33,8 +33,8 @@ # 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 +# Max occurrence by an RRULE, limit the number to prevent DoS attacks. +#max_vevent_rrule_occurrence = 10000 # Socket timeout (seconds) #timeout = 30 diff --git a/radicale/__main__.py b/radicale/__main__.py index 910ec311..2ea7935c 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -200,10 +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") + max_vevent_rrule_occurrence = configuration.get("server", "max_vevent_rrule_occurrence") logger.info("Item verification start using 'stock' encoding: %s", encoding) try: - if not item.verify(args_ns.verify_item[0], encoding, max_vevent_rrule_entries): + if not item.verify(args_ns.verify_item[0], encoding, max_vevent_rrule_occurrence): 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 dab90c70..1796e24d 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -79,7 +79,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, _internal_server: bool _max_content_length: int _max_resource_size: int - _max_vevent_rrule_entries: int + _max_vevent_rrule_occurrence: int _auth_realm: str _auth_type: str _web_type: str @@ -121,8 +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._max_vevent_rrule_occurrence = configuration.get("server", "max_vevent_rrule_occurrence") + logger.info("max_vevent_rrule_occurrence set to: %d", self._max_vevent_rrule_occurrence) 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 a0f79db3..f453f5cb 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -122,7 +122,7 @@ class ApplicationBase: _sharing: sharing.BaseSharing _encoding: str _max_resource_size: int - _max_vevent_rrule_entries: int + _max_vevent_rrule_occurrence: 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 61bdfd4f..b6898103 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -47,7 +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, + max_vevent_rrule_occurrence: int, tag: Optional[str] = None, write_whole_collection: Optional[bool] = None) -> Tuple[ Iterator[radicale_item.Item], # items @@ -75,7 +75,7 @@ def prepare(vobject_items: List[vobject.base.Component], path: str, if tag and write_whole_collection is not None: radicale_item.check_and_sanitize_items( vobject_items, - max_vevent_rrule_entries=max_vevent_rrule_entries, + max_vevent_rrule_occurrence=max_vevent_rrule_occurrence, is_collection=write_whole_collection, tag=tag) if write_whole_collection and tag == "VCALENDAR": vobject_components: List[vobject.base.Component] = [] @@ -228,7 +228,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, + self._max_vevent_rrule_occurrence, ) with self._storage.acquire_lock("w", user, path=path, request="PUT"): @@ -294,7 +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, + self._max_vevent_rrule_occurrence, tag, write_whole_collection) props = prepared_props if prepared_exc_info: diff --git a/radicale/config.py b/radicale/config.py index 4d84cc4e..ea94c2d5 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -254,9 +254,9 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "10000000", "help": "maximum size of resource (default: 10 Mbyte)", "type": positive_int}), - ("max_vevent_rrule_entries", { + ("max_vevent_rrule_occurrence", { "value": "10000", - "help": "maximum of RRULE entries (default: 10000)", + "help": "maximum occurrence by an RRULE (default: 10000)", "type": positive_int}), ("timeout", { "value": "30", diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 586fa4c3..5743e2cb 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -103,7 +103,7 @@ def predict_tag_of_whole_collection( def check_and_sanitize_items( vobject_items: List[vobject.base.Component], - max_vevent_rrule_entries: int, + max_vevent_rrule_occurrence: int, is_collection: bool = False, tag: str = "") -> None: """Check vobject items for common errors and add missing UIDs. @@ -417,7 +417,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, max_vevent_rrule_entries: int): +def verify(file: str, encoding: str, max_vevent_rrule_occurrence: int): logger.info("Verifying item: %s", file) with open(file, "rb") as f: content_raw = f.read() @@ -437,7 +437,7 @@ def verify(file: str, encoding: str, max_vevent_rrule_entries: int): 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, max_vevent_rrule_entries=max_vevent_rrule_entries) + radicale_item.check_and_sanitize_items(vobject_items, tag=tag, max_vevent_rrule_occurrence=max_vevent_rrule_occurrence) 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 37bf9525..ea9558a8 100644 --- a/radicale/storage/multifilesystem/base.py +++ b/radicale/storage/multifilesystem/base.py @@ -106,7 +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") + self._max_vevent_rrule_occurrence = configuration.get("server", "max_vevent_rrule_occurrence") 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 967d4e2e..7d5101fe 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, max_vevent_rrule_entries=self._storage._max_vevent_rrule_entries) + vobject_items, tag=self.tag, max_vevent_rrule_occurrence=self._storage._max_vevent_rrule_occurrence) vobject_item, = vobject_items temp_item = radicale_item.Item( collection=self, vobject_item=vobject_item) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index 983f08b7..87735a94 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -330,35 +330,42 @@ permissions: RrWw""") def test_add_event_with_rrule_count_500_limit_100(self) -> None: """Test event with RRULE COUNT=500 and limit 100.""" - self.configure({"server": {"max_vevent_rrule_entries": 100}}) + self.configure({"server": {"max_vevent_rrule_occurrence": 100}}) self.mkcalendar("/calendar.ics/") event = get_file_content("event_full_day_rrule_count_500.ics") self.put("/calendar.ics/event.ics", event, check=400) def test_add_event_with_rrule_count_500_limit_600(self) -> None: """Test event with RRULE COUNT=500 and limit 600.""" - self.configure({"server": {"max_vevent_rrule_entries": 600}}) + self.configure({"server": {"max_vevent_rrule_occurrence": 600}}) self.mkcalendar("/calendar.ics/") event = get_file_content("event_full_day_rrule_count_500.ics") self.put("/calendar.ics/event.ics", event) + def test_add_event_with_rrule_until_2y_limit_800(self) -> None: + """Test event with RRULE UNTIL=+2y and limit 800.""" + self.configure({"server": {"max_vevent_rrule_occurrence": 800}}) + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_full_day_rrule_until_2y.ics") + self.put("/calendar.ics/event.ics", event) + def test_add_event_with_rrule_until_2y_limit_100(self) -> None: """Test event with RRULE UNTIL=+2y and limit 100.""" - self.configure({"server": {"max_vevent_rrule_entries": 100}}) + self.configure({"server": {"max_vevent_rrule_occurrence": 100}}) self.mkcalendar("/calendar.ics/") event = get_file_content("event_full_day_rrule_until_2y.ics") self.put("/calendar.ics/event.ics", event, check=400) def test_add_event_with_rrule_until_50y_limit_100(self) -> None: """Test event with RRULE UNTIL=+50y and limit 100.""" - self.configure({"server": {"max_vevent_rrule_entries": 100}}) + self.configure({"server": {"max_vevent_rrule_occurrence": 100}}) self.mkcalendar("/calendar.ics/") event = get_file_content("event_full_day_rrule_until_50y.ics") self.put("/calendar.ics/event.ics", event, check=400) def test_add_event_with_rrule_until_5000y_limit_100(self) -> None: """Test event with RRULE UNTIL=+5000y and limit 100.""" - self.configure({"server": {"max_vevent_rrule_entries": 100}}) + self.configure({"server": {"max_vevent_rrule_occurrence": 100}}) self.mkcalendar("/calendar.ics/") event = get_file_content("event_full_day_rrule_until_5000y.ics") self.put("/calendar.ics/event.ics", event, check=400)