log on_notice_condition: implementation

This commit is contained in:
Peter Bieringer
2026-05-12 08:29:59 +02:00
parent b6805177f7
commit b9ec1c223f
21 changed files with 263 additions and 70 deletions

View File

@@ -282,7 +282,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
"""Manage a request.""" """Manage a request."""
def response(status: int, headers: types.WSGIResponseHeaders, def response(status: int, headers: types.WSGIResponseHeaders,
answer: Union[None, str, bytes], answer: Union[None, str, bytes],
xml_request: Union[None, str] = None) -> _IntermediateResponse: xml_request: Union[None, str] = None, request_info: dict = {}) -> _IntermediateResponse:
"""Helper to create response from internal types.WSGIResponse""" """Helper to create response from internal types.WSGIResponse"""
headers = dict(headers) headers = dict(headers)
content_encoding = "plain" content_encoding = "plain"
@@ -294,8 +294,16 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug("Response content (nonXML):\n%s", utils.textwrap_str(answer, self._limit_content)) logger.debug("Response content (nonXML):\n%s", utils.textwrap_str(answer, self._limit_content))
else: else:
if logger.isEnabledFor(logging.DEBUG): if self._response_content_on_notice_condition != {}:
logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug") if log.log_conditional(
"response-content",
condition=self._response_content_on_notice_condition,
value=request_info,
):
logger.notice("Response content (nonXML, log condition passed):\n%s", utils.textwrap_str(answer, self._limit_content))
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug")
headers["Content-Type"] += "; charset=%s" % self._encoding headers["Content-Type"] += "; charset=%s" % self._encoding
answer = answer.encode(self._encoding) answer = answer.encode(self._encoding)
accept_encoding = [ accept_encoding = [
@@ -315,12 +323,22 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
# Add extra headers set in configuration # Add extra headers set in configuration
headers.update(self._extra_headers) headers.update(self._extra_headers)
request_info["status"] = status
if self._response_header_on_debug: if self._response_header_on_debug:
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug("Response header:\n%s", utils.textwrap_str(pprint.pformat(headers), self._limit_content)) logger.debug("Response header:\n%s", utils.textwrap_str(pprint.pformat(headers), self._limit_content))
else: else:
if logger.isEnabledFor(logging.DEBUG): if self._response_header_on_notice_condition != {}:
logger.debug("Response header: suppressed by config/option [logging] response_header_on_debug") if log.log_conditional(
"response-header",
condition=self._response_header_on_notice_condition,
value=request_info,
):
logger.notice("Response header (log condition passed):\n%s", utils.textwrap_str(pprint.pformat(headers), self._limit_content))
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Response header: suppressed by config/option [logging] response_header_on_debug")
# Start response # Start response
# delay on error # delay on error
@@ -436,8 +454,10 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
if environ.get("HTTP_X_FORWARDED_HOST") or environ.get("HTTP_X_FORWARDED_PROTO") or environ.get("HTTP_X_FORWARDED_SERVER"): if environ.get("HTTP_X_FORWARDED_HOST") or environ.get("HTTP_X_FORWARDED_PROTO") or environ.get("HTTP_X_FORWARDED_SERVER"):
reverse_proxy = True reverse_proxy = True
remote_useragent = "" remote_useragent = ""
remote_useragent_txt = ""
if environ.get("HTTP_USER_AGENT"): if environ.get("HTTP_USER_AGENT"):
remote_useragent = " using %r" % environ["HTTP_USER_AGENT"] remote_useragent = environ["HTTP_USER_AGENT"]
remote_useragent_txt = " using %r" % environ["HTTP_USER_AGENT"]
depthinfo = "" depthinfo = ""
if environ.get("HTTP_DEPTH"): if environ.get("HTTP_DEPTH"):
depthinfo = " with depth %r" % environ["HTTP_DEPTH"] depthinfo = " with depth %r" % environ["HTTP_DEPTH"]
@@ -447,12 +467,14 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
https_info = "" https_info = ""
logger.info("%s request for %r%s received from %s%s%s", logger.info("%s request for %r%s received from %s%s%s",
request_method, unsafe_path, depthinfo, request_method, unsafe_path, depthinfo,
remote_host, remote_useragent, https_info) remote_host, remote_useragent_txt, https_info)
if self._request_header_on_debug: if self._request_header_on_debug:
logger.debug("Request header:\n%s", logger.debug("Request header:\n%s",
utils.textwrap_str(pprint.pformat(self._scrub_headers(environ)), self._limit_content)) utils.textwrap_str(pprint.pformat(self._scrub_headers(environ)), self._limit_content))
else: else:
logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug") if not self._request_header_on_notice_condition != {}:
# conditional request header logging is later
logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug")
# SCRIPT_NAME is already removed from PATH_INFO, according to the # SCRIPT_NAME is already removed from PATH_INFO, according to the
# WSGI specification. # WSGI specification.
@@ -535,6 +557,23 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
self._rights._user_groups = self._auth._ldap_groups self._rights._user_groups = self._auth._ldap_groups
except AttributeError: except AttributeError:
pass pass
request_info: dict = {
"method": request_method,
"login": login, # not 'user' in this step
"path": path,
"useragent": remote_useragent,
"host": remote_host,
}
if not self._request_header_on_debug and self._request_header_on_notice_condition != {}:
if log.log_conditional(
"request-header",
condition=self._request_header_on_notice_condition,
value=request_info,
):
logger.notice("Request header (log condition passed):\n%s", utils.textwrap_str(pprint.pformat(self._scrub_headers(environ)), self._limit_content))
if user and login == user: if user and login == user:
logger.info("Successful login: %r (%s)", user, info) logger.info("Successful login: %r (%s)", user, info)
elif user: elif user:
@@ -619,7 +658,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
try: try:
status, headers, answer, xml_request = function( status, headers, answer, xml_request = function(
environ, base_prefix, path, user, remote_host, remote_useragent) environ, base_prefix, path, user, request_info)
except PermissionError as e: except PermissionError as e:
logger.error("PermissionError: %s", e) logger.error("PermissionError: %s", e)
status, headers, answer, xml_request = httputils.INTERNAL_SERVER_ERROR status, headers, answer, xml_request = httputils.INTERNAL_SERVER_ERROR
@@ -656,4 +695,4 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
"WWW-Authenticate": "WWW-Authenticate":
"Basic realm=\"%s\"" % self._auth_realm}) "Basic realm=\"%s\"" % self._auth_realm})
return response(status, headers, answer, xml_request) return response(status, headers, answer, xml_request, request_info)

View File

@@ -23,7 +23,7 @@ import unicodedata
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import Optional, Union from typing import Optional, Union
from radicale import (auth, config, hook, httputils, pathutils, rights, from radicale import (auth, config, hook, httputils, log, pathutils, rights,
sharing, storage, types, utils, web, xmlutils) sharing, storage, types, utils, web, xmlutils)
from radicale.log import logger from radicale.log import logger
from radicale.rights import intersect from radicale.rights import intersect
@@ -137,12 +137,16 @@ class ApplicationBase:
self._log_bad_put_request_content = configuration.get("logging", "bad_put_request_content") self._log_bad_put_request_content = configuration.get("logging", "bad_put_request_content")
self._response_content_on_debug = configuration.get("logging", "response_content_on_debug") self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
self._request_content_on_debug = configuration.get("logging", "request_content_on_debug") self._request_content_on_debug = configuration.get("logging", "request_content_on_debug")
self._response_content_on_notice_condition = configuration.get("logging", "response_content_on_notice_condition")
self._request_content_on_notice_condition = configuration.get("logging", "request_content_on_notice_condition")
self._limit_content = configuration.get("logging", "limit_content") self._limit_content = configuration.get("logging", "limit_content")
self._validate_user_value = configuration.get("server", "validate_user_value") self._validate_user_value = configuration.get("server", "validate_user_value")
self._validate_path_value = configuration.get("server", "validate_path_value") self._validate_path_value = configuration.get("server", "validate_path_value")
self._hook = hook.load(configuration) self._hook = hook.load(configuration)
def _read_xml_request_body(self, environ: types.WSGIEnviron def _read_xml_request_body(self,
environ: types.WSGIEnviron,
request_info: dict,
) -> Optional[ET.Element]: ) -> Optional[ET.Element]:
content = httputils.decode_request( content = httputils.decode_request(
self.configuration, environ, self.configuration, environ,
@@ -154,19 +158,41 @@ class ApplicationBase:
except ET.ParseError as e: except ET.ParseError as e:
logger.debug("Request content (Invalid XML):\n%s", content) logger.debug("Request content (Invalid XML):\n%s", content)
raise RuntimeError("Failed to parse XML: %s" % e) from e raise RuntimeError("Failed to parse XML: %s" % e) from e
if logger.isEnabledFor(logging.DEBUG): if self._request_content_on_debug:
if self._request_content_on_debug: if logger.isEnabledFor(logging.DEBUG):
logger.debug("Request content (XML):\n%s", logger.debug("Request content (XML):\n%s", utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
utils.textwrap_str(xmlutils.pretty_xml(xml_content))) else:
if self._request_content_on_notice_condition != {}:
if log.log_conditional(
"request-content",
condition=self._request_content_on_notice_condition,
value=request_info,
):
logger.notice("Request content (XML, log condition passed):\n%s", utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Request content (XML, log condition skipped): suppressed")
else: else:
logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug") if logger.isEnabledFor(logging.DEBUG):
logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug")
return xml_content return xml_content
def _xml_response(self, xml_content: ET.Element) -> bytes: def _xml_response(self, xml_content: ET.Element, request_info: dict) -> bytes:
if logger.isEnabledFor(logging.DEBUG): if self._response_content_on_debug:
if self._response_content_on_debug: if logger.isEnabledFor(logging.DEBUG):
logger.debug("Response content (XML):\n%s", logger.debug("Response content (XML):\n%s",
utils.textwrap_str(xmlutils.pretty_xml(xml_content), self._limit_content)) utils.textwrap_str(xmlutils.pretty_xml(xml_content), self._limit_content))
else:
if self._response_content_on_notice_condition != {}:
if log.log_conditional(
"response-content",
condition=self._response_content_on_notice_condition,
value=request_info,
):
logger.notice("Response content (XML, log condition passed):\n%s", utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Response content (XML, log condition skipped): suppressed")
else: else:
logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug") logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug")
f = io.BytesIO() f = io.BytesIO()
@@ -174,11 +200,11 @@ class ApplicationBase:
xml_declaration=True) xml_declaration=True)
return f.getvalue() return f.getvalue()
def _webdav_error_response(self, status: int, human_tag: str def _webdav_error_response(self, status: int, human_tag: str, request_info: dict
) -> types.WSGIResponse: ) -> types.WSGIResponse:
"""Generate XML error response.""" """Generate XML error response."""
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
content = self._xml_response(xmlutils.webdav_error(human_tag)) content = self._xml_response(xmlutils.webdav_error(human_tag), request_info)
return status, headers, content, None return status, headers, content, None

View File

@@ -55,7 +55,7 @@ def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection,
class ApplicationPartDelete(ApplicationBase): class ApplicationPartDelete(ApplicationBase):
def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str,
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: path: str, user: str, request_info: dict) -> types.WSGIResponse:
"""Manage DELETE request.""" """Manage DELETE request."""
actor = user actor = user
permissions_filter = None permissions_filter = None
@@ -126,4 +126,4 @@ class ApplicationPartDelete(ApplicationBase):
for notification_item in hook_notification_item_list: # Will be empty if hook not enabled for notification_item in hook_notification_item_list: # Will be empty if hook not enabled
self._hook.notify(notification_item) self._hook.notify(notification_item)
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
return client.OK, headers, self._xml_response(xml_answer), None return client.OK, headers, self._xml_response(xml_answer, request_info), None

View File

@@ -68,7 +68,7 @@ class ApplicationPartGet(ApplicationBase):
return value return value
def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: user: str, request_info: dict) -> types.WSGIResponse:
"""Manage GET request.""" """Manage GET request."""
# Redirect to /.web if the root path is requested # Redirect to /.web if the root path is requested
if not pathutils.strip_path(path): if not pathutils.strip_path(path):
@@ -84,7 +84,7 @@ class ApplicationPartGet(ApplicationBase):
base_prefix + unsafe_path, location) base_prefix + unsafe_path, location)
return httputils.redirect(location, client.MOVED_PERMANENTLY) return httputils.redirect(location, client.MOVED_PERMANENTLY)
# Dispatch /.web path to web module # Dispatch /.web path to web module
return self._web.get(environ, base_prefix, path, user) return self._web.get(environ, base_prefix, path, user, request_info)
permissions_filter = None permissions_filter = None
share = None share = None
if self._sharing._enabled: if self._sharing._enabled:

View File

@@ -26,7 +26,7 @@ from radicale.app.get import ApplicationPartGet
class ApplicationPartHead(ApplicationPartGet, ApplicationBase): class ApplicationPartHead(ApplicationPartGet, ApplicationBase):
def do_HEAD(self, environ: types.WSGIEnviron, base_prefix: str, path: str, def do_HEAD(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: user: str, request_info: dict) -> types.WSGIResponse:
"""Manage HEAD request.""" """Manage HEAD request."""
# Body is dropped in `Application.__call__` for HEAD requests # Body is dropped in `Application.__call__` for HEAD requests
return self.do_GET(environ, base_prefix, path, user, remote_host, remote_useragent) return self.do_GET(environ, base_prefix, path, user, request_info)

View File

@@ -32,12 +32,12 @@ from radicale.log import logger
class ApplicationPartMkcalendar(ApplicationBase): class ApplicationPartMkcalendar(ApplicationBase):
def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str, def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str,
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: path: str, user: str, request_info: dict) -> types.WSGIResponse:
"""Manage MKCALENDAR request.""" """Manage MKCALENDAR request."""
if "w" not in self._rights.authorization(user, path): if "w" not in self._rights.authorization(user, path):
return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED
try: try:
xml_content = self._read_xml_request_body(environ) xml_content = self._read_xml_request_body(environ, request_info)
except RuntimeError as e: except RuntimeError as e:
logger.warning( logger.warning(
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True) "Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
@@ -67,7 +67,7 @@ class ApplicationPartMkcalendar(ApplicationBase):
item = next(iter(self._storage.discover(path)), None) item = next(iter(self._storage.discover(path)), None)
if item: if item:
return self._webdav_error_response( return self._webdav_error_response(
client.CONFLICT, "D:resource-must-be-null") client.CONFLICT, "D:resource-must-be-null", request_info)
parent_path = pathutils.parent_path(path) parent_path = pathutils.parent_path(path)
parent_item = next(iter(self._storage.discover(parent_path)), None) parent_item = next(iter(self._storage.discover(parent_path)), None)
if not parent_item: if not parent_item:

View File

@@ -32,13 +32,13 @@ from radicale.log import logger
class ApplicationPartMkcol(ApplicationBase): class ApplicationPartMkcol(ApplicationBase):
def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str, def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str,
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: path: str, user: str, request_info: dict) -> types.WSGIResponse:
"""Manage MKCOL request.""" """Manage MKCOL request."""
permissions = self._rights.authorization(user, path) permissions = self._rights.authorization(user, path)
if not rights.intersect(permissions, "Ww"): if not rights.intersect(permissions, "Ww"):
return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED
try: try:
xml_content = self._read_xml_request_body(environ) xml_content = self._read_xml_request_body(environ, request_info)
except RuntimeError as e: except RuntimeError as e:
logger.warning( logger.warning(
"Bad MKCOL request on %r: %s", path, e, exc_info=True) "Bad MKCOL request on %r: %s", path, e, exc_info=True)

View File

@@ -49,7 +49,7 @@ def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False):
class ApplicationPartMove(ApplicationBase): class ApplicationPartMove(ApplicationBase):
def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str,
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: path: str, user: str, request_info: dict) -> types.WSGIResponse:
"""Manage MOVE request.""" """Manage MOVE request."""
raw_dest = environ.get("HTTP_DESTINATION", "") raw_dest = environ.get("HTTP_DESTINATION", "")
@@ -136,7 +136,7 @@ class ApplicationPartMove(ApplicationBase):
to_collection.has_uid(item.uid)): to_collection.has_uid(item.uid)):
return self._webdav_error_response( return self._webdav_error_response(
client.CONFLICT, "%s:no-uid-conflict" % ( client.CONFLICT, "%s:no-uid-conflict" % (
"C" if collection_tag == "VCALENDAR" else "CR")) "C" if collection_tag == "VCALENDAR" else "CR"), request_info)
to_href = posixpath.basename(pathutils.strip_path(to_path)) to_href = posixpath.basename(pathutils.strip_path(to_path))
try: try:
self._storage.move(item, to_collection, to_href) self._storage.move(item, to_collection, to_href)

View File

@@ -28,7 +28,7 @@ from radicale.app.base import ApplicationBase
class ApplicationPartOptions(ApplicationBase): class ApplicationPartOptions(ApplicationBase):
def do_OPTIONS(self, environ: types.WSGIEnviron, base_prefix: str, def do_OPTIONS(self, environ: types.WSGIEnviron, base_prefix: str,
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: path: str, user: str, request_info: dict) -> types.WSGIResponse:
"""Manage OPTIONS request.""" """Manage OPTIONS request."""
headers = { headers = {
"Allow": ", ".join( "Allow": ", ".join(

View File

@@ -26,10 +26,10 @@ from radicale.app.base import ApplicationBase
class ApplicationPartPost(ApplicationBase): class ApplicationPartPost(ApplicationBase):
def do_POST(self, environ: types.WSGIEnviron, base_prefix: str, def do_POST(self, environ: types.WSGIEnviron, base_prefix: str,
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: path: str, user: str, request_info: dict) -> types.WSGIResponse:
"""Manage POST request.""" """Manage POST request."""
if path == "/.web" or path.startswith("/.web/"): if path == "/.web" or path.startswith("/.web/"):
return self._web.post(environ, base_prefix, path, user) return self._web.post(environ, base_prefix, path, user, request_info)
elif path == "/.sharing" or path.startswith("/.sharing/"): elif path == "/.sharing" or path.startswith("/.sharing/"):
return self._sharing.post(environ, base_prefix, path, user) return self._sharing.post(environ, base_prefix, path, user, request_info)
return httputils.METHOD_NOT_ALLOWED return httputils.METHOD_NOT_ALLOWED

View File

@@ -574,7 +574,7 @@ class ApplicationPartPropfind(ApplicationBase):
yield item, permission, raw_permissions yield item, permission, raw_permissions
def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str,
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: path: str, user: str, request_info: dict) -> types.WSGIResponse:
"""Manage PROPFIND request.""" """Manage PROPFIND request."""
http_depth = environ.get("HTTP_DEPTH", "0") http_depth = environ.get("HTTP_DEPTH", "0")
permissions_filter = None permissions_filter = None
@@ -594,7 +594,7 @@ class ApplicationPartPropfind(ApplicationBase):
if not access.check("r"): if not access.check("r"):
return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED
try: try:
xml_content = self._read_xml_request_body(environ) xml_content = self._read_xml_request_body(environ, request_info)
except RuntimeError as e: except RuntimeError as e:
logger.warning( logger.warning(
"Bad PROPFIND request on %r: %s", path, e, exc_info=True) "Bad PROPFIND request on %r: %s", path, e, exc_info=True)
@@ -659,4 +659,5 @@ class ApplicationPartPropfind(ApplicationBase):
allowed_items, user, self._encoding, max_resource_size=self._max_resource_size, shares=shares) allowed_items, user, self._encoding, max_resource_size=self._max_resource_size, shares=shares)
if xml_answer is None: if xml_answer is None:
return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED
return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) request_info["status"] = client.MULTI_STATUS
return client.MULTI_STATUS, headers, self._xml_response(xml_answer, request_info), xmlutils.pretty_xml(xml_content)

View File

@@ -97,7 +97,7 @@ def xml_proppatch(base_prefix: str, path: str,
class ApplicationPartProppatch(ApplicationBase): class ApplicationPartProppatch(ApplicationBase):
def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str,
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: path: str, user: str, request_info: dict) -> types.WSGIResponse:
"""Manage PROPPATCH request.""" """Manage PROPPATCH request."""
actor = user actor = user
permissions_filter = None permissions_filter = None
@@ -155,7 +155,8 @@ class ApplicationPartProppatch(ApplicationBase):
logger.info("PROPPATCH request on shared %r: write-permissions, overlay not enforced, but enforced by permission 'E'", path_orig) logger.info("PROPPATCH request on shared %r: write-permissions, overlay not enforced, but enforced by permission 'E'", path_orig)
share_overlay = True share_overlay = True
try: try:
xml_content = self._read_xml_request_body(environ) xml_content = self._read_xml_request_body(environ, request_info)
except RuntimeError as e: except RuntimeError as e:
logger.warning( logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True) "Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
@@ -193,7 +194,8 @@ class ApplicationPartProppatch(ApplicationBase):
logger.warning( logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True) "Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST return httputils.BAD_REQUEST
return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) request_info["status"] = client.MULTI_STATUS
return client.MULTI_STATUS, headers, self._xml_response(xml_answer, request_info), xmlutils.pretty_xml(xml_content)
with self._storage.acquire_lock("w", user, path=path, request="PROPPATCH"): with self._storage.acquire_lock("w", user, path=path, request="PROPPATCH"):
item = next(iter(self._storage.discover(path)), None) item = next(iter(self._storage.discover(path)), None)
@@ -242,4 +244,5 @@ class ApplicationPartProppatch(ApplicationBase):
logger.warning( logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True) "Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST return httputils.BAD_REQUEST
return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) request_info["status"] = client.MULTI_STATUS
return client.MULTI_STATUS, headers, self._xml_response(xml_answer, request_info), xmlutils.pretty_xml(xml_content)

View File

@@ -179,7 +179,7 @@ def prepare(vobject_items: List[vobject.base.Component], path: str,
class ApplicationPartPut(ApplicationBase): class ApplicationPartPut(ApplicationBase):
def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str,
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: path: str, user: str, request_info: dict) -> types.WSGIResponse:
"""Manage PUT request.""" """Manage PUT request."""
actor = user actor = user
permissions_filter = None permissions_filter = None
@@ -196,7 +196,7 @@ class ApplicationPartPut(ApplicationBase):
if not access.check("w"): if not access.check("w"):
return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED
try: try:
content = httputils.read_request_body(self.configuration, environ) content = httputils.read_request_body(self.configuration, environ, request_info)
except RuntimeError as e: except RuntimeError as e:
logger.warning("Bad PUT request on %r (read_request_body): %s", path, e, exc_info=True) logger.warning("Bad PUT request on %r (read_request_body): %s", path, e, exc_info=True)
return httputils.BAD_REQUEST return httputils.BAD_REQUEST
@@ -344,9 +344,10 @@ class ApplicationPartPut(ApplicationBase):
prepared_item, = prepared_items prepared_item, = prepared_items
if (item and item.uid != prepared_item.uid or if (item and item.uid != prepared_item.uid or
not item and parent_item.has_uid(prepared_item.uid)): not item and parent_item.has_uid(prepared_item.uid)):
request_info["status"] = client.CONFLICT
return self._webdav_error_response( return self._webdav_error_response(
client.CONFLICT, "%s:no-uid-conflict" % ( client.CONFLICT, "%s:no-uid-conflict" % (
"C" if tag == "VCALENDAR" else "CR")) "C" if tag == "VCALENDAR" else "CR"), request_info)
href = posixpath.basename(pathutils.strip_path(path)) href = posixpath.basename(pathutils.strip_path(path))
try: try:

View File

@@ -149,7 +149,7 @@ def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Eleme
def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
collection: storage.BaseCollection, encoding: str, collection: storage.BaseCollection, encoding: str,
unlock_storage_fn: Callable[[], None], unlock_storage_fn: Callable[[], None],
max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = "", max_occurrence: int = 0, user: str = "", request_info: dict = {},
share: Union[dict, None] = None) -> Tuple[int, ET.Element]: share: Union[dict, None] = None) -> Tuple[int, ET.Element]:
"""Read and answer REPORT requests that return XML. """Read and answer REPORT requests that return XML.
@@ -216,8 +216,11 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
sync_token, names = collection.sync(old_sync_token) sync_token, names = collection.sync(old_sync_token)
except ValueError as e: except ValueError as e:
# Invalid sync token # Invalid sync token
remote_useragent_txt = ""
if request_info["useragent"] != "":
remote_useragent_txt = " using %r" % request_info["useragent"]
logger.warning("Client provided invalid sync token for path %r (user %r from %s%s): %s", logger.warning("Client provided invalid sync token for path %r (user %r from %s%s): %s",
path, user, remote_addr, remote_useragent, e, exc_info=True) path, user, request_info["host"], remote_useragent_txt, e, exc_info=True)
# client.CONFLICT doesn't work with some clients (e.g. InfCloud) # client.CONFLICT doesn't work with some clients (e.g. InfCloud)
return (client.FORBIDDEN, return (client.FORBIDDEN,
xmlutils.webdav_error("D:valid-sync-token")) xmlutils.webdav_error("D:valid-sync-token"))
@@ -851,7 +854,7 @@ def test_filter(collection_tag: str, item: radicale_item.Item,
class ApplicationPartReport(ApplicationBase): class ApplicationPartReport(ApplicationBase):
def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str,
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: path: str, user: str, request_info: dict) -> types.WSGIResponse:
"""Manage REPORT request.""" """Manage REPORT request."""
permissions_filter = None permissions_filter = None
share = None share = None
@@ -867,7 +870,7 @@ class ApplicationPartReport(ApplicationBase):
if not access.check("r"): if not access.check("r"):
return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED
try: try:
xml_content = self._read_xml_request_body(environ) xml_content = self._read_xml_request_body(environ, request_info)
except RuntimeError as e: except RuntimeError as e:
logger.warning("Bad REPORT request on %r: %s", path, e, logger.warning("Bad REPORT request on %r: %s", path, e,
exc_info=True) exc_info=True)
@@ -905,10 +908,11 @@ class ApplicationPartReport(ApplicationBase):
try: try:
status, xml_answer = xml_report( status, xml_answer = xml_report(
base_prefix, path, xml_content, collection, self._encoding, base_prefix, path, xml_content, collection, self._encoding,
lock_stack.close, max_occurrence, user, remote_host, remote_useragent, share=share) lock_stack.close, max_occurrence, user, request_info, share=share)
except ValueError as e: except ValueError as e:
logger.warning( logger.warning(
"Bad REPORT request on %r: %s", path, e, exc_info=True) "Bad REPORT request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST return httputils.BAD_REQUEST
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding} headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
return status, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) request_info["status"] = status
return status, headers, self._xml_response(xml_answer, request_info), xmlutils.pretty_xml(xml_content)

View File

@@ -32,7 +32,7 @@ import time
from http import client from http import client
from typing import List, Mapping, Union, cast from typing import List, Mapping, Union, cast
from radicale import config, pathutils, types, utils from radicale import config, log, pathutils, types, utils
from radicale.log import logger from radicale.log import logger
if sys.version_info < (3, 9): if sys.version_info < (3, 9):
@@ -151,16 +151,29 @@ def read_raw_request_body(configuration: "config.Configuration",
def read_request_body(configuration: "config.Configuration", def read_request_body(configuration: "config.Configuration",
environ: types.WSGIEnviron) -> str: environ: types.WSGIEnviron, request_info: dict) -> str:
content = decode_request(configuration, environ, content = decode_request(configuration, environ,
read_raw_request_body(configuration, environ)) read_raw_request_body(configuration, environ))
if logger.isEnabledFor(logging.DEBUG): _limit_content = configuration.get("logging", "limit_content")
if configuration.get("logging", "request_content_on_debug"): if configuration.get("logging", "request_content_on_debug"):
_limit_content = configuration.get("logging", "limit_content") if logger.isEnabledFor(logging.DEBUG):
logger.debug("Request content (sha256sum): %s", utils.sha256_str(content)) logger.debug("Request content (sha256sum): %s", utils.sha256_str(content))
logger.debug("Request content:\n%s", utils.textwrap_str(content, _limit_content)) logger.debug("Request content:\n%s", utils.textwrap_str(content, _limit_content))
else:
_request_content_on_notice_condition = configuration.get("logging", "request_content_on_notice_condition")
if _request_content_on_notice_condition != {}:
if log.log_conditional(
"request-content",
condition=_request_content_on_notice_condition,
value=request_info,
):
logger.notice("Request content (log condition passed):\n%s", utils.textwrap_str(content, _limit_content))
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Request content (log condition skipped): suppressed")
else: else:
logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug") if logger.isEnabledFor(logging.DEBUG):
logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug")
return content return content

View File

@@ -28,8 +28,10 @@ Log messages are sent to the first available target of:
import contextlib import contextlib
import io import io
import ipaddress
import logging import logging
import os import os
import re
import socket import socket
import struct import struct
import sys import sys
@@ -48,6 +50,19 @@ LOGGER_FORMATS: Mapping[str, str] = {
} }
DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S %z" DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S %z"
LOG_CONDITION_TOKEN: dict = {"method": "str",
"path": "str",
"useragent": "str",
"host": "ipaddress",
"login": "str",
"status": "int",
}
LOG_CONDITION_CONDITION: list = ["match", "value"]
LOG_CONDITION_MATCH_STR: list = ["startswith", "endswith", "equal", "re", "==", "="]
LOG_CONDITION_MATCH_INT: list = ["=", ">", "<", "<=", ">=", "equal", "==", "<>", "!="]
LOG_CONDITION_MATCH_IPADDRESS: list = ["==", "equal", "=", "<>", "!="]
LOG_CONDITION_MATCH_IPNETWORK: list = ["included", "excluded", "incl", "excl"]
LOG_LEVEL_OPTIONS: list = ["trace", "debug", "info", "notice", "warning", "error", "critical", "alert"] LOG_LEVEL_OPTIONS: list = ["trace", "debug", "info", "notice", "warning", "error", "critical", "alert"]
LOG_LEVEL_TRACE: int = 5 LOG_LEVEL_TRACE: int = 5
@@ -326,3 +341,90 @@ def set_level(level: Union[int, str], backtrace_on_debug: bool, trace_filter: st
logger.addFilter(PassTRACETOKENFilter(trace_filter)) logger.addFilter(PassTRACETOKENFilter(trace_filter))
else: else:
logger.trace("Logging messages on 'trace' level enabled") logger.trace("Logging messages on 'trace' level enabled")
def log_conditional(name: str, condition: dict, value: dict) -> bool:
logger.trace("log/conditional/%s/CHECK : condition=%r value=%r", name, condition, value)
# "and" combination
condition_active = False
for token in condition:
condition_active = True
if LOG_CONDITION_TOKEN[token] == "str":
logger.trace("log/conditional/%s/%s/string/CHECK : %r %s %r", name, token, condition[token]["value"], condition[token]["match"], value[token])
if condition[token]["match"] in ["equal", "==", "="]:
if not condition[token]["value"] == value[token]:
return False
elif condition[token]["match"] in ["startswith"]:
if not value[token].startswith(condition[token]["value"]):
return False
elif condition[token]["match"] in ["endswith"]:
if not value[token].endswith(condition[token]["value"]):
return False
elif condition[token]["match"] in ["re"]:
if not re.search(condition[token]["value"], value[token]):
return False
logger.trace("log/conditional/%s/%s/string/PASSED : %r %s %r", name, token, condition[token]["value"], condition[token]["match"], value[token])
elif LOG_CONDITION_TOKEN[token] == "int":
condition[token]["value"] = int(condition[token]["value"])
logger.trace("log/conditional/%s/%s/integer/CHECK : %r %s %r", name, token, condition[token]["value"], condition[token]["match"], value[token])
if condition[token]["match"] in ["equal", "==", "="]:
if not condition[token]["value"] == value[token]:
return False
elif condition[token]["match"] in ["<"]:
if not condition[token]["value"] < value[token]:
return False
elif condition[token]["match"] in ["<="]:
if not condition[token]["value"] <= value[token]:
return False
elif condition[token]["match"] in [">"]:
if not condition[token]["value"] > value[token]:
return False
elif condition[token]["match"] in [">="]:
if not condition[token]["value"] >= value[token]:
return False
elif condition[token]["match"] in ["<>", "!="]:
if not condition[token]["value"] != value[token]:
return False
logger.trace("log/conditional/%s/%s/integer/PASSED : %r %s %r", name, token, condition[token]["value"], condition[token]["match"], value[token])
elif LOG_CONDITION_TOKEN[token] == "ipaddress":
logger.trace("log/conditional/%s/%s/ip/CHECK : %r %s %r", name, token, condition[token]["value"], condition[token]["match"], value[token])
# simple IP address check
if condition[token]["match"] in ["equal", "==", "="]:
if not condition[token]["value"] == value[token]:
return False
elif condition[token]["match"] in ["<>", "!="]:
if not condition[token]["value"] != value[token]:
return False
elif condition[token]["match"] in ["included", "incl", "excluded", "excl"]:
if condition[token]["match"] in ["included", "incl"]:
if value[token] == "unknown":
return False
else:
ip_net = ipaddress.ip_network(value[token])
ip = ipaddress.ip_network(condition[token]["value"])
if type(ip_net) is ipaddress.IPv4Network and type(ip) is ipaddress.IPv4Network:
if not ip_net.subnet_of(ip):
return False
elif type(ip_net) is ipaddress.IPv6Network and type(ip) is ipaddress.IPv6Network:
if not ip_net.subnet_of(ip):
return False
else:
return False
elif condition[token]["match"] in ["excluded", "excl"]:
if value[token] != "unknown":
ip_net = ipaddress.ip_network(value[token])
if type(ip_net) is ipaddress.IPv4Network and type(ip) is ipaddress.IPv4Network:
if ip_net.subnet_of(ip):
return False
elif type(ip_net) is ipaddress.IPv6Network and type(ip) is ipaddress.IPv6Network:
if ip_net.subnet_of(ip):
return False
logger.trace("log/conditional/%s/%s/ip/PASSED : %r %s %r", name, token, condition[token]["value"], condition[token]["match"], value[token])
else:
logger.trace("log/conditional/%s/%s/ip/SKIPPED: %r %s %r", name, token, condition[token]["value"], condition[token]["match"], value[token])
# only return 'True' if at least one condition was found
logger.trace("log/conditional/%s/PASSED: condition=%r value=%r", name, condition, value)
return condition_active

View File

@@ -511,7 +511,7 @@ class BaseSharing:
return None return None
# *** POST API *** # *** POST API ***
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, request_info: dict) -> types.WSGIResponse:
# Late import to avoid circular dependency in config # Late import to avoid circular dependency in config
from radicale.app import base as app_base from radicale.app import base as app_base
from radicale.app.base import Access from radicale.app.base import Access
@@ -616,7 +616,7 @@ class BaseSharing:
logger.trace("sharing/API: called by authenticated user: %r", user) logger.trace("sharing/API: called by authenticated user: %r", user)
# read POST data # read POST data
try: try:
request_body = httputils.read_request_body(self.configuration, environ) request_body = httputils.read_request_body(self.configuration, environ, request_info)
except RuntimeError as e: except RuntimeError as e:
logger.warning("Bad POST request on %r (read_request_body): %s", path, e, exc_info=True) logger.warning("Bad POST request on %r (read_request_body): %s", path, e, exc_info=True)
return httputils.bad_request("Failed read POST request body") return httputils.bad_request("Failed read POST request body")

View File

@@ -28,10 +28,10 @@ from radicale import httputils, types, web
class Web(web.BaseWeb): class Web(web.BaseWeb):
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse: user: str, request_info: dict) -> types.WSGIResponse:
return client.OK, {"Content-Type": "text/plain"}, "custom", None return client.OK, {"Content-Type": "text/plain"}, "custom", None
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse: user: str, request_info: dict) -> types.WSGIResponse:
content = httputils.read_request_body(self.configuration, environ) content = httputils.read_request_body(self.configuration, environ, request_info)
return client.OK, {"Content-Type": "text/plain"}, "echo:" + content, None return client.OK, {"Content-Type": "text/plain"}, "echo:" + content, None

View File

@@ -49,7 +49,7 @@ class BaseWeb:
self.configuration = configuration self.configuration = configuration
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse: user: str, request_info: dict) -> types.WSGIResponse:
"""GET request. """GET request.
``base_prefix`` is sanitized and never ends with "/". ``base_prefix`` is sanitized and never ends with "/".
@@ -58,11 +58,13 @@ class BaseWeb:
``user`` is empty for anonymous users. ``user`` is empty for anonymous users.
``request_info`` dict of additional information
""" """
return httputils.METHOD_NOT_ALLOWED return httputils.METHOD_NOT_ALLOWED
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse: user: str, request_info: dict) -> types.WSGIResponse:
"""POST request. """POST request.
``base_prefix`` is sanitized and never ends with "/". ``base_prefix`` is sanitized and never ends with "/".
@@ -71,6 +73,8 @@ class BaseWeb:
``user`` is empty for anonymous users. ``user`` is empty for anonymous users.
``request_info`` dict of additional information
Use ``httputils.read*_request_body(self.configuration, environ)`` to Use ``httputils.read*_request_body(self.configuration, environ)`` to
read the body. read the body.

View File

@@ -34,6 +34,6 @@ FALLBACK_MIMETYPE = httputils.FALLBACK_MIMETYPE # deprecated
class Web(web.BaseWeb): class Web(web.BaseWeb):
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse: user: str, request_info: dict) -> types.WSGIResponse:
return httputils.serve_resource("radicale.web", "internal_data", return httputils.serve_resource("radicale.web", "internal_data",
base_prefix, path) base_prefix, path)

View File

@@ -28,7 +28,7 @@ from radicale import httputils, pathutils, types, web
class Web(web.BaseWeb): class Web(web.BaseWeb):
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str, def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
user: str) -> types.WSGIResponse: user: str, request_info: dict) -> types.WSGIResponse:
assert path == "/.web" or path.startswith("/.web/") assert path == "/.web" or path.startswith("/.web/")
assert pathutils.sanitize_path(path) == path assert pathutils.sanitize_path(path) == path
if path != "/.web": if path != "/.web":