From 4a4e571cce5c74c41bfd6389a7f2fc2a8df5749e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 1 Aug 2026 18:52:08 +0200 Subject: [PATCH 01/21] Add: [report] max_expand_occurrence option to separate from max_freebusy_occurrence --- CHANGELOG.md | 1 + DOCUMENTATION.md | 13 +++++++++++++ config | 4 ++++ radicale/app/report.py | 3 ++- radicale/config.py | 6 +++++- radicale/tests/test_expand.py | 4 ++-- 6 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3351f7..aec3e5b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 3.7.8.dev * 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 ## 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 7bfa9e72..40b5f605 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2191,6 +2191,19 @@ This is an automated message. Please do not reply. #### [reporting] +##### max_expand_occurrence + +_(>= 3.7.8)_ + +When returning an expanded report, a list of occurrences are +generated based on a given time frame. Large time frames 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 +is thrown instead of returning the results. + +Default: 10000 + ##### max_freebusy_occurrence _(>= 3.2.3)_ diff --git a/config b/config index 2bd39383..9f513301 100644 --- a/config +++ b/config @@ -499,6 +499,10 @@ Content-Security-Policy = default-src 'self'; object-src 'none' [reporting] +# When returning an expanded report, limit the number of returned +# occurences per event to prevent DoS attacks. +#max_expand_occurrence = 10000 + # When returning a free-busy report, limit the number of returned # occurences per event to prevent DoS attacks. #max_freebusy_occurrence = 10000 diff --git a/radicale/app/report.py b/radicale/app/report.py index fab2e445..ed8a147d 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -895,9 +895,9 @@ class ApplicationPartReport(ApplicationBase): assert item.collection is not None collection = item.collection - max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence") if xml_content is not None and \ xml_content.tag == xmlutils.make_clark("C:free-busy-query"): + max_occurrence = self.configuration.get("reporting", "max_freebusy_occurrence") try: status, body = free_busy_report( base_prefix, path, xml_content, collection, self._encoding, @@ -909,6 +909,7 @@ class ApplicationPartReport(ApplicationBase): headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding} return status, headers, str(body), xmlutils.pretty_xml(xml_content) else: + max_occurrence = self.configuration.get("reporting", "max_expand_occurrence") try: status, xml_answer = xml_report( base_prefix, path, xml_content, collection, self._encoding, diff --git a/radicale/config.py b/radicale/config.py index 9554e4ff..197eb2fc 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -813,9 +813,13 @@ This is an automated message. Please do not reply.""", ("headers", OrderedDict([ ("_allow_extra", str)])), ("reporting", OrderedDict([ + ("max_expand_occurrence", { + "value": "10000", + "help": "number of expand occurrences per event when reporting", + "type": positive_int}), ("max_freebusy_occurrence", { "value": "10000", - "help": "number of occurrences per event when reporting", + "help": "number of free-busy occurrences per event when reporting", "type": positive_int})])) ]) diff --git a/radicale/tests/test_expand.py b/radicale/tests/test_expand.py index d4bf0b72..62178e40 100644 --- a/radicale/tests/test_expand.py +++ b/radicale/tests/test_expand.py @@ -326,7 +326,7 @@ permissions: RrWw""") def test_report_with_expand_property_max_occur(self) -> None: """Test report with expand property too many vevents""" - self.configure({"reporting": {"max_freebusy_occurrence": 100}}) + self.configure({"reporting": {"max_expand_occurrence": 100}}) self._test_expand_max( "event_daily_rrule_forever", "20060103T000000Z", @@ -336,7 +336,7 @@ permissions: RrWw""") def test_report_with_max_occur(self) -> None: """Test report with too many vevents""" - self.configure({"reporting": {"max_freebusy_occurrence": 10}}) + self.configure({"reporting": {"max_expand_occurrence": 10}}) uid = "event_multiple_too_many" start = "20130901T000000Z" From b044addc53630d1dee48f287bdb8c62992137a02 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 1 Aug 2026 18:54:31 +0200 Subject: [PATCH 02/21] 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) From bcb5061416f0a3dd06c813fe8338a0fbcdcf8da8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 1 Aug 2026 18:55:29 +0200 Subject: [PATCH 03/21] add support for max_vevent_rrule_entries --- radicale/item/__init__.py | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 9aa92799..586fa4c3 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -233,11 +233,40 @@ def check_and_sanitize_items( if ref_value_param is not None: dates.params["VALUE"] = ref_value_param # vobject interprets recurrence rules on demand - try: - component.rruleset - except Exception as e: - raise ValueError("Invalid recurrence rules in %s in object %r" - % (component.name, component_uid)) from e + if hasattr(component, "rrule"): + logger.trace("Recurrence rule found in %s in object %r: %r", component.name, component_uid, component.rrule.value) + # early check of maximum of COUNT to avoid DoS + pattern = re.compile('.*;COUNT=(\\d+).*') + match = pattern.match(component.rrule.value) + if match: + rrule_count = int(match[1]) + if max_vevent_rrule_entries > 0 and rrule_count > max_vevent_rrule_entries: + logger.error("Recurrence rule count in %s in object %r: %d (REJECTED/limit: %d)" % (component.name, component_uid, rrule_count, max_vevent_rrule_entries)) + raise ValueError("Too many recurrence rule entries in %s in object %r: %d (limit: %d)" + % (component.name, component_uid, rrule_count, max_vevent_rrule_entries)) + else: + logger.trace("Recurrence rule count in %s in object %r: %d (PASSED/limit: %d)" % (component.name, component_uid, rrule_count, max_vevent_rrule_entries)) + # generic check by vobject + try: + rruleset = component.rruleset + except Exception as e: + raise ValueError("Invalid recurrence rules in %s in object %r" + % (component.name, component_uid)) from e + # check limit after generation (e.g. UNTIL) + # TODO: find possibility to add also early check of UNTIL + infinite = False + if (";UNTIL=" not in component.rrule.value and + ";COUNT=" not in component.rrule.value): + infinite = True + + if infinite is False: + rrule_entries = len(list(rruleset)) + if max_vevent_rrule_entries > 0 and rrule_entries > max_vevent_rrule_entries: + logger.warning("Recurrence rule entries in %s in object %r: %d (limit: %d)" % (component.name, component_uid, rrule_entries, max_vevent_rrule_entries)) + raise ValueError("Too many recurrence rule entries in %s in object %r: %d (limit: %d)" + % (component.name, component_uid, rrule_entries, max_vevent_rrule_entries)) + else: + logger.trace("Recurrence rule entries in %s in object %r: %d" % (component.name, component_uid, rrule_entries)) elif tag == "VADDRESSBOOK": # https://tools.ietf.org/html/rfc6352#section-5.1 object_uids = set() From 320ad29424be8a172a34775d0963c426a33e5e8b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 1 Aug 2026 18:56:13 +0200 Subject: [PATCH 04/21] max_vevent_rrule_entries: test cases --- .../static/event_full_day_rrule_count_500.ics | 31 ++++++++++++++++ .../static/event_full_day_rrule_until_2y.ics | 31 ++++++++++++++++ .../event_full_day_rrule_until_5000y.ics | 31 ++++++++++++++++ .../static/event_full_day_rrule_until_50y.ics | 31 ++++++++++++++++ radicale/tests/test_base.py | 35 +++++++++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 radicale/tests/static/event_full_day_rrule_count_500.ics create mode 100644 radicale/tests/static/event_full_day_rrule_until_2y.ics create mode 100644 radicale/tests/static/event_full_day_rrule_until_5000y.ics create mode 100644 radicale/tests/static/event_full_day_rrule_until_50y.ics diff --git a/radicale/tests/static/event_full_day_rrule_count_500.ics b/radicale/tests/static/event_full_day_rrule_count_500.ics new file mode 100644 index 00000000..bdde555d --- /dev/null +++ b/radicale/tests/static/event_full_day_rrule_count_500.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102 +DTEND;TZID=US/Eastern:20060103 +RRULE:FREQ=DAILY;COUNT=500 +SUMMARY:Recurring event with count 500 +UID:event_full_day_rrule_count_500 +DTSTAMP:20060102T094829Z +END:VEVENT +END:VCALENDAR + diff --git a/radicale/tests/static/event_full_day_rrule_until_2y.ics b/radicale/tests/static/event_full_day_rrule_until_2y.ics new file mode 100644 index 00000000..aa5a6501 --- /dev/null +++ b/radicale/tests/static/event_full_day_rrule_until_2y.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102 +DTEND;TZID=US/Eastern:20060103 +RRULE:FREQ=DAILY;UNTIL=20080101 +SUMMARY:Recurring event with until +2y +UID:event_full_day_rrule_until_2y +DTSTAMP:20060102T094829Z +END:VEVENT +END:VCALENDAR + diff --git a/radicale/tests/static/event_full_day_rrule_until_5000y.ics b/radicale/tests/static/event_full_day_rrule_until_5000y.ics new file mode 100644 index 00000000..7296c199 --- /dev/null +++ b/radicale/tests/static/event_full_day_rrule_until_5000y.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102 +DTEND;TZID=US/Eastern:20060103 +RRULE:FREQ=DAILY;UNTIL=70060101 +SUMMARY:Recurring event with until +5000y +UID:event_full_day_rrule_until_5000y +DTSTAMP:20060102T094829Z +END:VEVENT +END:VCALENDAR + diff --git a/radicale/tests/static/event_full_day_rrule_until_50y.ics b/radicale/tests/static/event_full_day_rrule_until_50y.ics new file mode 100644 index 00000000..59580855 --- /dev/null +++ b/radicale/tests/static/event_full_day_rrule_until_50y.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102 +DTEND;TZID=US/Eastern:20060103 +RRULE:FREQ=DAILY;UNTIL=20560101 +SUMMARY:Recurring event with until +50y +UID:event_full_day_rrule_until_50y +DTSTAMP:20060102T094829Z +END:VEVENT +END:VCALENDAR + diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index 47f4ee2d..983f08b7 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -328,6 +328,41 @@ permissions: RrWw""") event = get_file_content("event_mixed_datetime_and_date.ics") self.put("/calendar.ics/event.ics", event) + 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.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.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_100(self) -> None: + """Test event with RRULE UNTIL=+2y and limit 100.""" + self.configure({"server": {"max_vevent_rrule_entries": 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.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.mkcalendar("/calendar.ics/") + event = get_file_content("event_full_day_rrule_until_5000y.ics") + self.put("/calendar.ics/event.ics", event, check=400) + def test_add_event_with_exdate_without_rrule(self) -> None: """Test event with EXDATE but not having RRULE.""" self.mkcalendar("/calendar.ics/") From 67dbf8b3c6b289bafacdb74112defd581b0a594e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 1 Aug 2026 18:56:37 +0200 Subject: [PATCH 05/21] max_expand_occurrence: additional test cases --- radicale/tests/test_expand.py | 50 ++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/radicale/tests/test_expand.py b/radicale/tests/test_expand.py index 62178e40..687ce599 100644 --- a/radicale/tests/test_expand.py +++ b/radicale/tests/test_expand.py @@ -128,7 +128,8 @@ permissions: RrWw""") expected_start_times: List[str], expected_end_times: List[str], only_dates: bool, - nr_uids: int) -> None: + nr_uids: int, + check: int = 207) -> None: _, responses = self.report("/calendar.ics/", self._req_without_expand(expected_uid, start, end)) assert len(responses) == 1 @@ -154,7 +155,9 @@ permissions: RrWw""") assert len(uids) == nr_uids _, responses = self.report("/calendar.ics/", - self._req_with_expand(expected_uid, start, end)) + self._req_with_expand(expected_uid, start, end), check=check) + if check != 207: + return assert len(responses) == 1 @@ -176,17 +179,21 @@ permissions: RrWw""") uids.append(line) if line.startswith("RECURRENCE-ID:"): - assert line in expected_recurrence_ids + if expected_recurrence_ids: + assert line in expected_recurrence_ids recurrence_ids.append(line) if line.startswith("DTSTART:"): - assert line in expected_start_times + if expected_start_times: + assert line in expected_start_times if line.startswith("DTEND:"): - assert line in expected_end_times + if expected_end_times: + assert line in expected_end_times - assert len(uids) == len(expected_recurrence_ids) - assert len(set(recurrence_ids)) == len(expected_recurrence_ids) + if expected_recurrence_ids: + assert len(uids) == len(expected_recurrence_ids) + assert len(set(recurrence_ids)) == len(expected_recurrence_ids) def _test_expand_max(self, expected_uid: str, @@ -289,6 +296,35 @@ permissions: RrWw""") 1 ) + def test_report_with_expand_property_all_day_count_500_event_pass(self) -> None: + """Test report with expand property for all day count 500 events""" + self.configure({"reporting": {"max_expand_occurrence": 501}}) + self._test_expand( + "event_full_day_rrule_count_500", + "20060103T000000Z", + "20080105T000000Z", + [], + [], + [], + ONLY_DATES, + 1 + ) + + def test_report_with_expand_property_all_day_count_500_event_reject(self) -> None: + """Test report with expand property for all day count 500 events""" + self.configure({"reporting": {"max_expand_occurrence": 10}}) + self._test_expand( + "event_full_day_rrule_count_500", + "20060103T000000Z", + "20080105T000000Z", + [], + [], + [], + ONLY_DATES, + 1, + 400 + ) + def test_report_with_expand_property_overridden(self) -> None: """Test report with expand property with overridden events""" self._test_expand( From fc1e53cb473cde75bb4285eaacd8b76798756620 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 1 Aug 2026 21:28:04 +0200 Subject: [PATCH 06/21] update doc --- DOCUMENTATION.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index bcbecf38..70c8bdc7 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -870,11 +870,15 @@ Announced to clients requesting "max-resource-size" via PROPFIND. _(>= 3.7.8)_ -The maximum of generated entries of a rrule. +The maximum of generated entries of an rrule of an vevent. +Large time frames of 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 +is thrown instead of accepting the item. Default: `10000` - ##### timeout Socket timeout. (seconds) From 953aebc85fc3998fa84f9fea2a7c772ac0781a11 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 10:58:13 +0200 Subject: [PATCH 07/21] max_vevent_rrule_occurrence: rename option --- CHANGELOG.md | 2 +- DOCUMENTATION.md | 6 +++--- config | 4 ++-- radicale/__main__.py | 4 ++-- radicale/app/__init__.py | 6 +++--- radicale/app/base.py | 2 +- radicale/app/put.py | 8 ++++---- radicale/config.py | 4 ++-- radicale/item/__init__.py | 6 +++--- radicale/storage/multifilesystem/base.py | 2 +- radicale/storage/multifilesystem/get.py | 2 +- radicale/tests/test_base.py | 17 ++++++++++++----- 12 files changed, 35 insertions(+), 28 deletions(-) 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) From 4187811bc257c40ea826bf91423c6f7b0dbacfa1 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 10:58:58 +0200 Subject: [PATCH 08/21] max_vevent_rrule_occurrence: improve logging, catch FREQ+UNTIL --- radicale/item/__init__.py | 68 ++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 5743e2cb..b8a28f29 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -53,6 +53,17 @@ VCF_TO_ICS_SUPPORTED_PLACEHOLDERS: list = ["fn", "n:f", "n:g", "n:a", "age", "ni # List of BDAY years acting as flag for "no year specified" VCF_TO_ICS_BDAY_NO_YEAR: list = ["1604"] +# List of RRULE frequencies and their factor related to 1 second +RRULE_FREQUENCIES_TO_DAY: dict[str, float] = { + "YEARLY": 365*60*60*24, + "MONTHLY": 365/30*60*60*24, + "WEEKLY": 60*60*24*7, + "DAILY": 60*60*24, + "HOURLY": 60*60, + "MINUTELY": 60, + "SECONDLY": 1, + } + def read_components(s: str) -> List[vobject.base.Component]: """Wrapper for vobject.readComponents""" @@ -234,26 +245,59 @@ def check_and_sanitize_items( dates.params["VALUE"] = ref_value_param # vobject interprets recurrence rules on demand if hasattr(component, "rrule"): + # workaround for vobject < 1.0.0 as it has no limiter in "getrruleset" logger.trace("Recurrence rule found in %s in object %r: %r", component.name, component_uid, component.rrule.value) - # early check of maximum of COUNT to avoid DoS - pattern = re.compile('.*;COUNT=(\\d+).*') + # early check of maximum of COUNT to avoid DoS (semi-ugly workaround) + pattern = re.compile('.*;COUNT=(\\d+)(;.*)?$') match = pattern.match(component.rrule.value) if match: rrule_count = int(match[1]) - if max_vevent_rrule_entries > 0 and rrule_count > max_vevent_rrule_entries: - logger.error("Recurrence rule count in %s in object %r: %d (REJECTED/limit: %d)" % (component.name, component_uid, rrule_count, max_vevent_rrule_entries)) + if max_vevent_rrule_occurrence > 0 and rrule_count > max_vevent_rrule_occurrence: + logger.error("Recurrence rule %r count in %s in object %r: %d (REJECTED/limit: %d)", component.rrule.value, component.name, component_uid, rrule_count, max_vevent_rrule_occurrence) raise ValueError("Too many recurrence rule entries in %s in object %r: %d (limit: %d)" - % (component.name, component_uid, rrule_count, max_vevent_rrule_entries)) + % (component.name, component_uid, rrule_count, max_vevent_rrule_occurrence)) else: - logger.trace("Recurrence rule count in %s in object %r: %d (PASSED/limit: %d)" % (component.name, component_uid, rrule_count, max_vevent_rrule_entries)) + logger.trace("Recurrence rule count in %s in object %r: %d (PASSED/limit: %d)" % (component.name, component_uid, rrule_count, max_vevent_rrule_occurrence)) + if hasattr(component, "dtstart"): + # early check of maximum of (UNTIL-DTSTART)/FREQ to avoid DoS (ugly workaround with some guessing) + pattern = re.compile('FREQ=([A-Z]+)(;.*)?$') + match = pattern.match(component.rrule.value) + if match and match[1] in RRULE_FREQUENCIES_TO_DAY: + # RRULE has known FREQ + freq = match[1] + logger.trace("Recurrence rule found with FREQ: %s", freq) + pattern = re.compile('.*;UNTIL=([\\dTZ]+)(;.*)?$') + match = pattern.match(component.rrule.value) + dtstart = radicale_filter.date_to_datetime(component.dtstart.value) + if match: + # RRULE has UNTIL + ignoretz = ( + not isinstance(dtstart, datetime.datetime) + or dtstart.tzinfo is None + ) + until = vobject.icalendar.rrule.rrulestr(component.rrule.value, ignoretz=ignoretz)._until + if dtstart.tzinfo is not None: + until = until.astimezone(dtstart.tzinfo) + logger.trace("Recurrence rule found with UNTIL: %s", until) + logger.trace("DTSTART found: %s", dtstart) + delta = until - dtstart + seconds = delta.total_seconds() + logger.trace("delta in seconds: %d", seconds) + rrule_entries = seconds / RRULE_FREQUENCIES_TO_DAY[freq] + logger.trace("estimated rule entries: %d", rrule_entries) + if max_vevent_rrule_occurrence > 0 and rrule_entries > max_vevent_rrule_occurrence: + logger.warning("Recurrence rule %r entries in %s in object %r: %d (estimated/REJECTED/limit: %d)", component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence) + raise ValueError("Too many recurrence rule entries in %s in object %r: %d (limit: %d)" + % (component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence)) + else: + logger.trace("Recurrence rule %r entries in %s in object %r: %d (estimated/PASSED/limit: %d)" % (component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence)) # generic check by vobject try: rruleset = component.rruleset except Exception as e: raise ValueError("Invalid recurrence rules in %s in object %r" % (component.name, component_uid)) from e - # check limit after generation (e.g. UNTIL) - # TODO: find possibility to add also early check of UNTIL + # check limit (last resort) infinite = False if (";UNTIL=" not in component.rrule.value and ";COUNT=" not in component.rrule.value): @@ -261,12 +305,12 @@ def check_and_sanitize_items( if infinite is False: rrule_entries = len(list(rruleset)) - if max_vevent_rrule_entries > 0 and rrule_entries > max_vevent_rrule_entries: - logger.warning("Recurrence rule entries in %s in object %r: %d (limit: %d)" % (component.name, component_uid, rrule_entries, max_vevent_rrule_entries)) + if max_vevent_rrule_occurrence > 0 and rrule_entries > max_vevent_rrule_occurrence: + logger.warning("Recurrence rule %r entries in %s in object %r: %d (calculated/REJECTED/limit: %d)", component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence) raise ValueError("Too many recurrence rule entries in %s in object %r: %d (limit: %d)" - % (component.name, component_uid, rrule_entries, max_vevent_rrule_entries)) + % (component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence)) else: - logger.trace("Recurrence rule entries in %s in object %r: %d" % (component.name, component_uid, rrule_entries)) + logger.trace("Recurrence rule %r entries in %s in object %r: %d (calculated/PASSED/limit: %d)", component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence) elif tag == "VADDRESSBOOK": # https://tools.ietf.org/html/rfc6352#section-5.1 object_uids = set() From 202c9396ce21349c0dbbd8a8e9ebd2873f2a5aed Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 11:35:45 +0200 Subject: [PATCH 09/21] log cosmetics --- radicale/item/__init__.py | 19 ++++++++++--------- radicale/tests/test_base.py | 6 ++++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index b8a28f29..89f17cdc 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -53,8 +53,8 @@ VCF_TO_ICS_SUPPORTED_PLACEHOLDERS: list = ["fn", "n:f", "n:g", "n:a", "age", "ni # List of BDAY years acting as flag for "no year specified" VCF_TO_ICS_BDAY_NO_YEAR: list = ["1604"] -# List of RRULE frequencies and their factor related to 1 second -RRULE_FREQUENCIES_TO_DAY: dict[str, float] = { +# List of RRULE frequencies and their interval in seconds +RRULE_FREQUENCIES_TO_INTERVAL: dict[str, float] = { "YEARLY": 365*60*60*24, "MONTHLY": 365/30*60*60*24, "WEEKLY": 60*60*24*7, @@ -259,13 +259,12 @@ def check_and_sanitize_items( else: logger.trace("Recurrence rule count in %s in object %r: %d (PASSED/limit: %d)" % (component.name, component_uid, rrule_count, max_vevent_rrule_occurrence)) if hasattr(component, "dtstart"): - # early check of maximum of (UNTIL-DTSTART)/FREQ to avoid DoS (ugly workaround with some guessing) + # early check of maximum of (UNTIL-DTSTART)/interval(FREQ) to avoid DoS (ugly workaround with some guessing) pattern = re.compile('FREQ=([A-Z]+)(;.*)?$') match = pattern.match(component.rrule.value) - if match and match[1] in RRULE_FREQUENCIES_TO_DAY: + if match and match[1] in RRULE_FREQUENCIES_TO_INTERVAL: # RRULE has known FREQ freq = match[1] - logger.trace("Recurrence rule found with FREQ: %s", freq) pattern = re.compile('.*;UNTIL=([\\dTZ]+)(;.*)?$') match = pattern.match(component.rrule.value) dtstart = radicale_filter.date_to_datetime(component.dtstart.value) @@ -278,12 +277,14 @@ def check_and_sanitize_items( until = vobject.icalendar.rrule.rrulestr(component.rrule.value, ignoretz=ignoretz)._until if dtstart.tzinfo is not None: until = until.astimezone(dtstart.tzinfo) - logger.trace("Recurrence rule found with UNTIL: %s", until) - logger.trace("DTSTART found: %s", dtstart) delta = until - dtstart seconds = delta.total_seconds() - logger.trace("delta in seconds: %d", seconds) - rrule_entries = seconds / RRULE_FREQUENCIES_TO_DAY[freq] + if seconds < 0: + # UNTIL < DTSTART + logger.error("Recurrence rule %r in %s in object %r REJECTED, UNTIL is in the past", component.rrule.value, component.name, component_uid) + raise ValueError("Recurrence rule in %s in object %r has UNTIL in the past" + % (component.name, component_uid)) + rrule_entries = seconds / RRULE_FREQUENCIES_TO_INTERVAL[freq] logger.trace("estimated rule entries: %d", rrule_entries) if max_vevent_rrule_occurrence > 0 and rrule_entries > max_vevent_rrule_occurrence: logger.warning("Recurrence rule %r entries in %s in object %r: %d (estimated/REJECTED/limit: %d)", component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index 87735a94..a6eec5ae 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -356,6 +356,12 @@ permissions: RrWw""") 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_in_the_past(self) -> None: + """Test event with RRULE UNTIL=in-the-past.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_full_day_rrule_until_in_the_past.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_occurrence": 100}}) From e430e7984cff184f896de1913e0de9a9372ad17c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 11:39:02 +0200 Subject: [PATCH 10/21] add missing test case --- ...event_full_day_rrule_until_in_the_past.ics | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 radicale/tests/static/event_full_day_rrule_until_in_the_past.ics diff --git a/radicale/tests/static/event_full_day_rrule_until_in_the_past.ics b/radicale/tests/static/event_full_day_rrule_until_in_the_past.ics new file mode 100644 index 00000000..e460d186 --- /dev/null +++ b/radicale/tests/static/event_full_day_rrule_until_in_the_past.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102 +DTEND;TZID=US/Eastern:20060103 +RRULE:FREQ=DAILY;UNTIL=20050101 +SUMMARY:Recurring event with until in the past +UID:event_full_day_rrule_until_in_the_past +DTSTAMP:20060102T094829Z +END:VEVENT +END:VCALENDAR + From 432a599a130828c0cfcbecfd23ac505ebcf4394e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 15:12:48 +0200 Subject: [PATCH 11/21] max_vevent_rrule_occurrence: change loglevel, align test ics name --- radicale/item/__init__.py | 10 +++++----- ...s => event_full_day_rrule_until_before_dtstart.ics} | 0 radicale/tests/test_base.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) rename radicale/tests/static/{event_full_day_rrule_until_in_the_past.ics => event_full_day_rrule_until_before_dtstart.ics} (100%) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 89f17cdc..26c93849 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -257,7 +257,7 @@ def check_and_sanitize_items( raise ValueError("Too many recurrence rule entries in %s in object %r: %d (limit: %d)" % (component.name, component_uid, rrule_count, max_vevent_rrule_occurrence)) else: - logger.trace("Recurrence rule count in %s in object %r: %d (PASSED/limit: %d)" % (component.name, component_uid, rrule_count, max_vevent_rrule_occurrence)) + logger.debug("Recurrence rule count in %s in object %r: %d (PASSED/limit: %d)" % (component.name, component_uid, rrule_count, max_vevent_rrule_occurrence)) if hasattr(component, "dtstart"): # early check of maximum of (UNTIL-DTSTART)/interval(FREQ) to avoid DoS (ugly workaround with some guessing) pattern = re.compile('FREQ=([A-Z]+)(;.*)?$') @@ -281,8 +281,8 @@ def check_and_sanitize_items( seconds = delta.total_seconds() if seconds < 0: # UNTIL < DTSTART - logger.error("Recurrence rule %r in %s in object %r REJECTED, UNTIL is in the past", component.rrule.value, component.name, component_uid) - raise ValueError("Recurrence rule in %s in object %r has UNTIL in the past" + logger.error("Recurrence rule %r in %s in object %r REJECTED, UNTIL < DTSTART", component.rrule.value, component.name, component_uid) + raise ValueError("Recurrence rule in %s in object %r has UNTIL < DTSTART" % (component.name, component_uid)) rrule_entries = seconds / RRULE_FREQUENCIES_TO_INTERVAL[freq] logger.trace("estimated rule entries: %d", rrule_entries) @@ -291,7 +291,7 @@ def check_and_sanitize_items( raise ValueError("Too many recurrence rule entries in %s in object %r: %d (limit: %d)" % (component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence)) else: - logger.trace("Recurrence rule %r entries in %s in object %r: %d (estimated/PASSED/limit: %d)" % (component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence)) + logger.debug("Recurrence rule %r entries in %s in object %r: %d (estimated/PASSED/limit: %d)" % (component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence)) # generic check by vobject try: rruleset = component.rruleset @@ -311,7 +311,7 @@ def check_and_sanitize_items( raise ValueError("Too many recurrence rule entries in %s in object %r: %d (limit: %d)" % (component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence)) else: - logger.trace("Recurrence rule %r entries in %s in object %r: %d (calculated/PASSED/limit: %d)", component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence) + logger.debug("Recurrence rule %r entries in %s in object %r: %d (calculated/PASSED/limit: %d)", component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence) elif tag == "VADDRESSBOOK": # https://tools.ietf.org/html/rfc6352#section-5.1 object_uids = set() diff --git a/radicale/tests/static/event_full_day_rrule_until_in_the_past.ics b/radicale/tests/static/event_full_day_rrule_until_before_dtstart.ics similarity index 100% rename from radicale/tests/static/event_full_day_rrule_until_in_the_past.ics rename to radicale/tests/static/event_full_day_rrule_until_before_dtstart.ics diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index a6eec5ae..59976b0d 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -356,10 +356,10 @@ permissions: RrWw""") 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_in_the_past(self) -> None: - """Test event with RRULE UNTIL=in-the-past.""" + def test_add_event_with_rrule_until_before_dtstart(self) -> None: + """Test event with RRULE UNTIL < DTSTART.""" self.mkcalendar("/calendar.ics/") - event = get_file_content("event_full_day_rrule_until_in_the_past.ics") + event = get_file_content("event_full_day_rrule_until_before_dtstart.ics") self.put("/calendar.ics/event.ics", event, check=400) def test_add_event_with_rrule_until_50y_limit_100(self) -> None: From d6c245f4570e363e6572cc4b3e499341ce85778c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 15:16:22 +0200 Subject: [PATCH 12/21] event test: add y2040 test --- radicale/tests/static/event1_y2040.ics | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 radicale/tests/static/event1_y2040.ics diff --git a/radicale/tests/static/event1_y2040.ics b/radicale/tests/static/event1_y2040.ics new file mode 100644 index 00000000..eff13c8f --- /dev/null +++ b/radicale/tests/static/event1_y2040.ics @@ -0,0 +1,35 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20130902T150157Z +LAST-MODIFIED:20130902T150158Z +DTSTAMP:20130902T150158Z +UID:event1-Y2040 +SUMMARY:Event in year 2040 +CATEGORIES:some_category1,another_category2 +ORGANIZER:mailto:unclesam@example.com +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com +ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com +DTSTART;TZID=Europe/Paris:20400901T180000 +DTEND;TZID=Europe/Paris:20400901T190000 +END:VEVENT +END:VCALENDAR From 1b05af42338fc4af0784913e669cc2d18e500d6d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 15:16:46 +0200 Subject: [PATCH 13/21] test_add_event_y2040 --- radicale/tests/test_base.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index 59976b0d..2048cbae 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -151,6 +151,19 @@ permissions: RrWw""") assert "Event" in answer assert "UID:event" in answer + def test_add_event_y2040(self) -> None: + """Add an event with year 2040.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1_y2040.ics") + path = "/calendar.ics/event1_y2040.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + assert "ETag" in headers + assert headers["Content-Type"] == "text/calendar; charset=utf-8" + assert "VEVENT" in answer + assert "Event" in answer + assert "UID:event" in answer + @pytest.mark.skipif(not utils.vobject_supports_period(), reason="vobject <= 0.9.9 does not support PERIOD") def test_add_event_with_rdate_period_start_duration_single(self) -> None: """Add an event with RDATE/PERIOD with start+duration.""" From c0b163f778439e7613027b3b08550adaa45d51a9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 15:26:18 +0200 Subject: [PATCH 14/21] align destination event names --- radicale/tests/test_base.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index 2048cbae..35a68272 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -346,48 +346,48 @@ permissions: RrWw""") 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) + self.put("/calendar.ics/event_full_day_rrule_count_500.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_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) + self.put("/calendar.ics/event_full_day_rrule_count_500.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_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) + self.put("/calendar.ics/event_full_day_rrule_until_2y.ics", event, check=400) + + 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_full_day_rrule_until_2y.ics", event) def test_add_event_with_rrule_until_before_dtstart(self) -> None: """Test event with RRULE UNTIL < DTSTART.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event_full_day_rrule_until_before_dtstart.ics") - self.put("/calendar.ics/event.ics", event, check=400) + self.put("/calendar.ics/event_full_day_rrule_until_before_dtstart.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_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) + self.put("/calendar.ics/event_full_day_rrule_until_before_dtstart.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_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) + self.put("/calendar.ics/event_full_day_rrule_until_5000y.ics", event, check=400) def test_add_event_with_exdate_without_rrule(self) -> None: """Test event with EXDATE but not having RRULE.""" From 7cbf418d3454437b65d5c62df8faaaa5375064b0 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 16:03:06 +0200 Subject: [PATCH 15/21] fix test case item name --- radicale/tests/test_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index 35a68272..edc816a8 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -380,7 +380,7 @@ permissions: RrWw""") 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_full_day_rrule_until_before_dtstart.ics", event, check=400) + self.put("/calendar.ics/event_full_day_rrule_until_50y.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.""" From 490fbbf69d5ec35241d471e9616572beaba2e573 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 16:03:25 +0200 Subject: [PATCH 16/21] max_vevent_rrule_occurrence: improve pre-check --- radicale/item/__init__.py | 86 +++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 26c93849..975e4e7a 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -247,51 +247,51 @@ def check_and_sanitize_items( if hasattr(component, "rrule"): # workaround for vobject < 1.0.0 as it has no limiter in "getrruleset" logger.trace("Recurrence rule found in %s in object %r: %r", component.name, component_uid, component.rrule.value) - # early check of maximum of COUNT to avoid DoS (semi-ugly workaround) - pattern = re.compile('.*;COUNT=(\\d+)(;.*)?$') - match = pattern.match(component.rrule.value) - if match: - rrule_count = int(match[1]) - if max_vevent_rrule_occurrence > 0 and rrule_count > max_vevent_rrule_occurrence: - logger.error("Recurrence rule %r count in %s in object %r: %d (REJECTED/limit: %d)", component.rrule.value, component.name, component_uid, rrule_count, max_vevent_rrule_occurrence) + if not hasattr(component, "dtstart"): + # e.g. VTODO + rrule = vobject.icalendar.rrule.rrulestr(component.rrule.value) + else: + dtstart = radicale_filter.date_to_datetime(component.dtstart.value) + ignoretz = ( + not isinstance(dtstart, datetime.datetime) + or dtstart.tzinfo is None + ) + rrule = vobject.icalendar.rrule.rrulestr(component.rrule.value, ignoretz=ignoretz) + # early check of maximum of COUNT to avoid DoS (workaround) + if hasattr(rrule, "_count") and rrule._count is not None: + logger.trace("Recurrence rule %r in %s in object %r contains: COUNT=%d", component.rrule.value, component.name, component_uid, rrule._count) + if max_vevent_rrule_occurrence > 0 and rrule._count > max_vevent_rrule_occurrence: + logger.error("Recurrence rule %r count in %s in object %r: %d (REJECTED/limit: %d)", component.rrule.value, component.name, component_uid, rrule._count, max_vevent_rrule_occurrence) raise ValueError("Too many recurrence rule entries in %s in object %r: %d (limit: %d)" - % (component.name, component_uid, rrule_count, max_vevent_rrule_occurrence)) + % (component.name, component_uid, rrule._count, max_vevent_rrule_occurrence)) else: - logger.debug("Recurrence rule count in %s in object %r: %d (PASSED/limit: %d)" % (component.name, component_uid, rrule_count, max_vevent_rrule_occurrence)) - if hasattr(component, "dtstart"): - # early check of maximum of (UNTIL-DTSTART)/interval(FREQ) to avoid DoS (ugly workaround with some guessing) - pattern = re.compile('FREQ=([A-Z]+)(;.*)?$') - match = pattern.match(component.rrule.value) - if match and match[1] in RRULE_FREQUENCIES_TO_INTERVAL: - # RRULE has known FREQ - freq = match[1] - pattern = re.compile('.*;UNTIL=([\\dTZ]+)(;.*)?$') - match = pattern.match(component.rrule.value) - dtstart = radicale_filter.date_to_datetime(component.dtstart.value) - if match: - # RRULE has UNTIL - ignoretz = ( - not isinstance(dtstart, datetime.datetime) - or dtstart.tzinfo is None - ) - until = vobject.icalendar.rrule.rrulestr(component.rrule.value, ignoretz=ignoretz)._until - if dtstart.tzinfo is not None: - until = until.astimezone(dtstart.tzinfo) - delta = until - dtstart - seconds = delta.total_seconds() - if seconds < 0: - # UNTIL < DTSTART - logger.error("Recurrence rule %r in %s in object %r REJECTED, UNTIL < DTSTART", component.rrule.value, component.name, component_uid) - raise ValueError("Recurrence rule in %s in object %r has UNTIL < DTSTART" - % (component.name, component_uid)) - rrule_entries = seconds / RRULE_FREQUENCIES_TO_INTERVAL[freq] - logger.trace("estimated rule entries: %d", rrule_entries) - if max_vevent_rrule_occurrence > 0 and rrule_entries > max_vevent_rrule_occurrence: - logger.warning("Recurrence rule %r entries in %s in object %r: %d (estimated/REJECTED/limit: %d)", component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence) - raise ValueError("Too many recurrence rule entries in %s in object %r: %d (limit: %d)" - % (component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence)) - else: - logger.debug("Recurrence rule %r entries in %s in object %r: %d (estimated/PASSED/limit: %d)" % (component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence)) + logger.debug("Recurrence rule count in %s in object %r: %d (PASSED/limit: %d)" % (component.name, component_uid, rrule._count, max_vevent_rrule_occurrence)) + else: + logger.trace("Recurrence rule %r in %s in object %r doesn't contain: COUNT", component.rrule.value, component.name, component_uid) + # early check of maximum of (UNTIL-DTSTART)/interval(FREQ) to avoid DoS (ugly workaround with some guessing) + if hasattr(rrule, "_freq") and rrule._freq is not None and hasattr(rrule, "_until") and rrule._until is not None and hasattr(component, "dtstart"): + if vobject.icalendar.FREQUENCIES[rrule._freq] not in RRULE_FREQUENCIES_TO_INTERVAL: + raise ValueError("Unsupported FREQ in recurrence rule in %s in object %r: %r" + % (component.name, component_uid, rrule._freq)) + # RRULE has known FREQ+UNTIL+DTSTART + if dtstart.tzinfo is not None: + rrule._until = rrule._until.astimezone(dtstart.tzinfo) + delta = rrule._until - dtstart + seconds = delta.total_seconds() + if seconds < 0: + # UNTIL < DTSTART + logger.error("Recurrence rule %r in %s in object %r REJECTED, UNTIL < DTSTART", component.rrule.value, component.name, component_uid) + raise ValueError("Recurrence rule in %s in object %r has UNTIL < DTSTART" + % (component.name, component_uid)) + rrule_entries = seconds / RRULE_FREQUENCIES_TO_INTERVAL[vobject.icalendar.FREQUENCIES[rrule._freq]] + if max_vevent_rrule_occurrence > 0 and rrule_entries > max_vevent_rrule_occurrence: + logger.warning("Recurrence rule %r entries in %s in object %r: %d (estimated/REJECTED/limit: %d)", component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence) + raise ValueError("Too many recurrence rule entries in %s in object %r: %d (limit: %d)" + % (component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence)) + else: + logger.debug("Recurrence rule %r entries in %s in object %r: %d (estimated/PASSED/limit: %d)" % (component.rrule.value, component.name, component_uid, rrule_entries, max_vevent_rrule_occurrence)) + else: + logger.trace("Recurrence rule %r in %s in object %r doesn't contain: FREQ+UNTIL", component.rrule.value, component.name, component_uid) # generic check by vobject try: rruleset = component.rruleset From e22570417455ba4d9ebc19c18417eb06e914359d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 16:22:08 +0200 Subject: [PATCH 17/21] max_vevent_rrule_occurrence: add additional test cases --- radicale/tests/test_base.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index edc816a8..4459c640 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -382,6 +382,13 @@ permissions: RrWw""") event = get_file_content("event_full_day_rrule_until_50y.ics") self.put("/calendar.ics/event_full_day_rrule_until_50y.ics", event, check=400) + def test_add_event_with_rrule_until_50y_limit_20000(self) -> None: + """Test event with RRULE UNTIL=+50y and limit 20000.""" + self.configure({"server": {"max_vevent_rrule_occurrence": 20000}}) + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_full_day_rrule_until_50y.ics") + self.put("/calendar.ics/event_full_day_rrule_until_50y.ics", event) + 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_occurrence": 100}}) @@ -389,6 +396,13 @@ permissions: RrWw""") event = get_file_content("event_full_day_rrule_until_5000y.ics") self.put("/calendar.ics/event_full_day_rrule_until_5000y.ics", event, check=400) + def test_add_event_with_rrule_until_5000y_limit_2000000(self) -> None: + """Test event with RRULE UNTIL=+5000y and limit 2000000.""" + self.configure({"server": {"max_vevent_rrule_occurrence": 2000000}}) + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_full_day_rrule_until_5000y.ics") + self.put("/calendar.ics/event_full_day_rrule_until_5000y.ics", event) + def test_add_event_with_exdate_without_rrule(self) -> None: """Test event with EXDATE but not having RRULE.""" self.mkcalendar("/calendar.ics/") From 75a5d9f10e02e33cf10702b3eb40185cb5e52347 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 16:29:05 +0200 Subject: [PATCH 18/21] max_vevent_rrule_occurrence: exclude 50/5000y tests from 32-bit platform for now --- radicale/tests/test_base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index 4459c640..506d0871 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -375,6 +375,7 @@ permissions: RrWw""") event = get_file_content("event_full_day_rrule_until_before_dtstart.ics") self.put("/calendar.ics/event_full_day_rrule_until_before_dtstart.ics", event, check=400) + @pytest.mark.skipif(sys.maxsize <= 2**32, reason="So far not working on on 32-bit platform") 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_occurrence": 100}}) @@ -382,6 +383,7 @@ permissions: RrWw""") event = get_file_content("event_full_day_rrule_until_50y.ics") self.put("/calendar.ics/event_full_day_rrule_until_50y.ics", event, check=400) + @pytest.mark.skipif(sys.maxsize <= 2**32, reason="So far not working on on 32-bit platform") def test_add_event_with_rrule_until_50y_limit_20000(self) -> None: """Test event with RRULE UNTIL=+50y and limit 20000.""" self.configure({"server": {"max_vevent_rrule_occurrence": 20000}}) @@ -389,6 +391,7 @@ permissions: RrWw""") event = get_file_content("event_full_day_rrule_until_50y.ics") self.put("/calendar.ics/event_full_day_rrule_until_50y.ics", event) + @pytest.mark.skipif(sys.maxsize <= 2**32, reason="So far not working on on 32-bit platform") 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_occurrence": 100}}) @@ -396,6 +399,7 @@ permissions: RrWw""") event = get_file_content("event_full_day_rrule_until_5000y.ics") self.put("/calendar.ics/event_full_day_rrule_until_5000y.ics", event, check=400) + @pytest.mark.skipif(sys.maxsize <= 2**32, reason="So far not working on on 32-bit platform") def test_add_event_with_rrule_until_5000y_limit_2000000(self) -> None: """Test event with RRULE UNTIL=+5000y and limit 2000000.""" self.configure({"server": {"max_vevent_rrule_occurrence": 2000000}}) From 751189d70848092ebbbe5ebb31c37e7574c68c7b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 16:43:30 +0200 Subject: [PATCH 19/21] max_vevent_rrule_occurrence: TZ fix of RRULE/UNTIL --- radicale/item/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 975e4e7a..0b6ebd89 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -274,6 +274,9 @@ def check_and_sanitize_items( raise ValueError("Unsupported FREQ in recurrence rule in %s in object %r: %r" % (component.name, component_uid, rrule._freq)) # RRULE has known FREQ+UNTIL+DTSTART + # TZ code taken from vobject/icalendar.py/getrruleset + if rrule._until.tzinfo is None: + rrule._until = rrule._until.replace(tzinfo=dtstart.tzinfo) if dtstart.tzinfo is not None: rrule._until = rrule._until.astimezone(dtstart.tzinfo) delta = rrule._until - dtstart From 94c77e48d3b3047e32fe8f4fcaed2ef20d4ecb27 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 18:45:11 +0200 Subject: [PATCH 20/21] fix MONTHLY interval --- radicale/item/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 0b6ebd89..82981036 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -56,7 +56,7 @@ VCF_TO_ICS_BDAY_NO_YEAR: list = ["1604"] # List of RRULE frequencies and their interval in seconds RRULE_FREQUENCIES_TO_INTERVAL: dict[str, float] = { "YEARLY": 365*60*60*24, - "MONTHLY": 365/30*60*60*24, + "MONTHLY": 365/12*60*60*24, "WEEKLY": 60*60*24*7, "DAILY": 60*60*24, "HOURLY": 60*60, From b9b80a8e88195882f9a02e6eeecd2df166e78956 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 2 Aug 2026 21:01:11 +0200 Subject: [PATCH 21/21] max_vevent_rrule_occurrence: cosmetics --- radicale/item/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 82981036..c0511b43 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -55,8 +55,8 @@ VCF_TO_ICS_BDAY_NO_YEAR: list = ["1604"] # List of RRULE frequencies and their interval in seconds RRULE_FREQUENCIES_TO_INTERVAL: dict[str, float] = { - "YEARLY": 365*60*60*24, - "MONTHLY": 365/12*60*60*24, + "YEARLY": 60*60*24*365, + "MONTHLY": 60*60*24*365/12, "WEEKLY": 60*60*24*7, "DAILY": 60*60*24, "HOURLY": 60*60,