Skip to content

Fix RecursionError in Component.__repr__ on deeply nested calendars (Closes #1370)#1371

Open
gistrec wants to merge 3 commits into
collective:mainfrom
gistrec:main
Open

Fix RecursionError in Component.__repr__ on deeply nested calendars (Closes #1370)#1371
gistrec wants to merge 3 commits into
collective:mainfrom
gistrec:main

Conversation

@gistrec
Copy link
Copy Markdown

@gistrec gistrec commented May 8, 2026

Problem

Component.__repr__ (in src/icalendar/cal/component.py) is recursive over self.subcomponents. Combined with the fact that the parser places no limit on BEGIN/END nesting depth, a ~13 KB crafted .ics is enough to make repr() / str() / f"{cal}" raise RecursionError at the default Python recursion limit (1000). Threshold is depth ≈ 498.

from_ical(), walk(), and to_ical() are unaffected — those code paths are already iterative.

Realistic exposure: anywhere a logger / error reporter / debug page formats an attacker-controlled Calendar object through repr(). CalDAV servers, .ics import endpoints, and calendar-invitation processors are the typical consumers.

The OSS-Fuzz harness exercises from_ical() / walk() / to_ical() but does not exercise __repr__, which is why this path was not surfaced by fuzzing.

Change

Replace the recursive __repr__ with an iterative stack-based walk. The output format is intentionally identical to the previous implementation for normally-shaped calendars (VCALENDAR({...}, VEVENT({...}), VEVENT({...}))); only the implementation strategy changes.

The PR is intentionally minimal: I am not adding a parser-side depth limit in handle_begin_component here. That would be a sensible defense-in-depth, but it changes acceptance semantics (currently-valid inputs would start being rejected), which deserves a separate discussion.

Tests

Added src/icalendar/tests/test_repr_recursion.py covering:

  • repr() succeeds at depths [10, 100, 500, 1000] (parametrized) — previously crashed for any depth ≥ ~498.
  • str() succeeds at depth 800 (it falls through to __repr__).
  • Output format is unchanged for a normally-shaped calendar with two sibling VEVENTs.

Local result: 9451 passed, 736 skipped (was 9444 passed; the +7 are the new tests, parametrized count). No existing test changes.

Reproducer (matches issue)

import icalendar

depth = 500
ics = (
    "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//x//EN\r\n"
    + "BEGIN:VEVENT\r\n" * depth
    + "UID:nested@example.com\r\nDTSTAMP:20260101T000000Z\r\n"
    + "END:VEVENT\r\n" * depth
    + "END:VCALENDAR\r\n"
)
cal = icalendar.Calendar.from_ical(ics)
repr(cal)   # before: RecursionError; after: ~6 KB string

@read-the-docs-community
Copy link
Copy Markdown

read-the-docs-community Bot commented May 8, 2026

Copy link
Copy Markdown
Member

@stevepiercy stevepiercy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gistrec thank you for your thorough analysis, code implementation, tests, and excellent comments. Would you please take a look at my minor suggestions, and update your branch against main?

Then I'd like a maintainer to review for final approval. @SashankBhamidi @angatha @niccokunzmann

Comment thread src/icalendar/tests/test_repr_recursion.py Outdated
Comment thread CHANGES.rst Outdated
Component.__repr__ recursively calls str() on every subcomponent.
Parsing accepts BEGIN/END nesting of arbitrary depth (the parser
itself is iterative), so a ~13 KB .ics with ~500 nested VEVENTs
caused any caller doing repr(cal) / str(cal) / f"{cal}" to raise
an uncaught RecursionError at the default recursion limit.

This affects logging, error reporting, and debug-page rendering of
attacker-controlled calendars (e.g. CalDAV, .ics import endpoints,
calendar invitation processing).

Replaced the recursive implementation with an iterative stack-based
walk. Output format is unchanged for normally-shaped calendars.
Added regression tests covering depths up to 1000 and the unchanged
shallow-calendar format.

Existing OSS-Fuzz harness in src/icalendar/fuzzing/ical_fuzzer.py
exercises from_ical() / walk() / to_ical() but not __repr__, which
is why this code path slipped through fuzzing.
Comment thread src/icalendar/tests/test_repr_recursion.py Outdated
Comment thread src/icalendar/tests/test_repr_recursion.py
Comment thread src/icalendar/cal/component.py
gistrec added 2 commits May 19, 2026 04:24
Per @angatha review on PR collective#1371:

- Parametrize ``test_str_does_not_raise_recursion_error`` with the same
  depths as the ``repr()`` test ([10, 100, 500, 1000]) and combine the
  two into a single test whose only varying axis (besides depth) is the
  string-conversion function.

- Add exact-equality tests for ``repr()`` output:

  * ``test_repr_exact_output_for_nested_components`` builds nested empty
    ``Event``s under a ``Calendar`` and asserts the entire ``repr()``
    string equals the format produced by the previous recursive
    implementation: ``VCALENDAR({}, VEVENT({}, ...))``.

  * ``test_repr_exact_output_for_sibling_components`` does the same for
    two sibling VEVENTs: ``VCALENDAR({}, VEVENT({}), VEVENT({}))``.

  Using empty constructed components keeps the expected string
  independent of the ``repr`` of any parsed property value.
Upstream switched to towncrier-managed CHANGES.rst in collective#1389 and cut
release 7.1.1. Resolved the CHANGES.rst conflict by accepting
upstream's regenerated file and migrating this PR's change log entry
to a towncrier fragment at news/1370.bugfix per the new contributor
workflow (docs/contribute/index.rst).
@gistrec
Copy link
Copy Markdown
Author

gistrec commented May 19, 2026

Fixed, ready for re-review, @angatha

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants