diff --git a/CHANGELOG.md b/CHANGELOG.md index ff94443b..25129ef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 3.5.9.dev * Extend: [auth] add support for type http_remote_user +* Extend: logging of invalid sync-token with user, path, remote host and useragent ## 3.5.8 * Extend: [auth] re-factor & overhaul LDAP authentication, especially for Python's ldap module diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index 0b895820..940d15b5 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -371,7 +371,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, if not login or user: status, headers, answer = function( - environ, base_prefix, path, user) + environ, base_prefix, path, user, remote_host, remote_useragent) if (status, headers, answer) == httputils.NOT_ALLOWED: logger.info("Access to %r denied for %s", path, repr(user) if user else "anonymous user") diff --git a/radicale/app/delete.py b/radicale/app/delete.py index 060abb18..695de45f 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -55,7 +55,7 @@ def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection, class ApplicationPartDelete(ApplicationBase): def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage DELETE request.""" access = Access(self._rights, user, path) if not access.check("w"): diff --git a/radicale/app/get.py b/radicale/app/get.py index edd29b75..b8adb39a 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2023 Unrud +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -58,7 +59,7 @@ class ApplicationPartGet(ApplicationBase): return value def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, - user: str) -> types.WSGIResponse: + user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage GET request.""" # Redirect to /.web if the root path is requested if not pathutils.strip_path(path): diff --git a/radicale/app/head.py b/radicale/app/head.py index 5166db2d..eec68bb5 100644 --- a/radicale/app/head.py +++ b/radicale/app/head.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2022 Unrud +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -25,7 +26,7 @@ from radicale.app.get import ApplicationPartGet class ApplicationPartHead(ApplicationPartGet, ApplicationBase): def do_HEAD(self, environ: types.WSGIEnviron, base_prefix: str, path: str, - user: str) -> types.WSGIResponse: + user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage HEAD request.""" # Body is dropped in `Application.__call__` for HEAD requests - return self.do_GET(environ, base_prefix, path, user) + return self.do_GET(environ, base_prefix, path, user, remote_host, remote_useragent) diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index 632d3c38..db14bfdc 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -33,7 +33,7 @@ from radicale.log import logger class ApplicationPartMkcalendar(ApplicationBase): def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage MKCALENDAR request.""" if "w" not in self._rights.authorization(user, path): return httputils.NOT_ALLOWED diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 169cb62c..72d5aa2b 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -33,7 +33,7 @@ from radicale.log import logger class ApplicationPartMkcol(ApplicationBase): def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage MKCOL request.""" permissions = self._rights.authorization(user, path) if not rights.intersect(permissions, "Ww"): diff --git a/radicale/app/move.py b/radicale/app/move.py index 77e56f3e..ba346762 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -48,7 +48,7 @@ def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False): class ApplicationPartMove(ApplicationBase): def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage MOVE request.""" raw_dest = environ.get("HTTP_DESTINATION", "") to_url = urlparse(raw_dest) diff --git a/radicale/app/options.py b/radicale/app/options.py index 6e9053a3..a869e2ab 100644 --- a/radicale/app/options.py +++ b/radicale/app/options.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2021 Unrud +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -26,7 +27,7 @@ from radicale.app.base import ApplicationBase class ApplicationPartOptions(ApplicationBase): def do_OPTIONS(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage OPTIONS request.""" headers = { "Allow": ", ".join( diff --git a/radicale/app/post.py b/radicale/app/post.py index f5367b86..df944499 100644 --- a/radicale/app/post.py +++ b/radicale/app/post.py @@ -2,8 +2,9 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud -# Copyright © 2020 Tom Hacohen +# Copyright © 2017-2021 Unrud +# Copyright © 2020-2020 Tom Hacohen +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -25,7 +26,7 @@ from radicale.app.base import ApplicationBase class ApplicationPartPost(ApplicationBase): def do_POST(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage POST request.""" if path == "/.web" or path.startswith("/.web/"): return self._web.post(environ, base_prefix, path, user) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 6a3cea6d..b546c5e1 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -2,7 +2,8 @@ # Copyright © 2008 Nicolas Kandel # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2021 Unrud +# Copyright © 2025-2025 Peter Bieringer # # 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 @@ -376,7 +377,7 @@ class ApplicationPartPropfind(ApplicationBase): yield item, permission def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPFIND request.""" access = Access(self._rights, user, path) if not access.check("r"): diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index 2e8eed47..9d6dc221 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -73,7 +73,7 @@ def xml_proppatch(base_prefix: str, path: str, class ApplicationPartProppatch(ApplicationBase): def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPPATCH request.""" access = Access(self._rights, user, path) if not access.check("w"): diff --git a/radicale/app/put.py b/radicale/app/put.py index 6cfed1eb..de11589b 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -142,7 +142,7 @@ def prepare(vobject_items: List[vobject.base.Component], path: str, class ApplicationPartPut(ApplicationBase): def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PUT request.""" access = Access(self._rights, user, path) if not access.check("w"): diff --git a/radicale/app/report.py b/radicale/app/report.py index b63681f7..dda4ba10 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -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], collection: storage.BaseCollection, encoding: str, unlock_storage_fn: Callable[[], None], - max_occurrence: int = 0, + max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = "" ) -> Tuple[int, ET.Element]: """Read and answer REPORT requests that return XML. @@ -213,8 +213,8 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], sync_token, names = collection.sync(old_sync_token) except ValueError as e: # Invalid sync token - logger.warning("Client provided invalid sync token %r: %s", - old_sync_token, e, exc_info=True) + 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) # client.CONFLICT doesn't work with some clients (e.g. InfCloud) return (client.FORBIDDEN, xmlutils.webdav_error("D:valid-sync-token")) @@ -776,7 +776,7 @@ def test_filter(collection_tag: str, item: radicale_item.Item, class ApplicationPartReport(ApplicationBase): def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, - path: str, user: str) -> types.WSGIResponse: + path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage REPORT request.""" access = Access(self._rights, user, path) if not access.check("r"): @@ -820,7 +820,7 @@ class ApplicationPartReport(ApplicationBase): try: status, xml_answer = xml_report( base_prefix, path, xml_content, collection, self._encoding, - lock_stack.close, max_occurrence) + lock_stack.close, max_occurrence, user, remote_host, remote_useragent) except ValueError as e: logger.warning( "Bad REPORT request on %r: %s", path, e, exc_info=True) diff --git a/radicale/tests/__init__.py b/radicale/tests/__init__.py index c1a2aab2..9a57e6fd 100644 --- a/radicale/tests/__init__.py +++ b/radicale/tests/__init__.py @@ -1,6 +1,7 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2012-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud +# Copyright © 2017-2023 Unrud +# Copyright © 2024-2025 Peter Bieringer # # 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 @@ -79,6 +80,8 @@ class BaseTest: if http_if_match is not None and not isinstance(http_if_match, str): raise TypeError("http_if_match argument must be %r, not %r" % (str, type(http_if_match))) + remote_useragent = kwargs.pop("remote_useragent", None) + remote_host = kwargs.pop("remote_host", None) environ: Dict[str, Any] = {k.upper(): v for k, v in kwargs.items()} for k, v in environ.items(): if not isinstance(v, str): @@ -90,6 +93,10 @@ class BaseTest: login.encode(encoding)).decode() if http_if_match: environ["HTTP_IF_MATCH"] = http_if_match + if remote_useragent: + environ["HTTP_USER_AGENT"] = remote_useragent + if remote_host: + environ["REMOTE_ADDR"] = remote_host environ["REQUEST_METHOD"] = method.upper() environ["PATH_INFO"] = path if data is not None: diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index c13c8725..0f33a4fc 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -1687,7 +1687,7 @@ permissions: RrWw""") """, 400, is_xml=False) def _report_sync_token( - self, calendar_path: str, sync_token: Optional[str] = None + self, calendar_path: str, sync_token: Optional[str] = None, **kwargs ) -> Tuple[str, RESPONSES]: sync_token_xml = ( "" % sync_token @@ -1699,7 +1699,7 @@ permissions: RrWw""") %s -""" % sync_token_xml) +""" % sync_token_xml, **kwargs) xml = DefusedET.fromstring(answer) if status in (403, 409): assert xml.tag == xmlutils.make_clark("D:error") @@ -1847,6 +1847,15 @@ permissions: RrWw""") calendar_path, "http://radicale.org/ns/sync/INVALID") assert not sync_token + def test_report_sync_collection_invalid_sync_token_with_user(self) -> None: + """Test sync-collection report with an invalid sync token and user+host+useragent""" + self.configure({"auth": {"type": "none"}}) + calendar_path = "/calendar.ics/" + self.mkcalendar(calendar_path) + sync_token, _ = self._report_sync_token( + calendar_path, "http://radicale.org/ns/sync/INVALID", login="testuser:", remote_host="192.0.2.1", remote_useragent="Testclient/1.0") + assert not sync_token + def test_propfind_sync_token(self) -> None: """Retrieve the sync-token with a propfind request""" calendar_path = "/calendar.ics/"