Add: [system] max_vevent_rrule_entries option to catch DoS by problematic RRULE early enough

This commit is contained in:
Peter Bieringer
2026-08-01 18:54:31 +02:00
parent 4a4e571cce
commit b044addc53
11 changed files with 36 additions and 6 deletions

View File

@@ -4,6 +4,7 @@
* 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")
* 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
## 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

View File

@@ -866,6 +866,15 @@ Limited to 80% of max_content_length to cover plain base64 encoded payload.
Announced to clients requesting "max-resource-size" via PROPFIND.
##### max_vevent_rrule_entries
_(>= 3.7.8)_
The maximum of generated entries of a rrule.
Default: `10000`
##### timeout
Socket timeout. (seconds)

3
config
View File

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

View File

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

View File

@@ -79,6 +79,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
_internal_server: bool
_max_content_length: int
_max_resource_size: int
_max_vevent_rrule_entries: int
_auth_realm: str
_auth_type: str
_web_type: str
@@ -120,6 +121,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
self._max_resource_size = max_resource_size_limited
else:
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")
logger.info("max_vevent_rrule_entries set to: %d", self._max_vevent_rrule_entries)
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)
self._request_header_on_debug = configuration.get("logging", "request_header_on_debug")

View File

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

View File

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

View File

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

View File

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

View File

@@ -106,6 +106,7 @@ class StorageBase(storage.BaseStorage):
"logging", "storage_cache_actions_on_debug")
self._max_resource_size = configuration.get(
"server", "max_resource_size")
self._max_vevent_rrule_entries = configuration.get("server", "max_vevent_rrule_entries")
def _get_collection_root_folder(self) -> str:
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(
raw_text.decode(self._encoding))
radicale_item.check_and_sanitize_items(
vobject_items, tag=self.tag)
vobject_items, tag=self.tag, max_vevent_rrule_entries=self._storage._max_vevent_rrule_entries)
vobject_item, = vobject_items
temp_item = radicale_item.Item(
collection=self, vobject_item=vobject_item)