Merge pull request #2124 from pbiering/logging-conditional

Add support for conditional logging
This commit is contained in:
Peter Bieringer
2026-05-12 09:17:07 +02:00
committed by GitHub
25 changed files with 658 additions and 77 deletions

View File

@@ -1803,6 +1803,65 @@ Log response content (body) on `level = debug`
Default: `False` Default: `False`
##### request_header_on_notice_condition
_(>= 3.7.3)_
Log request header on `level = notice` if condition is fulfilled
Default: `{}`
Format: JSON structure as text
Supported tokens:
* `method`
* `path`
* `useragent`
* `host` (IPv4/IPv6 address/network)
* `login`
* `status` (only supported on responses)
Supported matches:
* str: `startswith`, `endswith`, `equal`, `re`
* int: `==`, `<`, `<=`, `>=`, `>`, `!=`
* IP address: `equal`, `==`, `!=`
* IP network: `included`, `excluded`
Examples:
* `{"method": {"match": "equal", "value": "GET"}, "login": {"match": "equal", "value": "owner"}}`
* `{"method": {"match": "equal", "value": "PUT"}, "status": {"match": ">=", "value": "201"}, "host": {"match": "included", "value": "127.0.0.0/8"}}`
* `{"method": {"match": "equal", "value": "PUT"}, "path": {"match": "re", "value": "ev?nt[01]"}}`
##### request_content_on_notice_condition
_(>= 3.7.3)_
Log request content (body) on `level = notice` if condition is fulfilled
Default: `{}`
Format: see `request_header_on_notice_condition`
##### response_header_on_notice_condition
_(>= 3.7.3)_
Log response header on `level = notice` if condition is fulfilled
Default: `{}`
Format: see `request_header_on_notice_condition`
##### response_content_on_notice_condition
_(>= 3.7.3)_
Log response content (body) on `level = notice` if condition is fulfilled
Default: `{}`
Format: see `request_header_on_notice_condition`
##### rights_rule_doesnt_match_on_debug ##### rights_rule_doesnt_match_on_debug
_(>= 3.2.3)_ _(>= 3.2.3)_

12
config
View File

@@ -397,6 +397,18 @@
# Log response content on level=debug # Log response content on level=debug
#response_content_on_debug = False #response_content_on_debug = False
# Log request header on level=notice with condition
#request_header_on_notice_condition = {}
# Log request content on level=notice with condition
#request_content_on_notice_condition = {}
# Log response header on level=notice with condition
#response_header_on_notice_condition = {}
# Log response content on level=notice with condition
#response_content_on_notice_condition = {}
# Log rights rule which doesn't match on level=debug # Log rights rule which doesn't match on level=debug
#rights_rule_doesnt_match_on_debug = False #rights_rule_doesnt_match_on_debug = False

View File

@@ -130,6 +130,14 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
logger.debug("log request content on debug: %s", self._request_content_on_debug) logger.debug("log request content on debug: %s", self._request_content_on_debug)
logger.debug("log response header on debug: %s", self._response_header_on_debug) logger.debug("log response header on debug: %s", self._response_header_on_debug)
logger.debug("log response content on debug: %s", self._response_content_on_debug) logger.debug("log response content on debug: %s", self._response_content_on_debug)
self._request_header_on_notice_condition = configuration.get("logging", "request_header_on_notice_condition")
self._request_content_on_notice_condition = configuration.get("logging", "request_content_on_notice_condition")
self._response_header_on_notice_condition = configuration.get("logging", "response_header_on_notice_condition")
self._response_content_on_notice_condition = configuration.get("logging", "response_content_on_notice_condition")
logger.notice("log request header on notice condition: %s", self._request_header_on_notice_condition)
logger.notice("log request content on notice condition: %s", self._request_content_on_notice_condition)
logger.notice("log response header on notice condition: %s", self._response_header_on_notice_condition)
logger.notice("log response content on notice condition: %s", self._response_content_on_notice_condition)
self._limit_content = configuration.get("logging", "limit_content") self._limit_content = configuration.get("logging", "limit_content")
logger.debug("log limit for content: %d", self._limit_content) logger.debug("log limit for content: %d", self._limit_content)
self._auth_delay = configuration.get("auth", "delay") self._auth_delay = configuration.get("auth", "delay")
@@ -274,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"
@@ -286,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 = [
@@ -307,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
@@ -428,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"]
@@ -439,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.
@@ -527,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:
@@ -611,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
@@ -648,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

@@ -27,9 +27,11 @@ Use ``load()`` to obtain an instance of ``Configuration`` for use with
""" """
import contextlib import contextlib
import ipaddress
import json import json
import math import math
import os import os
import re
import string import string
import sys import sys
from collections import OrderedDict from collections import OrderedDict
@@ -167,6 +169,66 @@ def json_str(value: Any) -> dict:
return ret return ret
def json_str_condition(value: Any) -> dict:
if not value:
return {}
ret = json.loads(value)
for (token, props) in ret.items():
checked_props = check_and_sanitize_props(props)
# check token
if token not in log.LOG_CONDITION_TOKEN:
raise ValueError("unsupported log condition token: %r" % token)
# check condition entry
for cond_name in checked_props:
if cond_name not in log.LOG_CONDITION_CONDITION:
raise ValueError("unsupported log condition: %r" % cond_name)
if cond_name == "match":
if log.LOG_CONDITION_TOKEN[token] == "str":
if checked_props[cond_name] not in log.LOG_CONDITION_MATCH_STR:
raise ValueError("unsupported log match: %r" % checked_props[cond_name])
elif log.LOG_CONDITION_TOKEN[token] == "int":
if checked_props[cond_name] not in log.LOG_CONDITION_MATCH_INT:
raise ValueError("unsupported log match: %r" % checked_props[cond_name])
elif log.LOG_CONDITION_TOKEN[token] == "ipaddress":
if checked_props[cond_name] not in log.LOG_CONDITION_MATCH_IPADDRESS + log.LOG_CONDITION_MATCH_IPNETWORK:
raise ValueError("unsupported log match: %r" % checked_props[cond_name])
else:
raise RuntimeError("unsupported log match (fix code): %r" % cond_name)
if cond_name == "value":
if log.LOG_CONDITION_TOKEN[token] == "str":
if checked_props["match"] == "re":
try:
if re.match(checked_props[cond_name], "Test"):
pass
else:
pass
except Exception as e:
raise ValueError("unsupported log match value(re) for condition %r: %r (%s)" % (checked_props["match"], checked_props[cond_name], e))
pass
elif log.LOG_CONDITION_TOKEN[token] == "int":
if str(int(checked_props[cond_name])) != checked_props[cond_name]:
raise ValueError("unsupported log match value(int): %r" % checked_props[cond_name])
if token == "status":
# only 100-599 are valid
if int(checked_props[cond_name]) < 100 or int(checked_props[cond_name]) > 599:
raise ValueError("unsupported log match value(int) not in range 100-599: %r" % checked_props[cond_name])
elif log.LOG_CONDITION_TOKEN[token] == "ipaddress":
try:
if checked_props["match"] in log.LOG_CONDITION_MATCH_IPADDRESS:
ip = ipaddress.ip_address(checked_props[cond_name]) # noqa: F841
elif checked_props["match"] in log.LOG_CONDITION_MATCH_IPNETWORK:
ip_net = ipaddress.ip_network(checked_props[cond_name], strict=True) # noqa: F841
except Exception as e:
raise ValueError("unsupported log match value(ipaddress) for condition %r: %r (%s)" % (checked_props["match"], checked_props[cond_name], e))
# check condition entries are complete
for cond_name in log.LOG_CONDITION_CONDITION:
if cond_name not in checked_props:
raise ValueError("incomplete condition, misses: %r" % cond_name)
# all checks passed
ret[token] = checked_props
return ret
INTERNAL_OPTIONS: Sequence[str] = ("_allow_extra",) INTERNAL_OPTIONS: Sequence[str] = ("_allow_extra",)
# Default configuration # Default configuration
DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
@@ -667,6 +729,22 @@ This is an automated message. Please do not reply.""",
"value": "False", "value": "False",
"help": "log response content on level=debug", "help": "log response content on level=debug",
"type": bool}), "type": bool}),
("request_header_on_notice_condition", {
"value": "{}",
"help": "log request header on level=notice with condition",
"type": json_str_condition}),
("request_content_on_notice_condition", {
"value": "{}",
"help": "log request content on level=notice with condition",
"type": json_str_condition}),
("response_header_on_notice_condition", {
"value": "{}",
"help": "log response header on level=notice with condition",
"type": json_str_condition}),
("response_content_on_notice_condition", {
"value": "{}",
"help": "log response content on level=notice with condition",
"type": json_str_condition}),
("rights_rule_doesnt_match_on_debug", { ("rights_rule_doesnt_match_on_debug", {
"value": "False", "value": "False",
"help": "log rights rules which doesn't match on level=debug", "help": "log rights rules which doesn't match on level=debug",
@@ -856,8 +934,7 @@ class Configuration:
except Exception as e: except Exception as e:
raise RuntimeError( raise RuntimeError(
"Invalid %s value for option %r in section %r in %s: " "Invalid %s value for option %r in section %r in %s: "
"%r" % (type_.__name__, option, section, source, "%r (%s)" % (type_.__name__, option, section, source, raw_value, e)) from e
raw_value)) from e
self._configs.append((config, source, bool(privileged))) self._configs.append((config, source, bool(privileged)))
for section in new_values: for section in new_values:
self._values[section] = self._values.get(section, {}) self._values[section] = self._values.get(section, {})

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

@@ -1,7 +1,7 @@
# This file is part of Radicale - CalDAV and CardDAV server # This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2011-2017 Guillaume Ayoub # Copyright © 2011-2017 Guillaume Ayoub
# Copyright © 2017-2023 Unrud <unrud@outlook.com> # Copyright © 2017-2023 Unrud <unrud@outlook.com>
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de> # Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
# #
# This library is free software: you can redistribute it and/or modify # This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
@@ -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

@@ -1,6 +1,6 @@
# This file is part of Radicale - CalDAV and CardDAV server # This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2017-2021 Unrud <unrud@outlook.com> # Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de> # Copyright © 2025-2026 Peter Bieringer <pb@bieringer.de>
# #
# This library is free software: you can redistribute it and/or modify # This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
@@ -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

@@ -2458,3 +2458,233 @@ permissions: RrWw""")
self.mkcalendar("/calendar.ics/") self.mkcalendar("/calendar.ics/")
event = get_file_content("event_timezone_seconds.ics") event = get_file_content("event_timezone_seconds.ics")
self.put("/calendar.ics/event.ics", event) self.put("/calendar.ics/event.ics", event)
def test_logging_conditional_basic(self, caplog) -> None:
caplog.set_level(logging.INFO)
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "False",
"request_header_on_notice_condition": '{"method": {"match": "equal", "value": "GET"}, "login": {"match": "equal", "value": "owner"}}',
"request_content_on_notice_condition": '{"method": {"match": "equal", "value": "PUT"}, "useragent": {"match": "startswith", "value": "caldavsync"}}',
"response_header_on_notice_condition": '{"method": {"match": "equal", "value": "PUT"}, "status": {"match": ">=", "value": "201"}, "host": {"match": "included", "value": "127.0.0.0/8"}}',
"response_content_on_notice_condition": '{"method": {"match": "equal", "value": "GET"}, "path": {"match": "endswith", "value": ".ics"}}',
}})
self.mkcalendar("/test/")
event = get_file_content("event1.ics")
path = "/test/event1.ics"
path2 = "/test/event2.ics"
logging.info("\n*** check log condition: Response header (found)")
caplog.clear()
self.put(path, event, remote_host='127.0.0.1')
assert "Response header (log condition passed)" in "\n".join(caplog.messages)
logging.info("\n*** check log condition: Response header (not found)")
caplog.clear()
self.put(path, event, remote_host='192.0.2.1', check=204)
assert "Response header (log condition passed)" not in "\n".join(caplog.messages)
logging.info("\n*** check log condition: Response content (found)")
caplog.clear()
self.get(path)
assert "Response content (nonXML, log condition passed)" in "\n".join(caplog.messages)
logging.info("\n*** check log condition: Response content (not found)")
caplog.clear()
self.get(path2, check=404)
assert "Response content (log condition passed)" not in "\n".join(caplog.messages)
logging.info("\n*** check log condition: Request header (found)")
caplog.clear()
self.get(path2, check=401, login="owner:ownerpw")
assert "Request header (log condition passed)" in "\n".join(caplog.messages)
logging.info("\n*** check log condition: Request content (found)")
caplog.clear()
self.put(path, event, remote_useragent='caldavsync', check=204)
assert "Request content (log condition passed)" in "\n".join(caplog.messages)
def test_logging_conditional_ip_included(self, caplog) -> None:
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "False",
"response_header_on_notice_condition": '{"host": {"match": "included", "value": "127.0.0.0/8"}}',
}})
self.mkcalendar("/test/")
event = get_file_content("event1.ics")
path = "/test/event1.ics"
logging.info("\n*** check log condition: Response header (not found)")
caplog.clear()
self.put(path, event, remote_host='192.0.2.1')
assert "Response header (log condition passed)" not in "\n".join(caplog.messages)
logging.info("\n*** check log condition: Response header (found)")
caplog.clear()
self.put(path, event, remote_host='127.0.0.1', check=204)
assert "Response header (log condition passed)" in "\n".join(caplog.messages)
logging.info("\n*** check log condition: Response header (not found, no remote_host)")
caplog.clear()
self.put(path, event, check=204)
assert "Response header (log condition passed)" not in "\n".join(caplog.messages)
logging.info("\n*** check log condition: Response header (IPv6, not found)")
caplog.clear()
self.put(path, event, check=204, remote_host='2001:db8::1')
assert "Response header (log condition passed)" not in "\n".join(caplog.messages)
def test_logging_conditional_report(self, caplog) -> None:
caplog.set_level(logging.INFO)
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "False",
"response_content_on_notice_condition": '{"status": {"match": "equal", "value": "207"}, "method": {"match": "equal", "value": "REPORT"}}',
}})
self.mkcalendar("/test/")
logging.info("\n*** check log condition: Response content (found)")
caplog.clear()
_, responses = self.report("/test/", """\
<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop xmlns:D="DAV:">
<D:getetag/>
</D:prop>
</C:calendar-query>""")
assert "Response content (XML, log condition passed)" in "\n".join(caplog.messages)
def test_logging_conditional_propfind(self, caplog) -> None:
caplog.set_level(logging.INFO)
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "False",
"response_content_on_notice_condition": '{"status": {"match": "equal", "value": "207"}, "method": {"match": "equal", "value": "PROPFIND"}}',
}})
self.mkcalendar("/test/")
logging.info("\n*** check log condition: Response content (found)")
caplog.clear()
_, responses = self.propfind("/test/")
assert "Response content (XML, log condition passed)" in "\n".join(caplog.messages)
def test_logging_conditional_proppatch(self, caplog) -> None:
caplog.set_level(logging.INFO)
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "False",
"response_content_on_notice_condition": '{"status": {"match": "equal", "value": "207"}, "method": {"match": "equal", "value": "PROPPATCH"}}',
}})
self.mkcalendar("/test/")
proppatch = get_file_content("proppatch_set_calendar_color.xml")
logging.info("\n*** check log condition: Response content (found)")
caplog.clear()
_, responses = self.proppatch("/test/", proppatch)
assert "Response content (XML, log condition passed)" in "\n".join(caplog.messages)
def test_logging_conditional_mkcalendar(self, caplog) -> None:
caplog.set_level(logging.INFO)
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "False",
"request_header_on_notice_condition": '{"method": {"match": "equal", "value": "MKCALENDAR"}}',
}})
logging.info("\n*** check log condition: Request content (found)")
caplog.clear()
self.mkcalendar("/test/")
assert "Request header (log condition passed)" in "\n".join(caplog.messages)
def test_logging_conditional_mkcol(self, caplog) -> None:
caplog.set_level(logging.INFO)
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "True",
"request_content_on_notice_condition": '{"method": {"match": "equal", "value": "MKCOL"}}',
}})
logging.info("\n*** check log condition: Request content (found)")
caplog.clear()
self.create_addressbook("/test/")
def test_logging_conditional_regex_basic(self, caplog) -> None:
caplog.set_level(logging.INFO)
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "False",
"response_header_on_notice_condition": '{"method": {"match": "equal", "value": "PUT"}, "path": {"match": "re", "value": "ev?nt[01]"}}',
}})
self.mkcalendar("/test/")
event = get_file_content("event1.ics")
event2 = get_file_content("event2.ics")
path = "/test/event1.ics"
path2 = "/test/event2.ics"
logging.info("\n*** check log condition: Response header (found/re)")
caplog.clear()
self.put(path, event, remote_host='127.0.0.1')
assert "Response header (log condition passed)" in "\n".join(caplog.messages)
logging.info("\n*** check log condition: Response header (found/re)")
caplog.clear()
self.put(path2, event2, remote_host='127.0.0.1')
assert "Response header (log condition passed)" not in "\n".join(caplog.messages)
def test_logging_conditional_problems(self, caplog) -> None:
logging.info("\n*** check log condition config: re ok")
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "False",
"request_header_on_notice_condition": '{"path": {"match": "re", "value": "ev?.*nt[01]"}}',
}})
self.mkcalendar("/test1/")
logging.info("\n*** check log condition config: re broken")
try:
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "False",
"request_header_on_notice_condition": '{"path": {"match": "re", "value": "*.ics"}}',
}})
except RuntimeError:
pass
else:
raise
logging.info("\n*** check log condition config: status not supported <100")
try:
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "False",
"response_header_on_notice_condition": '{"status": {"match": "<=", "value": "99"}}',
}})
except RuntimeError:
pass
else:
raise
logging.info("\n*** check log condition config: status not supported >599")
try:
self.configure({"logging": {"request_header_on_debug": "False",
"request_content_on_debug": "False",
"response_header_on_debug": "False",
"response_content_on_debug": "False",
"response_header_on_notice_condition": '{"status": {"match": "<=", "value": "600"}}',
}})
except RuntimeError:
pass
else:
raise

View File

@@ -1,5 +1,6 @@
# This file is part of Radicale - CalDAV and CardDAV server # This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2017-2018 Unrud <unrud@outlook.com> # Copyright © 2017-2021 Unrud <unrud@outlook.com>
# Copyright © 2026-2026 Peter Bieringer <pb@bieringer.de>
# #
# This library is free software: you can redistribute it and/or modify # This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
@@ -49,7 +50,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 +59,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 +74,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

@@ -1,5 +1,6 @@
# This file is part of Radicale - CalDAV and CardDAV server # This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2017-2018 Unrud <unrud@outlook.com> # Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2026-2026 Peter Bieringer <pb@bieringer.de>
# #
# This library is free software: you can redistribute it and/or modify # This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
@@ -34,6 +35,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

@@ -1,6 +1,6 @@
# This file is part of Radicale - CalDAV and CardDAV server # This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2017-2022 Unrud <unrud@outlook.com> # Copyright © 2017-2022 Unrud <unrud@outlook.com>
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de> # Copyright © 2025-2026 Peter Bieringer <pb@bieringer.de>
# #
# This library is free software: you can redistribute it and/or modify # This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by
@@ -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":