From fbc3abc48dd0cd35bcadff649fc899b722c9bd00 Mon Sep 17 00:00:00 2001 From: David Greaves Date: Mon, 7 Jul 2025 21:02:43 +0100 Subject: [PATCH] support max_occurence anti-DoS in xml_report for vevent expansion Signed-off-by: David Greaves --- radicale/app/report.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/radicale/app/report.py b/radicale/app/report.py index 8e84a9fc..4c56adb1 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -145,7 +145,8 @@ def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Eleme def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], collection: storage.BaseCollection, encoding: str, - unlock_storage_fn: Callable[[], None] + unlock_storage_fn: Callable[[], None], + max_occurrence: int = 0, ) -> Tuple[int, ET.Element]: """Read and answer REPORT requests that return XML. @@ -244,6 +245,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], # !!! Don't access storage after this !!! unlock_storage_fn() + n_vevents = 0 while retrieved_items: # ``item.vobject_item`` might be accessed during filtering. # Don't keep reference to ``item``, because VObject requires a lot of @@ -298,15 +300,21 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], if time_range_element is not None: time_range_start, time_range_end = radicale_filter.parse_time_range(time_range_element) - expanded_element = _expand( + (expanded_element, n_vev) = _expand( element=element, item=copy.copy(item), start=start, end=end, time_range_start=time_range_start, time_range_end=time_range_end, + max_occurrence=max_occurrence, ) - + n_vevents += n_vev found_props.append(expanded_element) else: found_props.append(element) + n_vevents += 1 + # Avoid DoS with too many events + if max_occurrence and n_vevents > max_occurrence: + raise ValueError("REPORT occurrences limit of {} hit" + .format(max_occurrence)) else: not_found_props.append(element) @@ -327,7 +335,8 @@ def _expand( end: datetime.datetime, time_range_start: Optional[datetime.datetime] = None, time_range_end: Optional[datetime.datetime] = None, -) -> ET.Element: + max_occurrence: int = 0, +) -> Tuple[ET.Element, int]: vevent_component: vobject.base.Component = copy.copy(item.vobject_item) logger.info("Expanding event %s", item.href) @@ -401,7 +410,13 @@ def _expand( # that event should be included as it is still ongoing. If no # extra point is generated then it was a no-op. rstart = start - duration if duration and duration.total_seconds() > 0 else start - recurrences = rruleset.between(rstart, end, inc=True) + recurrences = rruleset.between(rstart, end, inc=True, count=max_occurrence) + if max_occurrence and len(recurrences) >= max_occurrence: + # this shouldn't be > and if it's == then assume a limit + # was hit and ignore that maybe some would be filtered out + # by EXDATE etc. This is anti-DoS, not precise limits + raise ValueError("REPORT occurrences limit of {} hit" + .format(max_occurrence)) _strip_component(vevent_component) _strip_single_event(vevent_recurrence, dt_format) @@ -500,14 +515,14 @@ def _expand( if not filtered_vevents: element.text = "" - return element + return element, 0 else: vevent_component.vevent_list = filtered_vevents logger.debug("lbt: vevent_component %s", vevent_component) element.text = vevent_component.serialize() - return element + return element, len(filtered_vevents) def _convert_timezone(vevent: vobject.icalendar.RecurringComponent, @@ -744,9 +759,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, @@ -761,7 +776,7 @@ class ApplicationPartReport(ApplicationBase): try: status, xml_answer = xml_report( base_prefix, path, xml_content, collection, self._encoding, - lock_stack.close) + lock_stack.close, max_occurrence) except ValueError as e: logger.warning( "Bad REPORT request on %r: %s", path, e, exc_info=True)