Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/+vtimezone-rrule-transitions.bugfix
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions src/icalendar/cal/timezone.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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
Expand All @@ -180,6 +203,7 @@ def get_transitions(
- transition_info = [(TZOFFSETTO, dts_offset, tzname)]

"""
self._check_rrule_frequency()
zone = self.tz_name
transitions = []
dst = {}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"]
],
[]
]
]
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0">
<vcalendar>
<components>
<vtimezone>
<properties>
<tzid>
<text>Custom</text>
</tzid>
</properties>
<components>
<standard>
<properties>
<dtstart>
<date-time>1970-01-01T00:00:00</date-time>
</dtstart>
<rrule>
<recur>
<freq>SECONDLY</freq>
</recur>
</rrule>
<tzoffsetfrom>
<utc-offset>+01:00</utc-offset>
</tzoffsetfrom>
<tzoffsetto>
<utc-offset>+00:00</utc-offset>
</tzoffsetto>
</properties>
</standard>
<daylight>
<properties>
<dtstart>
<date-time>1970-06-01T00:00:00</date-time>
</dtstart>
<rrule>
<recur>
<freq>YEARLY</freq>
</recur>
</rrule>
<tzoffsetfrom>
<utc-offset>+00:00</utc-offset>
</tzoffsetfrom>
<tzoffsetto>
<utc-offset>+01:00</utc-offset>
</tzoffsetto>
</properties>
</daylight>
</components>
</vtimezone>
<vevent>
<properties>
<uid>
<text>a</text>
</uid>
<dtstart>
<parameters>
<tzid>
<text>Custom</text>
</tzid>
</parameters>
<date-time>2025-01-01T12:00:00</date-time>
</dtstart>
</properties>
</vevent>
</components>
</vcalendar>
</icalendar>
3 changes: 3 additions & 0 deletions src/icalendar/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down
73 changes: 73 additions & 0 deletions src/icalendar/tests/test_vtimezone_rrule_transitions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""A VTIMEZONE RRULE must repeat yearly.

Comment thread
angatha marked this conversation as resolved.
Resolved
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
Loading