From debef18852f1b31fb651d133921cda02420d1ace Mon Sep 17 00:00:00 2001 From: shams Date: Tue, 27 Oct 2026 21:39:55 +0530 Subject: [PATCH 1/3] fix quadratic time of the XML declaration regex in vXml vXml._parse strips the declared encoding from an XML property's declaration with a regex whose lazy [^>]*? and greedy \s+ both match whitespace, so a long run of whitespace in the declaration is retried from every position and to_xcal() takes quadratic time (200k spaces: 41 s). Match a single whitespace before encoding, as the bytes regex does. The whitespace left in the declaration is valid XML. AI: Claude Fable 5.1 (claude-fable-5-1) via Claude Code. Prompt: find a security bug in icalendar, reproduce it, and write a minimal fix with a regression test and change log entry. Output: (this commit), reviewed by the author. --- news/+xml-declaration-whitespace.bugfix | 1 + src/icalendar/prop/xml.py | 6 +++++- .../tests/rfc_6321_xcal/test_xml_property.py | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 news/+xml-declaration-whitespace.bugfix diff --git a/news/+xml-declaration-whitespace.bugfix b/news/+xml-declaration-whitespace.bugfix new file mode 100644 index 000000000..90b419d3a --- /dev/null +++ b/news/+xml-declaration-whitespace.bugfix @@ -0,0 +1 @@ +Fixed the quadratic run time of :meth:`Component.to_xcal ` and :attr:`vXml.element ` for an ``XML`` property whose XML declaration contains a long run of whitespace. I used AI to assist me with this change. @shamsManna diff --git a/src/icalendar/prop/xml.py b/src/icalendar/prop/xml.py index 759854335..c86b45950 100644 --- a/src/icalendar/prop/xml.py +++ b/src/icalendar/prop/xml.py @@ -23,8 +23,12 @@ from icalendar.compatibility import Self # The text is already decoded, so an encoding in the XML declaration does not apply. +# A single ``\s`` before ``encoding`` keeps the match linear: with ``\s+``, the +# whitespace run before the attribute is retried from every position that the +# lazy ``[^>]*?`` reaches, so a long run of whitespace in the declaration takes +# quadratic time. The whitespace that stays in the declaration is valid XML. _DECLARED_ENCODING = re.compile( - r"""^(\ufeff?<\?xml\s[^>]*?)\s+encoding\s*=\s*("[^"]*"|'[^']*')""", + r"""^(\ufeff?<\?xml\s[^>]*?)\sencoding\s*=\s*("[^"]*"|'[^']*')""", re.ASCII, ) # Bytes are decoded with the encoding of their XML declaration. diff --git a/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py b/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py index ff0a7125a..5d6999ff9 100644 --- a/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py +++ b/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py @@ -20,6 +20,7 @@ import base64 import re +import time import xml.etree.ElementTree as ET from io import BytesIO from xml.etree.ElementTree import Element, ParseError, fromstring, tostring @@ -287,6 +288,25 @@ def test_xml_property_to_xcal_ignores_the_declared_encoding(declaration): assert Event.from_xcal(event.to_xcal())[0]["XML"].element.text == "café €" +@pytest.mark.parametrize("whitespace", [" ", "\t", "\n"]) +def test_xml_property_to_xcal_with_long_whitespace_in_the_declaration(whitespace): + """A long run of whitespace in the XML declaration is parsed in linear time. + + The regex that removes the declared encoding used to retry the whitespace + before ``encoding`` from every position, so this took minutes. + The bound is generous enough to never flake on slow CI. + """ + event = Event() + event.add( + "XML", + 'text', + ) + start = time.perf_counter() + xcal = event.to_xcal() + assert time.perf_counter() - start < 5 + assert Event.from_xcal(xcal)[0]["XML"].element.text == "text" + + # icalendar objects -> iCal/jCal From 9f55ce68d87da71efa17ffe9b74685ed82b44b7e Mon Sep 17 00:00:00 2001 From: shams Date: Wed, 28 Oct 2026 09:56:41 +0530 Subject: [PATCH 2/3] use possessive quantifiers for the XML declaration regex on Python 3.11+ A possessive \s++ alone is still quadratic, because the lazy [^>]*? keeps retrying it from every position in the whitespace run. Matching the declaration as alternating possessive runs of whitespace and other characters is linear and removes all whitespace before encoding, like the original pattern. Python 3.10 keeps the single \s. --- src/icalendar/prop/xml.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/icalendar/prop/xml.py b/src/icalendar/prop/xml.py index c86b45950..4b80f8015 100644 --- a/src/icalendar/prop/xml.py +++ b/src/icalendar/prop/xml.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import sys from contextlib import contextmanager, suppress from copy import copy, deepcopy from io import BytesIO @@ -23,14 +24,25 @@ from icalendar.compatibility import Self # The text is already decoded, so an encoding in the XML declaration does not apply. -# A single ``\s`` before ``encoding`` keeps the match linear: with ``\s+``, the -# whitespace run before the attribute is retried from every position that the -# lazy ``[^>]*?`` reaches, so a long run of whitespace in the declaration takes -# quadratic time. The whitespace that stays in the declaration is valid XML. -_DECLARED_ENCODING = re.compile( - r"""^(\ufeff?<\?xml\s[^>]*?)\sencoding\s*=\s*("[^"]*"|'[^']*')""", - re.ASCII, -) +# With ``[^>]*?\s+encoding``, the whitespace run before the attribute is retried +# from every position that the lazy ``[^>]*?`` reaches, so a long run of whitespace +# in the declaration takes quadratic time. +if sys.version_info >= (3, 11): + # Possessive quantifiers match the declaration as alternating runs of + # whitespace and other characters that are never given back, so the match is + # linear and all whitespace before ``encoding`` is removed. + _DECLARED_ENCODING = re.compile( + r"""^(\ufeff?<\?xml\s[^\s>]*+(?:\s++[^\s>]++)*?)\s++encoding\s*+=\s*+""" + r"""("[^"]*+"|'[^']*+')""", + re.ASCII, + ) +else: + # A single ``\s`` before ``encoding`` keeps the match linear. + # The whitespace that stays in the declaration is valid XML. + _DECLARED_ENCODING = re.compile( + r"""^(\ufeff?<\?xml\s[^>]*?)\sencoding\s*=\s*("[^"]*"|'[^']*')""", + re.ASCII, + ) # Bytes are decoded with the encoding of their XML declaration. _DECLARED_ENCODING_OF_BYTES = re.compile( rb"""^<\?xml\s[^>]*?\sencoding\s*=\s*["']([A-Za-z][A-Za-z0-9._-]*)["']""" From 5b5e119867cfbbc15034abc50d3d0b48335b23ce Mon Sep 17 00:00:00 2001 From: shams Date: Fri, 13 Nov 2026 14:47:35 +0530 Subject: [PATCH 3/3] test both XML declaration regexes and whitespace around encoding Drop the comment about the old pattern and keep the Python 3.10 regex as _DECLARED_ENCODING_PY310, so the declared encoding tests run against it on every Python version as well as against the possessive one on 3.11+. Add a test with long whitespace runs before version, before encoding and after encoding, which checks the encoding is still removed, plus declarations with whitespace and newlines around encoding. --- src/icalendar/prop/xml.py | 16 +++--- .../tests/rfc_6321_xcal/test_xml_property.py | 54 ++++++++++++++++++- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/icalendar/prop/xml.py b/src/icalendar/prop/xml.py index 4b80f8015..388747924 100644 --- a/src/icalendar/prop/xml.py +++ b/src/icalendar/prop/xml.py @@ -24,9 +24,12 @@ from icalendar.compatibility import Self # The text is already decoded, so an encoding in the XML declaration does not apply. -# With ``[^>]*?\s+encoding``, the whitespace run before the attribute is retried -# from every position that the lazy ``[^>]*?`` reaches, so a long run of whitespace -# in the declaration takes quadratic time. +# A single ``\s`` before ``encoding`` keeps the match linear. +# The whitespace that stays in the declaration is valid XML. +_DECLARED_ENCODING_PY310 = re.compile( + r"""^(\ufeff?<\?xml\s[^>]*?)\sencoding\s*=\s*("[^"]*"|'[^']*')""", + re.ASCII, +) if sys.version_info >= (3, 11): # Possessive quantifiers match the declaration as alternating runs of # whitespace and other characters that are never given back, so the match is @@ -37,12 +40,7 @@ re.ASCII, ) else: - # A single ``\s`` before ``encoding`` keeps the match linear. - # The whitespace that stays in the declaration is valid XML. - _DECLARED_ENCODING = re.compile( - r"""^(\ufeff?<\?xml\s[^>]*?)\sencoding\s*=\s*("[^"]*"|'[^']*')""", - re.ASCII, - ) + _DECLARED_ENCODING = _DECLARED_ENCODING_PY310 # Bytes are decoded with the encoding of their XML declaration. _DECLARED_ENCODING_OF_BYTES = re.compile( rb"""^<\?xml\s[^>]*?\sencoding\s*=\s*["']([A-Za-z][A-Za-z0-9._-]*)["']""" diff --git a/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py b/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py index 5d6999ff9..4d169f9a4 100644 --- a/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py +++ b/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py @@ -20,6 +20,7 @@ import base64 import re +import sys import time import xml.etree.ElementTree as ET from io import BytesIO @@ -41,6 +42,7 @@ ) from icalendar.parser.xcal.adapter import ElementAdapter from icalendar.parser.xcal.property import XCalPropertyParser +from icalendar.prop import xml as prop_xml XML = 'text' XML_BASE64 = base64.b64encode(XML.encode()).decode() @@ -270,6 +272,21 @@ def test_xml_property_to_xcal_and_back_is_equal(): assert str(copy.XML[1]) == '' +@pytest.fixture(params=["py310", "py311"]) +def declared_encoding(request, monkeypatch): + """Use the regex for the declared encoding of Python 3.10 and of Python 3.11+. + + Python 3.10 has no possessive quantifiers, so it uses a simpler regex. + Both are tested where they can be compiled. + """ + if request.param == "py310": + monkeypatch.setattr( + prop_xml, "_DECLARED_ENCODING", prop_xml._DECLARED_ENCODING_PY310 + ) + elif sys.version_info < (3, 11): + pytest.skip("Possessive quantifiers need Python 3.11 or later.") + + @pytest.mark.parametrize( "declaration", [ @@ -278,9 +295,13 @@ def test_xml_property_to_xcal_and_back_is_equal(): '', "", chr(0xFEFF) + '', + '', + '', ], ) -def test_xml_property_to_xcal_ignores_the_declared_encoding(declaration): +def test_xml_property_to_xcal_ignores_the_declared_encoding( + declaration, declared_encoding +): """The text is already decoded, so the encoding of its declaration is ignored.""" event = Event() event.add("XML", f'{declaration}café €') @@ -289,7 +310,9 @@ def test_xml_property_to_xcal_ignores_the_declared_encoding(declaration): @pytest.mark.parametrize("whitespace", [" ", "\t", "\n"]) -def test_xml_property_to_xcal_with_long_whitespace_in_the_declaration(whitespace): +def test_xml_property_to_xcal_with_long_whitespace_in_the_declaration( + whitespace, declared_encoding +): """A long run of whitespace in the XML declaration is parsed in linear time. The regex that removes the declared encoding used to retry the whitespace @@ -307,6 +330,33 @@ def test_xml_property_to_xcal_with_long_whitespace_in_the_declaration(whitespace assert Event.from_xcal(xcal)[0]["XML"].element.text == "text" +@pytest.mark.parametrize("whitespace", [" ", "\t", "\n"]) +def test_xml_property_to_xcal_with_long_whitespace_around_the_encoding( + whitespace, declared_encoding +): + """Long runs of whitespace around the attributes are parsed in linear time. + + The run before ``version`` is not followed by ``encoding``, and the run + before ``encoding`` is. The declared encoding is still removed, + so the text is not decoded again. + """ + event = Event() + event.add( + "XML", + "café €', + ) + start = time.perf_counter() + xcal = event.to_xcal() + assert time.perf_counter() - start < 5 + assert Event.from_xcal(xcal)[0]["XML"].element.text == "café €" + + # icalendar objects -> iCal/jCal