diff --git a/CHANGELOG.md b/CHANGELOG.md index 3609a39e..c0da9c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ * Adjust: [logging] header/content debug log indended by space to be skipped by logwatch * Improve: remove unnecessary open+read for mtime+size cache * Extend: add selected XML query properties to request result log line for improved timing analysis incl. logwatch support +* Add: [server] max_resource_size option +* Add: support PROPFIND/max-resource-size by max_resource_size (capped to 80% of max_content_length) ## 3.5.9 * Extend: [auth] add support for type http_remote_user diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 0a8760df..daeedb98 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -797,9 +797,21 @@ Default: `8` The maximum size of the request body. (bytes) -Default: `100000000` +Default: `100000000` (100 Mbyte) -In case of using a reverse proxy in front of check also there related option +In case of using a reverse proxy in front of check also there related option. + +##### max_resource_size + +_(>= 3.5.10)_ + +The maximum size of a resource. (bytes) + +Default: `10000000` (10 Mbyte) + +Limited to 80% of max_content_length to cover plain base64 encoded payload. + +Announced to clients requesting "max-resource-size" via PROPFIND. ##### timeout diff --git a/config b/config index 75a314e8..2f85c4e0 100644 --- a/config +++ b/config @@ -21,10 +21,15 @@ # Max parallel connections #max_connections = 8 -# Max size of request body (bytes) +# Max size of request body (bytes), default: 100 Mbyte # In case of using a reverse proxy in front of check also there related option #max_content_length = 100000000 +# Max resource size (bytes), default: 10 Mbyte +# Limited to 80% of max_content_length to cover plain base64 encoded payload +# Announced to clients requesting "max-resource-size" via PROPFIND +#max_ressource_size = 10000000 + # Socket timeout (seconds) #timeout = 30 diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index d96de77f..7d02c2f8 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -73,6 +73,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, _auth_delay: float _internal_server: bool _max_content_length: int + _max_resource_size: int _auth_realm: str _auth_type: str _web_type: str @@ -95,6 +96,14 @@ class Application(ApplicationPartDelete, ApplicationPartHead, """ super().__init__(configuration) self._mask_passwords = configuration.get("logging", "mask_passwords") + self._max_content_length = configuration.get("server", "max_content_length") + self._max_resource_size = configuration.get("server", "max_resource_size") + if (self._max_resource_size > (self._max_content_length * 0.8)): + max_resource_size_limited = int(self._max_content_length * 0.8) + logger.warning("max_resource_size capped to: %d bytes (from %d to 80%% of max_content_length %d)", max_resource_size_limited, self._max_resource_size, self._max_content_length) + self._max_resource_size = max_resource_size_limited + else: + logger.info("max_resource_size set to: %d bytes", self._max_resource_size) 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") diff --git a/radicale/app/base.py b/radicale/app/base.py index 18a68b95..aa0af7a2 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -39,6 +39,7 @@ class ApplicationBase: _rights: rights.BaseRights _web: web.BaseWeb _encoding: str + _max_resource_size: int _permit_delete_collection: bool _permit_overwrite_collection: bool _strict_preconditions: bool diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index d79d0269..3976af91 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -34,7 +34,7 @@ from radicale.log import logger def xml_propfind(base_prefix: str, path: str, xml_request: Optional[ET.Element], allowed_items: Iterable[Tuple[types.CollectionOrItem, str]], - user: str, encoding: str) -> Optional[ET.Element]: + user: str, encoding: str, max_resource_size: int) -> Optional[ET.Element]: """Read and answer PROPFIND requests. Read rfc4918-9.1 for info. @@ -71,14 +71,14 @@ def xml_propfind(base_prefix: str, path: str, write = permission == "w" multistatus.append(xml_propfind_response( base_prefix, path, item, props, user, encoding, write=write, - allprop=allprop, propname=propname)) + allprop=allprop, propname=propname, max_resource_size=max_resource_size)) return multistatus def xml_propfind_response( base_prefix: str, path: str, item: types.CollectionOrItem, - props: Sequence[str], user: str, encoding: str, write: bool = False, + props: Sequence[str], user: str, encoding: str, max_resource_size: int, write: bool = False, propname: bool = False, allprop: bool = False) -> ET.Element: """Build and return a PROPFIND response.""" if propname and allprop or (props and (propname or allprop)): @@ -111,6 +111,9 @@ def xml_propfind_response( props.append(xmlutils.make_clark("D:supported-report-set")) props.append(xmlutils.make_clark("D:resourcetype")) props.append(xmlutils.make_clark("D:owner")) + if not allprop: + # RFC4791#5.2.5: SHOULD NOT be returned by a PROPFIND DAV:allprop request + props.append(xmlutils.make_clark("C:max-resource-size")) if is_collection and collection.is_principal: props.append(xmlutils.make_clark("C:calendar-user-address-set")) @@ -239,6 +242,9 @@ def xml_propfind_response( child_element.text = xmlutils.make_href( base_prefix, "/%s/" % collection.owner) element.append(child_element) + elif tag == xmlutils.make_clark("C:max-resource-size"): + # RFC4791#5.2.5 + element.text = str(max_resource_size) elif is_collection: if tag == xmlutils.make_clark("D:getcontenttype"): if is_leaf: @@ -407,7 +413,7 @@ class ApplicationPartPropfind(ApplicationBase): headers = {"DAV": httputils.DAV_HEADERS, "Content-Type": "text/xml; charset=%s" % self._encoding} xml_answer = xml_propfind(base_prefix, path, xml_content, - allowed_items, user, self._encoding) + allowed_items, user, self._encoding, max_resource_size=self._max_resource_size) if xml_answer is None: return httputils.NOT_ALLOWED return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) diff --git a/radicale/app/put.py b/radicale/app/put.py index bd049158..1014f95b 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -46,7 +46,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, + content_type: str, permission: bool, parent_permission: bool, max_resource_size: int, tag: Optional[str] = None, write_whole_collection: Optional[bool] = None) -> Tuple[ Iterator[radicale_item.Item], # items @@ -103,6 +103,13 @@ def prepare(vobject_items: List[vobject.base.Component], path: str, else: logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e) raise + size = len(item.serialize()) + if (size > max_resource_size): + logger.warning("PUT request contains item with UID %r size %d > limit %d: %r", item.uid, size, max_resource_size, path) + # Use OverflowError as flag for max_resource_size + raise OverflowError + else: + logger.debug("PUT request contains item with UID %r size %d <= limit %d: %r", item.uid, size, max_resource_size, path) items.append(item) elif write_whole_collection and tag == "VADDRESSBOOK": for vobject_item in vobject_items: @@ -121,12 +128,26 @@ def prepare(vobject_items: List[vobject.base.Component], path: str, else: logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e) raise + size = len(item.serialize()) + if (size > max_resource_size): + logger.warning("PUT request contains item with UID %r size %d > limit %d: %r", item.uid, size, max_resource_size, path) + # Use OverflowError as flag for max_resource_size + raise OverflowError + else: + logger.debug("PUT request contains item with UID %r size %d <= limit %d: %r", item.uid, size, max_resource_size, path) items.append(item) elif not write_whole_collection: vobject_item, = vobject_items item = radicale_item.Item(collection_path=collection_path, vobject_item=vobject_item) item.prepare() + size = len(item.serialize()) + if (size > max_resource_size): + logger.warning("PUT request contains item with UID %r size %d above limit %d: %r", item.uid, size, max_resource_size, path) + # Use OverflowError as flag for max_resource_size + raise OverflowError + else: + logger.debug("PUT request contains item with UID %r size %d below limit %d: %r", item.uid, size, max_resource_size, path) items.append(item) if write_whole_collection: @@ -188,7 +209,8 @@ class ApplicationPartPut(ApplicationBase): prepared_props, prepared_exc_info) = prepare( vobject_items, path, content_type, bool(rights.intersect(access.permissions, "Ww")), - bool(rights.intersect(access.parent_permissions, "w"))) + bool(rights.intersect(access.parent_permissions, "w")), + self._max_resource_size) with self._storage.acquire_lock("w", user, path=path, request="PUT"): item = next(iter(self._storage.discover(path)), None) @@ -252,13 +274,18 @@ 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, tag, write_whole_collection) props = prepared_props if prepared_exc_info: - logger.warning( - "Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1], - exc_info=prepared_exc_info) - return httputils.BAD_REQUEST + # Use OverflowError as flag for max_resource_size + if prepared_exc_info[0] == OverflowError: + return httputils.PRECONDITION_FAILED + else: + logger.warning( + "Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1], + exc_info=prepared_exc_info) + return httputils.BAD_REQUEST if write_whole_collection: try: diff --git a/radicale/config.py b/radicale/config.py index abe7e465..519269bc 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -162,7 +162,11 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "type": positive_int}), ("max_content_length", { "value": "100000000", - "help": "maximum size of request body in bytes", + "help": "maximum size of request body in bytes (default: 100 Mbyte)", + "type": positive_int}), + ("max_resource_size", { + "value": "10000000", + "help": "maximum size of resource (default: 10 Mbyte)", "type": positive_int}), ("timeout", { "value": "30", @@ -600,7 +604,7 @@ This is an automated message. Please do not reply.""", ("profiling_per_request_min_duration", { "value": "3", "help": "log profiling data per request minimum duration (seconds)", - "type": int}), + "type": positive_int}), ("profiling_per_request_header", { "value": "False", "help": "Log profiling request body (if passing minimum duration)", @@ -612,11 +616,11 @@ This is an automated message. Please do not reply.""", ("profiling_per_request_method_interval", { "value": "600", "help": "log profiling data per request method interval (seconds)", - "type": int}), + "type": positive_int}), ("profiling_top_x_functions", { "value": "10", "help": "log profiling top X functions (limit)", - "type": int}), + "type": positive_int}), ("mask_passwords", { "value": "True", "help": "mask passwords in logs", diff --git a/radicale/tests/static/event_multiple3.ics b/radicale/tests/static/event_multiple3.ics new file mode 100644 index 00000000..c8275933 --- /dev/null +++ b/radicale/tests/static/event_multiple3.ics @@ -0,0 +1,40 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:event +SUMMARY:Event +DTSTART;TZID=Europe/Paris:20130901T190000 +DTEND;TZID=Europe/Paris:20130901T200000 +END:VEVENT +BEGIN:VTODO +UID:todo +DTSTART;TZID=Europe/Paris:20130901T220000 +DURATION:PT1H +SUMMARY:Todo +END:VTODO +BEGIN:VEVENT +UID:event2 +SUMMARY:Event-with-longer-description +DTSTART;TZID=Europe/Paris:20130901T190000 +DTEND;TZID=Europe/Paris:20130901T200000 +END:VEVENT +END:VCALENDAR diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index afcca1b2..c1d4e5ab 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -142,6 +142,22 @@ permissions: RrWw""") assert "Event" in answer assert "UID:event" in answer + def test_add_event_exceed_size(self) -> None: + """Add an event which is exceeding max-resource-size.""" + self.configure({"server": {"max_resource_size": 20}}) + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + path = "/calendar.ics/event1.ics" + self.put(path, event, check=412) + + def test_add_events_exceed_size(self) -> None: + """Add multipe events where last is exceeding max-resource-size.""" + self.configure({"server": {"max_resource_size": 603}}) + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_multiple3.ics") + path = "/calendar.ics/" + self.put(path, event, check=412) + def test_add_event_broken(self) -> None: """Add a broken event.""" self.mkcalendar("/calendar.ics/") @@ -676,11 +692,13 @@ permissions: RrWw""") assert not isinstance(response, int) status, prop = response["D:sync-token"] assert status == 200 and prop.text + assert "C:max-resource-size" not in response _, responses = self.propfind("/calendar.ics/event.ics", propfind) response = responses["/calendar.ics/event.ics"] assert not isinstance(response, int) status, prop = response["D:getetag"] assert status == 200 and prop.text + assert "C:max-resource-size" not in response def test_propfind_nonexistent(self) -> None: """Read a property that does not exist.""" @@ -692,6 +710,40 @@ permissions: RrWw""") status, prop = response["ICAL:calendar-color"] assert status == 404 and not prop.text + def test_propfind_max_resource_size(self) -> None: + """Read property C:max-resource-size""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + self.put("/calendar.ics/event.ics", event) + _, responses = self.propfind("/calendar.ics/", """\ + + + + + + """) + response = responses["/calendar.ics/"] + assert not isinstance(response, int) + status, prop = response["C:max-resource-size"] + assert status == 200 and prop.text + + def test_propfind_getctag(self) -> None: + """Read property CS:getctag""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + self.put("/calendar.ics/event.ics", event) + _, responses = self.propfind("/calendar.ics/", """\ + + + + + +""") + response = responses["/calendar.ics/"] + assert not isinstance(response, int) + status, prop = response["CS:getctag"] + assert status == 200 and prop.text + def test_proppatch(self) -> None: """Set/Remove a property and read it back.""" self.mkcalendar("/calendar.ics/")