diff --git a/news/+vtimezone-rrule-transitions.bugfix b/news/+vtimezone-rrule-transitions.bugfix new file mode 100644 index 000000000..c2385b1a9 --- /dev/null +++ b/news/+vtimezone-rrule-transitions.bugfix @@ -0,0 +1 @@ +Reject a VTIMEZONE whose RRULE does not have ``FREQ=YEARLY``. Timezones created from a VTIMEZONE expand its RRULE from DTSTART, so a calendar with an RRULE such as ``FREQ=SECONDLY`` in a VTIMEZONE could exhaust memory and CPU when it was parsed or when a date in that timezone was used. I used AI to assist me with this change. @shamsManna diff --git a/src/icalendar/cal/timezone.py b/src/icalendar/cal/timezone.py index 65881e264..c1c5c5d17 100644 --- a/src/icalendar/cal/timezone.py +++ b/src/icalendar/cal/timezone.py @@ -131,6 +131,28 @@ def _extract_offsets(component: TimezoneDaylight | TimezoneStandard, tzname: str is_dst = 1 return is_dst, transitions + def _check_rrule_frequency(self) -> None: + """Check that the timezone components only repeat yearly. + + Timezones created from a VTIMEZONE expand its RRULE from DTSTART, + so an RRULE such as ``FREQ=SECONDLY`` could exhaust memory and CPU. + + Raises: + ValueError: If an RRULE has a frequency other than ``YEARLY``. + """ + for component in self.walk(): + if isinstance(component, Timezone): + continue + ical_rrules = component.get("RRULE", []) + if not isinstance(ical_rrules, list): + ical_rrules = [ical_rrules] + for ical_rrule in ical_rrules: + if ical_rrule.get("FREQ") != ["YEARLY"]: + raise ValueError( + "Invalid iCalendar VTIMEZONE: expected an RRULE with" + f" FREQ=YEARLY, got {ical_rrule.to_ical().decode()!r}." + ) + @staticmethod def _make_unique_tzname(tzname, tznames): """ @@ -157,6 +179,7 @@ def to_tz(self, tzp: TZP = tzp, lookup_tzid: bool = True): tz = tzp.timezone(self.tz_name) if tz is not None: return tz + self._check_rrule_frequency() return tzp.create_timezone(self) @property @@ -180,6 +203,7 @@ def get_transitions( - transition_info = [(TZOFFSETTO, dts_offset, tzname)] """ + self._check_rrule_frequency() zone = self.tz_name transitions = [] dst = {} diff --git a/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_ical.ics b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_ical.ics new file mode 100644 index 000000000..750ca7691 --- /dev/null +++ b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_ical.ics @@ -0,0 +1,21 @@ +BEGIN:VCALENDAR +BEGIN:VTIMEZONE +TZID:Custom +BEGIN:STANDARD +DTSTART:19700101T000000 +RRULE:FREQ=SECONDLY +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700601T000000 +RRULE:FREQ=YEARLY +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:a +DTSTART;TZID=Custom:20250101T120000 +END:VEVENT +END:VCALENDAR diff --git a/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_jcal.jcal b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_jcal.jcal new file mode 100644 index 000000000..eef8fd787 --- /dev/null +++ b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_jcal.jcal @@ -0,0 +1,37 @@ +["vcalendar", + [], + [ + ["vtimezone", + [ + ["tzid", {}, "text", "Custom"] + ], + [ + ["standard", + [ + ["dtstart", {}, "date-time", "1970-01-01T00:00:00"], + ["rrule", {}, "recur", {"freq": "SECONDLY"}], + ["tzoffsetfrom", {}, "utc-offset", "+01:00"], + ["tzoffsetto", {}, "utc-offset", "+00:00"] + ], + [] + ], + ["daylight", + [ + ["dtstart", {}, "date-time", "1970-06-01T00:00:00"], + ["rrule", {}, "recur", {"freq": "YEARLY"}], + ["tzoffsetfrom", {}, "utc-offset", "+00:00"], + ["tzoffsetto", {}, "utc-offset", "+01:00"] + ], + [] + ] + ] + ], + ["vevent", + [ + ["uid", {}, "text", "a"], + ["dtstart", {"tzid": "Custom"}, "date-time", "2025-01-01T12:00:00"] + ], + [] + ] + ] +] diff --git a/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_xcal.xml b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_xcal.xml new file mode 100644 index 000000000..3af51ec48 --- /dev/null +++ b/src/icalendar/tests/calendars/pr_2047_vtimezone_secondly_rrule_xcal.xml @@ -0,0 +1,67 @@ + + + + + + + + Custom + + + + + + + 1970-01-01T00:00:00 + + + + SECONDLY + + + + +01:00 + + + +00:00 + + + + + + + 1970-06-01T00:00:00 + + + + YEARLY + + + + +00:00 + + + +01:00 + + + + + + + + + a + + + + + Custom + + + 2025-01-01T12:00:00 + + + + + + diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index 98cf1875a..447c5bd0c 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -243,6 +243,9 @@ def in_timezone(request, tzp): "pr_480_summary_with_colon.ics", "parsing_error_in_UTC_offset.ics", "parsing_error.ics", + "pr_2047_vtimezone_secondly_rrule_ical.ics", + "pr_2047_vtimezone_secondly_rrule_jcal.jcal", + "pr_2047_vtimezone_secondly_rrule_xcal.xml", ) diff --git a/src/icalendar/tests/test_vtimezone_rrule_transitions.py b/src/icalendar/tests/test_vtimezone_rrule_transitions.py new file mode 100644 index 000000000..5d631f1aa --- /dev/null +++ b/src/icalendar/tests/test_vtimezone_rrule_transitions.py @@ -0,0 +1,73 @@ +"""A VTIMEZONE RRULE must repeat yearly. + +Timezones created from a VTIMEZONE expand its RRULE from DTSTART. +An RRULE such as FREQ=SECONDLY could exhaust memory or CPU. +""" + +import dateutil.rrule +import pytest + +from icalendar import vRecur + +# Far more than any test here needs, small enough to stop in seconds. +MAX_EXPANDED_TRANSITIONS = 10_000 + + +@pytest.fixture(autouse=True) +def bounded_rrule_expansion(monkeypatch): + """Fail fast instead of hanging if an RRULE is expanded without a limit.""" + expand = dateutil.rrule.rrule._iter + + def bounded_expand(self): + for count, dt in enumerate(expand(self)): + if count >= MAX_EXPANDED_TRANSITIONS: + pytest.fail( + f"RRULE expanded to more than {MAX_EXPANDED_TRANSITIONS} transitions" + ) + yield dt + + monkeypatch.setattr(dateutil.rrule.rrule, "_iter", bounded_expand) + + +SECONDLY_ICAL = "pr_2047_vtimezone_secondly_rrule_ical.ics" +SECONDLY_JCAL = "pr_2047_vtimezone_secondly_rrule_jcal.jcal" +SECONDLY_XCAL = "pr_2047_vtimezone_secondly_rrule_xcal.xml" + + +def test_secondly_rrule_from_1970_is_invalid(calendars): + """Reading the offset of an event in a SECONDLY timezone fails fast. + + Without the check, this exhausts memory. + """ + with pytest.raises(ValueError, match="FREQ=YEARLY"): + calendars[SECONDLY_ICAL].events[0].DTSTART.utcoffset() + + +@pytest.mark.parametrize("source", [SECONDLY_ICAL, SECONDLY_JCAL, SECONDLY_XCAL]) +@pytest.mark.parametrize("method", ["to_tz", "get_transitions"]) +def test_secondly_rrule_is_invalid_in_all_formats(calendars, tzp, source, method): + """iCalendar, jCal and xCal reject the timezone.""" + with pytest.raises(ValueError, match="FREQ=YEARLY"): + timezone = calendars[source].timezones[0] + if method == "to_tz": + timezone.to_tz(tzp, lookup_tzid=False) + else: + timezone.get_transitions() + + +@pytest.mark.parametrize( + "freq", ["SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY"] +) +def test_rrule_that_does_not_repeat_yearly_is_invalid(timezones, tzp, freq): + """Only a yearly RRULE is valid in a timezone.""" + timezone = timezones.pacific_fiji + timezone.standard[0]["RRULE"] = vRecur.from_ical(f"FREQ={freq}") + with pytest.raises(ValueError, match="FREQ=YEARLY"): + timezone.to_tz(tzp, lookup_tzid=False) + with pytest.raises(ValueError, match="FREQ=YEARLY"): + timezone.get_transitions() + + +def test_yearly_rrule_is_used(timezones, tzp): + """A yearly RRULE with BYxxx parts still works.""" + assert timezones.pacific_fiji.to_tz(tzp, lookup_tzid=False) is not None