From 60b47634f28a16c141ba0d776a6656995e0d898f Mon Sep 17 00:00:00 2001 From: Alhuda Khan Date: Sun, 18 Oct 2026 15:12:32 +0530 Subject: [PATCH 1/4] remove invalid XML characters from vCalAddress xCal output vCalAddress.to_xcal() wrote the value into unfiltered, so a C0 control character from jCal produced XML that is not well-formed. Route it through _to_valid_xml_string like vText, vUri and vUnknown, and add U+FFFE and U+FFFF to that filter. AI use: Claude Opus 5.5 (claude-opus-5-5) via Claude Code. I asked it to look for a security-relevant bug in icalendar; it found the vCalAddress gap noted in #1888, wrote a jCal -> xCal reproducer, the fix, and the test changes, and ran the test suite and ruff. I reviewed the output. --- news/1888.bugfix | 1 + src/icalendar/parser/xcal/string.py | 2 +- src/icalendar/prop/cal_address.py | 3 ++- src/icalendar/tests/rfc_6321_xcal/test_text_escape.py | 9 +++++++-- 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 news/1888.bugfix diff --git a/news/1888.bugfix b/news/1888.bugfix new file mode 100644 index 000000000..1a1ac651b --- /dev/null +++ b/news/1888.bugfix @@ -0,0 +1 @@ +Fixed :meth:`vCalAddress.to_xcal() ` writing characters that are not allowed in XML, such as a C0 control character in an ``ORGANIZER`` or ``SENT-BY`` value parsed from jCal, which made :meth:`Component.to_xcal() ` produce XML that is not well-formed. The value now goes through the same filter as the text and URI types, and that filter also removes U+FFFE and U+FFFF. I used AI (Claude Opus 5.5, via Claude Code) to find this bug, write the fix, and write its tests; I reviewed and validated the output. @alhudz diff --git a/src/icalendar/parser/xcal/string.py b/src/icalendar/parser/xcal/string.py index 8fe571b49..5d929b9af 100644 --- a/src/icalendar/parser/xcal/string.py +++ b/src/icalendar/parser/xcal/string.py @@ -33,7 +33,7 @@ def xsd_float_to_string(f: float) -> str: return "-INF" -_VALID_XML_CHARACTERS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") +_VALID_XML_CHARACTERS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]") def _to_valid_xml_string(string: str) -> str: diff --git a/src/icalendar/prop/cal_address.py b/src/icalendar/prop/cal_address.py index 2206bd775..a4db81732 100644 --- a/src/icalendar/prop/cal_address.py +++ b/src/icalendar/prop/cal_address.py @@ -7,6 +7,7 @@ from icalendar.error import JCalParsingError from icalendar.parser import Parameters from icalendar.parser.xcal.base import XCalParser +from icalendar.parser.xcal.string import _to_valid_xml_string from icalendar.parser.xcal.wrapper import from_xcal_wrapper from icalendar.parser_tools import DEFAULT_ENCODING, to_unicode @@ -300,7 +301,7 @@ def to_xcal(self, element: Element) -> None: """The xCal representation of this property according to :rfc:`6321`.""" self.params.to_xcal(element) element = SubElement(element, "cal-address") - element.text = self.ical_value + element.text = _to_valid_xml_string(self.ical_value) __all__ = ["vCalAddress"] diff --git a/src/icalendar/tests/rfc_6321_xcal/test_text_escape.py b/src/icalendar/tests/rfc_6321_xcal/test_text_escape.py index 49fc173ff..b4603b407 100644 --- a/src/icalendar/tests/rfc_6321_xcal/test_text_escape.py +++ b/src/icalendar/tests/rfc_6321_xcal/test_text_escape.py @@ -17,6 +17,7 @@ import pytest from icalendar.config import parse_xml +from icalendar.prop.cal_address import vCalAddress from icalendar.prop.text import vText from icalendar.prop.uid import vUid from icalendar.prop.unknown import vUnknown @@ -39,8 +40,9 @@ def round_trip(xml: Element) -> Element: # #x9 | #xA | #xD | [#x20-#xD7FF] VALID_CHARACTERS_BELOW_SPACE = "\x09\x0a\x0d" -INVALID_CHARACTERS = "".join( - chr(i) for i in range(0x20) if chr(i) not in VALID_CHARACTERS_BELOW_SPACE +INVALID_CHARACTERS = ( + "".join(chr(i) for i in range(0x20) if chr(i) not in VALID_CHARACTERS_BELOW_SPACE) + + "\ufffe\uffff" ) VALID_CHARACTERS = "".join( @@ -107,6 +109,9 @@ def test_xml_round_trips(text): assert round_tripped.text == newline_escape(text) +@pytest.mark.parametrize( + "xcal_text_type", [vText, vUnknown, vUid, vUri, vXmlReference, vCalAddress] +) @pytest.mark.parametrize("text", INVALID_CHARACTERS) @pytest.mark.parametrize("wrap", ["", "asdasdasdasd"]) def test_invalid_xml_characters_are_replaced(xcal_text_type, text, wrap): From dab0b71b30f2b018e5e9f1d8fe6f5edc318e435c Mon Sep 17 00:00:00 2001 From: Alhuda Khan Date: Thu, 22 Oct 2026 12:09:12 +0530 Subject: [PATCH 2/4] document and rename the invalid XML character pattern --- src/icalendar/parser/xcal/string.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/icalendar/parser/xcal/string.py b/src/icalendar/parser/xcal/string.py index 5d929b9af..958eb1076 100644 --- a/src/icalendar/parser/xcal/string.py +++ b/src/icalendar/parser/xcal/string.py @@ -33,7 +33,25 @@ def xsd_float_to_string(f: float) -> str: return "-INF" -_VALID_XML_CHARACTERS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]") +_INVALID_XML_CHARACTERS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]") +"""Match the characters that XML 1.0 does not allow in a document. + +The ``Char`` production in section 2.2 of XML 1.0 allows only +``#x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]``. +This pattern matches the characters outside of it that a Python :class:`str` +can contain, apart from surrogates: + +- the C0 control characters ``#x0`` to ``#x1F``, except tab, line feed and + carriage return, and +- the noncharacters ``#xFFFE`` and ``#xFFFF``. + +Lone surrogates ``#xD800`` to ``#xDFFF`` are also outside ``Char``, but are not +matched here. They cannot be encoded as UTF-8, and ``to_ical()`` rejects them. + +.. seealso:: + + `XML 1.0, section 2.2 Characters `_ +""" def _to_valid_xml_string(string: str) -> str: @@ -49,7 +67,7 @@ def _to_valid_xml_string(string: str) -> str: `~icalendar.prop.text._strip_unsafe_text_chars`, `Issue 1888 `_, """ - return _VALID_XML_CHARACTERS.sub("", string) + return _INVALID_XML_CHARACTERS.sub("", string) __all__ = ["_to_valid_xml_string", "string_to_xsd_float", "xsd_float_to_string"] From a50c48819fdc36cbfe565e87c349fbbb5dbaff0e Mon Sep 17 00:00:00 2001 From: Alhuda Khan Date: Thu, 29 Oct 2026 11:29:03 +0530 Subject: [PATCH 3/4] move the change log entry to 1888.bugfix.1 --- news/{1888.bugfix => 1888.bugfix.1} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename news/{1888.bugfix => 1888.bugfix.1} (100%) diff --git a/news/1888.bugfix b/news/1888.bugfix.1 similarity index 100% rename from news/1888.bugfix rename to news/1888.bugfix.1 From 677cbc4554d1ae60383f6ea86abb909dd7237767 Mon Sep 17 00:00:00 2001 From: Alhuda Khan Date: Fri, 13 Nov 2026 14:44:58 +0530 Subject: [PATCH 4/4] apply docs review suggestions to the change log entry and docstring --- news/1888.bugfix.1 | 2 +- src/icalendar/parser/xcal/string.py | 20 ++++++++------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/news/1888.bugfix.1 b/news/1888.bugfix.1 index 1a1ac651b..46d867847 100644 --- a/news/1888.bugfix.1 +++ b/news/1888.bugfix.1 @@ -1 +1 @@ -Fixed :meth:`vCalAddress.to_xcal() ` writing characters that are not allowed in XML, such as a C0 control character in an ``ORGANIZER`` or ``SENT-BY`` value parsed from jCal, which made :meth:`Component.to_xcal() ` produce XML that is not well-formed. The value now goes through the same filter as the text and URI types, and that filter also removes U+FFFE and U+FFFF. I used AI (Claude Opus 5.5, via Claude Code) to find this bug, write the fix, and write its tests; I reviewed and validated the output. @alhudz +Fixed :meth:`vCalAddress.to_xcal() ` from writing characters that are disallowed in XML. The value now goes through the same filter as the text and URI types, and that filter also removes ``U+FFFE`` and ``U+FFFF``. I used AI (Claude Opus 5.5, via Claude Code) to find this bug, write the fix, and write its tests; I reviewed and validated the output. @alhudz diff --git a/src/icalendar/parser/xcal/string.py b/src/icalendar/parser/xcal/string.py index 958eb1076..f0ce86dcb 100644 --- a/src/icalendar/parser/xcal/string.py +++ b/src/icalendar/parser/xcal/string.py @@ -34,23 +34,19 @@ def xsd_float_to_string(f: float) -> str: _INVALID_XML_CHARACTERS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]") -"""Match the characters that XML 1.0 does not allow in a document. +"""Match the characters that XML 1.0 disallows in a document. -The ``Char`` production in section 2.2 of XML 1.0 allows only -``#x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]``. -This pattern matches the characters outside of it that a Python :class:`str` -can contain, apart from surrogates: +`XML 1.0, section 2.2, Characters `_, +or Char, allows only ``#x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | +[#x10000-#x10FFFF]``. This pattern matches the characters outside of it that a +Python string can contain, apart from surrogates: -- the C0 control characters ``#x0`` to ``#x1F``, except tab, line feed and +- the ``C0`` control characters ``#x0`` to ``#x1F``, except tab, line feed and carriage return, and - the noncharacters ``#xFFFE`` and ``#xFFFF``. -Lone surrogates ``#xD800`` to ``#xDFFF`` are also outside ``Char``, but are not -matched here. They cannot be encoded as UTF-8, and ``to_ical()`` rejects them. - -.. seealso:: - - `XML 1.0, section 2.2 Characters `_ +Lone surrogates ``#xD800`` to ``#xDFFF`` are also outside Char, but are not +matched here. They can't be encoded as UTF-8, and ``to_ical()`` rejects them. """