max_vevent_rrule_occurrence: rename option

This commit is contained in:
Peter Bieringer
2026-08-02 10:58:13 +02:00
parent fc1e53cb47
commit 953aebc85f
12 changed files with 35 additions and 28 deletions

View File

@@ -4,7 +4,7 @@
* Fix: sharing/proppatch: reject in case of write-access but 'p' is in permissions * Fix: sharing/proppatch: reject in case of write-access but 'p' is in permissions
* Fix: sharing/by-map: catch collection path without trailing / (supporting "pimsync") * Fix: sharing/by-map: catch collection path without trailing / (supporting "pimsync")
* Add: [report] max_expand_occurrence option to separate from max_freebusy_occurrence * Add: [report] max_expand_occurrence option to separate from max_freebusy_occurrence
* Add: [system] max_vevent_rrule_entries option to catch DoS by problematic RRULE early enough * Add: [system] max_vevent_rrule_occurrence option to catch DoS by problematic RRULE early enough (workaround for missing protection in current vobject version)
## 3.7.7 ## 3.7.7
* Fix: web plugin helpers httputils.serve_resource/serve_folder ignored their mimetypes and fallback_mimetype parameters and always used the built-in mapping, so custom web plugins could not serve additional file types with a correct Content-Type * Fix: web plugin helpers httputils.serve_resource/serve_folder ignored their mimetypes and fallback_mimetype parameters and always used the built-in mapping, so custom web plugins could not serve additional file types with a correct Content-Type

View File

@@ -866,12 +866,12 @@ Limited to 80% of max_content_length to cover plain base64 encoded payload.
Announced to clients requesting "max-resource-size" via PROPFIND. Announced to clients requesting "max-resource-size" via PROPFIND.
##### max_vevent_rrule_entries ##### max_vevent_rrule_occurrence
_(>= 3.7.8)_ _(>= 3.7.8)_
The maximum of generated entries of an rrule of an vevent. The maximum of occurrence by an rrule of a vevent.
Large time frames of RRULE by COUNT or UNTIL could Large time frames defined in RRULE by COUNT or UNTIL could
generate a lot of occurrences based on the time frame supplied. This generate a lot of occurrences based on the time frame supplied. This
setting limits the lookup to prevent potential denial of service setting limits the lookup to prevent potential denial of service
attacks on large time frames. If the limit is reached, an HTTP error attacks on large time frames. If the limit is reached, an HTTP error

4
config
View File

@@ -33,8 +33,8 @@
# Announced to clients requesting "max-resource-size" via PROPFIND # Announced to clients requesting "max-resource-size" via PROPFIND
#max_resource_size = 10000000 #max_resource_size = 10000000
# Max entries of a RRULE, limit the number to prevent DoS attacks. # Max occurrence by an RRULE, limit the number to prevent DoS attacks.
#max_vevent_rrule_entries = 10000 #max_vevent_rrule_occurrence = 10000
# Socket timeout (seconds) # Socket timeout (seconds)
#timeout = 30 #timeout = 30

View File

@@ -200,10 +200,10 @@ def run() -> None:
if args_ns.verify_item: if args_ns.verify_item:
encoding = configuration.get("encoding", "stock") encoding = configuration.get("encoding", "stock")
max_vevent_rrule_entries = configuration.get("server", "max_vevent_rrule_entries") max_vevent_rrule_occurrence = configuration.get("server", "max_vevent_rrule_occurrence")
logger.info("Item verification start using 'stock' encoding: %s", encoding) logger.info("Item verification start using 'stock' encoding: %s", encoding)
try: try:
if not item.verify(args_ns.verify_item[0], encoding, max_vevent_rrule_entries): if not item.verify(args_ns.verify_item[0], encoding, max_vevent_rrule_occurrence):
logger.critical("Item verification failed") logger.critical("Item verification failed")
sys.exit(1) sys.exit(1)
except Exception as e: except Exception as e:

View File

@@ -79,7 +79,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
_internal_server: bool _internal_server: bool
_max_content_length: int _max_content_length: int
_max_resource_size: int _max_resource_size: int
_max_vevent_rrule_entries: int _max_vevent_rrule_occurrence: int
_auth_realm: str _auth_realm: str
_auth_type: str _auth_type: str
_web_type: str _web_type: str
@@ -121,8 +121,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
self._max_resource_size = max_resource_size_limited self._max_resource_size = max_resource_size_limited
else: else:
logger.info("max_resource_size set to: %d bytes (%sbytes)", self._max_resource_size, utils.format_unit(self._max_resource_size, binary=True)) logger.info("max_resource_size set to: %d bytes (%sbytes)", self._max_resource_size, utils.format_unit(self._max_resource_size, binary=True))
self._max_vevent_rrule_entries = configuration.get("server", "max_vevent_rrule_entries") self._max_vevent_rrule_occurrence = configuration.get("server", "max_vevent_rrule_occurrence")
logger.info("max_vevent_rrule_entries set to: %d", self._max_vevent_rrule_entries) logger.info("max_vevent_rrule_occurrence set to: %d", self._max_vevent_rrule_occurrence)
self._bad_put_request_content = configuration.get("logging", "bad_put_request_content") self._bad_put_request_content = configuration.get("logging", "bad_put_request_content")
logger.info("log bad put request content: %s", self._bad_put_request_content) logger.info("log bad put request content: %s", self._bad_put_request_content)
self._request_header_on_debug = configuration.get("logging", "request_header_on_debug") self._request_header_on_debug = configuration.get("logging", "request_header_on_debug")

View File

@@ -122,7 +122,7 @@ class ApplicationBase:
_sharing: sharing.BaseSharing _sharing: sharing.BaseSharing
_encoding: str _encoding: str
_max_resource_size: int _max_resource_size: int
_max_vevent_rrule_entries: int _max_vevent_rrule_occurrence: int
_permit_delete_collection: bool _permit_delete_collection: bool
_permit_overwrite_collection: bool _permit_overwrite_collection: bool
_strict_preconditions: bool _strict_preconditions: bool

View File

@@ -47,7 +47,7 @@ PRODID = u"-//Radicale//NONSGML Version " + utils.package_version("radicale") +
def prepare(vobject_items: List[vobject.base.Component], path: str, def prepare(vobject_items: List[vobject.base.Component], path: str,
content_type: str, permission: bool, parent_permission: bool, max_resource_size: int, content_type: str, permission: bool, parent_permission: bool, max_resource_size: int,
max_vevent_rrule_entries: int, max_vevent_rrule_occurrence: int,
tag: Optional[str] = None, tag: Optional[str] = None,
write_whole_collection: Optional[bool] = None) -> Tuple[ write_whole_collection: Optional[bool] = None) -> Tuple[
Iterator[radicale_item.Item], # items Iterator[radicale_item.Item], # items
@@ -75,7 +75,7 @@ def prepare(vobject_items: List[vobject.base.Component], path: str,
if tag and write_whole_collection is not None: if tag and write_whole_collection is not None:
radicale_item.check_and_sanitize_items( radicale_item.check_and_sanitize_items(
vobject_items, vobject_items,
max_vevent_rrule_entries=max_vevent_rrule_entries, max_vevent_rrule_occurrence=max_vevent_rrule_occurrence,
is_collection=write_whole_collection, tag=tag) is_collection=write_whole_collection, tag=tag)
if write_whole_collection and tag == "VCALENDAR": if write_whole_collection and tag == "VCALENDAR":
vobject_components: List[vobject.base.Component] = [] vobject_components: List[vobject.base.Component] = []
@@ -228,7 +228,7 @@ class ApplicationPartPut(ApplicationBase):
bool(rights.intersect(access.permissions, "Ww")), bool(rights.intersect(access.permissions, "Ww")),
bool(rights.intersect(access.parent_permissions, "w")), bool(rights.intersect(access.parent_permissions, "w")),
self._max_resource_size, self._max_resource_size,
self._max_vevent_rrule_entries, self._max_vevent_rrule_occurrence,
) )
with self._storage.acquire_lock("w", user, path=path, request="PUT"): with self._storage.acquire_lock("w", user, path=path, request="PUT"):
@@ -294,7 +294,7 @@ class ApplicationPartPut(ApplicationBase):
bool(rights.intersect(access.permissions, "Ww")), bool(rights.intersect(access.permissions, "Ww")),
bool(rights.intersect(access.parent_permissions, "w")), bool(rights.intersect(access.parent_permissions, "w")),
self._max_resource_size, self._max_resource_size,
self._max_vevent_rrule_entries, self._max_vevent_rrule_occurrence,
tag, write_whole_collection) tag, write_whole_collection)
props = prepared_props props = prepared_props
if prepared_exc_info: if prepared_exc_info:

View File

@@ -254,9 +254,9 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"value": "10000000", "value": "10000000",
"help": "maximum size of resource (default: 10 Mbyte)", "help": "maximum size of resource (default: 10 Mbyte)",
"type": positive_int}), "type": positive_int}),
("max_vevent_rrule_entries", { ("max_vevent_rrule_occurrence", {
"value": "10000", "value": "10000",
"help": "maximum of RRULE entries (default: 10000)", "help": "maximum occurrence by an RRULE (default: 10000)",
"type": positive_int}), "type": positive_int}),
("timeout", { ("timeout", {
"value": "30", "value": "30",

View File

@@ -103,7 +103,7 @@ def predict_tag_of_whole_collection(
def check_and_sanitize_items( def check_and_sanitize_items(
vobject_items: List[vobject.base.Component], vobject_items: List[vobject.base.Component],
max_vevent_rrule_entries: int, max_vevent_rrule_occurrence: int,
is_collection: bool = False, tag: str = "") -> None: is_collection: bool = False, tag: str = "") -> None:
"""Check vobject items for common errors and add missing UIDs. """Check vobject items for common errors and add missing UIDs.
@@ -417,7 +417,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str
return math.floor(start.timestamp()), math.ceil(end.timestamp()) return math.floor(start.timestamp()), math.ceil(end.timestamp())
def verify(file: str, encoding: str, max_vevent_rrule_entries: int): def verify(file: str, encoding: str, max_vevent_rrule_occurrence: int):
logger.info("Verifying item: %s", file) logger.info("Verifying item: %s", file)
with open(file, "rb") as f: with open(file, "rb") as f:
content_raw = f.read() content_raw = f.read()
@@ -437,7 +437,7 @@ def verify(file: str, encoding: str, max_vevent_rrule_entries: int):
try: try:
tag = radicale_item.predict_tag_of_whole_collection(vobject_items) tag = radicale_item.predict_tag_of_whole_collection(vobject_items)
if tag is not None: if tag is not None:
radicale_item.check_and_sanitize_items(vobject_items, tag=tag, max_vevent_rrule_entries=max_vevent_rrule_entries) radicale_item.check_and_sanitize_items(vobject_items, tag=tag, max_vevent_rrule_occurrence=max_vevent_rrule_occurrence)
else: else:
raise ValueError("collection tag cannot be predicted") raise ValueError("collection tag cannot be predicted")
except Exception as e: except Exception as e:

View File

@@ -106,7 +106,7 @@ class StorageBase(storage.BaseStorage):
"logging", "storage_cache_actions_on_debug") "logging", "storage_cache_actions_on_debug")
self._max_resource_size = configuration.get( self._max_resource_size = configuration.get(
"server", "max_resource_size") "server", "max_resource_size")
self._max_vevent_rrule_entries = configuration.get("server", "max_vevent_rrule_entries") self._max_vevent_rrule_occurrence = configuration.get("server", "max_vevent_rrule_occurrence")
def _get_collection_root_folder(self) -> str: def _get_collection_root_folder(self) -> str:
return os.path.join(self._filesystem_folder, "collection-root") return os.path.join(self._filesystem_folder, "collection-root")

View File

@@ -124,7 +124,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
vobject_items = radicale_item.read_components( vobject_items = radicale_item.read_components(
raw_text.decode(self._encoding)) raw_text.decode(self._encoding))
radicale_item.check_and_sanitize_items( radicale_item.check_and_sanitize_items(
vobject_items, tag=self.tag, max_vevent_rrule_entries=self._storage._max_vevent_rrule_entries) vobject_items, tag=self.tag, max_vevent_rrule_occurrence=self._storage._max_vevent_rrule_occurrence)
vobject_item, = vobject_items vobject_item, = vobject_items
temp_item = radicale_item.Item( temp_item = radicale_item.Item(
collection=self, vobject_item=vobject_item) collection=self, vobject_item=vobject_item)

View File

@@ -330,35 +330,42 @@ permissions: RrWw""")
def test_add_event_with_rrule_count_500_limit_100(self) -> None: def test_add_event_with_rrule_count_500_limit_100(self) -> None:
"""Test event with RRULE COUNT=500 and limit 100.""" """Test event with RRULE COUNT=500 and limit 100."""
self.configure({"server": {"max_vevent_rrule_entries": 100}}) self.configure({"server": {"max_vevent_rrule_occurrence": 100}})
self.mkcalendar("/calendar.ics/") self.mkcalendar("/calendar.ics/")
event = get_file_content("event_full_day_rrule_count_500.ics") event = get_file_content("event_full_day_rrule_count_500.ics")
self.put("/calendar.ics/event.ics", event, check=400) self.put("/calendar.ics/event.ics", event, check=400)
def test_add_event_with_rrule_count_500_limit_600(self) -> None: def test_add_event_with_rrule_count_500_limit_600(self) -> None:
"""Test event with RRULE COUNT=500 and limit 600.""" """Test event with RRULE COUNT=500 and limit 600."""
self.configure({"server": {"max_vevent_rrule_entries": 600}}) self.configure({"server": {"max_vevent_rrule_occurrence": 600}})
self.mkcalendar("/calendar.ics/") self.mkcalendar("/calendar.ics/")
event = get_file_content("event_full_day_rrule_count_500.ics") event = get_file_content("event_full_day_rrule_count_500.ics")
self.put("/calendar.ics/event.ics", event) self.put("/calendar.ics/event.ics", event)
def test_add_event_with_rrule_until_2y_limit_800(self) -> None:
"""Test event with RRULE UNTIL=+2y and limit 800."""
self.configure({"server": {"max_vevent_rrule_occurrence": 800}})
self.mkcalendar("/calendar.ics/")
event = get_file_content("event_full_day_rrule_until_2y.ics")
self.put("/calendar.ics/event.ics", event)
def test_add_event_with_rrule_until_2y_limit_100(self) -> None: def test_add_event_with_rrule_until_2y_limit_100(self) -> None:
"""Test event with RRULE UNTIL=+2y and limit 100.""" """Test event with RRULE UNTIL=+2y and limit 100."""
self.configure({"server": {"max_vevent_rrule_entries": 100}}) self.configure({"server": {"max_vevent_rrule_occurrence": 100}})
self.mkcalendar("/calendar.ics/") self.mkcalendar("/calendar.ics/")
event = get_file_content("event_full_day_rrule_until_2y.ics") event = get_file_content("event_full_day_rrule_until_2y.ics")
self.put("/calendar.ics/event.ics", event, check=400) self.put("/calendar.ics/event.ics", event, check=400)
def test_add_event_with_rrule_until_50y_limit_100(self) -> None: def test_add_event_with_rrule_until_50y_limit_100(self) -> None:
"""Test event with RRULE UNTIL=+50y and limit 100.""" """Test event with RRULE UNTIL=+50y and limit 100."""
self.configure({"server": {"max_vevent_rrule_entries": 100}}) self.configure({"server": {"max_vevent_rrule_occurrence": 100}})
self.mkcalendar("/calendar.ics/") self.mkcalendar("/calendar.ics/")
event = get_file_content("event_full_day_rrule_until_50y.ics") event = get_file_content("event_full_day_rrule_until_50y.ics")
self.put("/calendar.ics/event.ics", event, check=400) self.put("/calendar.ics/event.ics", event, check=400)
def test_add_event_with_rrule_until_5000y_limit_100(self) -> None: def test_add_event_with_rrule_until_5000y_limit_100(self) -> None:
"""Test event with RRULE UNTIL=+5000y and limit 100.""" """Test event with RRULE UNTIL=+5000y and limit 100."""
self.configure({"server": {"max_vevent_rrule_entries": 100}}) self.configure({"server": {"max_vevent_rrule_occurrence": 100}})
self.mkcalendar("/calendar.ics/") self.mkcalendar("/calendar.ics/")
event = get_file_content("event_full_day_rrule_until_5000y.ics") event = get_file_content("event_full_day_rrule_until_5000y.ics")
self.put("/calendar.ics/event.ics", event, check=400) self.put("/calendar.ics/event.ics", event, check=400)