diff --git a/radicale/app/base.py b/radicale/app/base.py index aa0af7a2..29ef3b71 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -1,6 +1,6 @@ # This file is part of Radicale - CalDAV and CardDAV server # Copyright © 2020 Unrud -# Copyright © 2024-2024 Peter Bieringer +# Copyright © 2024-2026 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 @@ -17,14 +17,14 @@ import io import logging -import posixpath import sys import xml.etree.ElementTree as ET -from typing import Optional +from typing import Optional, Union from radicale import (auth, config, hook, httputils, pathutils, rights, storage, types, utils, web, xmlutils) from radicale.log import logger +from radicale.rights import intersect # HACK: https://github.com/tiran/defusedxml/issues/54 import defusedxml.ElementTree as DefusedET # isort:skip @@ -106,15 +106,19 @@ class Access: permissions: str _rights: rights.BaseRights _parent_permissions: Optional[str] + _permissions_filter: Union[str, None] = None - def __init__(self, rights: rights.BaseRights, user: str, path: str + def __init__(self, rights: rights.BaseRights, user: str, path: str, permissions_filter: Union[str, None] = None ) -> None: self._rights = rights self.user = user self.path = path - self.parent_path = pathutils.unstrip_path( - posixpath.dirname(pathutils.strip_path(path)), True) + self.parent_path = pathutils.parent_path(path) self.permissions = self._rights.authorization(self.user, self.path) + if permissions_filter is not None: + self._permissions_filter = permissions_filter + permissions_filtered = intersect(self.permissions, permissions_filter) + self.permissions = permissions_filtered self._parent_permissions = None @property @@ -124,6 +128,9 @@ class Access: if self._parent_permissions is None: self._parent_permissions = self._rights.authorization( self.user, self.parent_path) + if self._permissions_filter is not None: + parent_permissions_filtered = intersect(self._parent_permissions, self._permissions_filter) + self._parent_permissions = parent_permissions_filtered return self._parent_permissions def check(self, permission: str, diff --git a/radicale/app/delete.py b/radicale/app/delete.py index 2201e998..cd8b0c3a 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2020 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 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 @@ -57,7 +57,8 @@ class ApplicationPartDelete(ApplicationBase): def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage DELETE request.""" - access = Access(self._rights, user, path) + permissions_filter = None + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED with self._storage.acquire_lock("w", user, path=path, request="DELETE"): diff --git a/radicale/app/get.py b/radicale/app/get.py index 2eac58f1..98e82f7a 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2023 Unrud -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 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 @@ -76,7 +76,8 @@ class ApplicationPartGet(ApplicationBase): return httputils.redirect(location, client.MOVED_PERMANENTLY) # Dispatch /.web path to web module return self._web.get(environ, base_prefix, path, user) - access = Access(self._rights, user, path) + permissions_filter = None + access = Access(self._rights, user, path, permissions_filter) if not access.check("r") and "i" not in access.permissions: return httputils.NOT_ALLOWED with self._storage.acquire_lock("r", user): diff --git a/radicale/app/mkcalendar.py b/radicale/app/mkcalendar.py index 53abcdbd..bf6da1c8 100644 --- a/radicale/app/mkcalendar.py +++ b/radicale/app/mkcalendar.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2021 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 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 @@ -19,7 +19,6 @@ # along with Radicale. If not, see . import errno -import posixpath import re import socket from http import client @@ -62,8 +61,7 @@ class ApplicationPartMkcalendar(ApplicationBase): if item: return self._webdav_error_response( client.CONFLICT, "D:resource-must-be-null") - parent_path = pathutils.unstrip_path( - posixpath.dirname(pathutils.strip_path(path)), True) + parent_path = pathutils.parent_path(path) parent_item = next(iter(self._storage.discover(parent_path)), None) if not parent_item: return httputils.CONFLICT diff --git a/radicale/app/mkcol.py b/radicale/app/mkcol.py index 45ad7c4a..8d3b1755 100644 --- a/radicale/app/mkcol.py +++ b/radicale/app/mkcol.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2021 Unrud -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 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 @@ -19,7 +19,6 @@ # along with Radicale. If not, see . import errno -import posixpath import re import socket from http import client @@ -66,8 +65,7 @@ class ApplicationPartMkcol(ApplicationBase): item = next(iter(self._storage.discover(path)), None) if item: return httputils.METHOD_NOT_ALLOWED - parent_path = pathutils.unstrip_path( - posixpath.dirname(pathutils.strip_path(path)), True) + parent_path = pathutils.parent_path(path) parent_item = next(iter(self._storage.discover(parent_path)), None) if not parent_item: return httputils.CONFLICT diff --git a/radicale/app/move.py b/radicale/app/move.py index 168619e3..5fce17c8 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2023 Unrud -# Copyright © 2023-2025 Peter Bieringer +# Copyright © 2023-2026 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 @@ -67,7 +67,8 @@ class ApplicationPartMove(ApplicationBase): # Remote destination server, not supported return httputils.REMOTE_DESTINATION - access = Access(self._rights, user, path) + permissions_filter = None + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED to_path = pathutils.sanitize_path(to_url.path) @@ -76,7 +77,9 @@ class ApplicationPartMove(ApplicationBase): "start with base prefix", to_path, path) return httputils.NOT_ALLOWED to_path = to_path[len(base_prefix):] - to_access = Access(self._rights, user, to_path) + to_user = user + to_permissions_filter = None + to_access = Access(self._rights, to_user, to_path, to_permissions_filter) if not to_access.check("w"): return httputils.NOT_ALLOWED @@ -94,8 +97,7 @@ class ApplicationPartMove(ApplicationBase): to_item = next(iter(self._storage.discover(to_path)), None) if isinstance(to_item, storage.BaseCollection): return httputils.FORBIDDEN - to_parent_path = pathutils.unstrip_path( - posixpath.dirname(pathutils.strip_path(to_path)), True) + to_parent_path = pathutils.parent_path(to_path) to_collection = next(iter( self._storage.discover(to_parent_path)), None) if not to_collection: diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 62af2949..b006da7b 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -3,7 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2021 Unrud -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 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 @@ -405,7 +405,9 @@ class ApplicationPartPropfind(ApplicationBase): def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPFIND request.""" - access = Access(self._rights, user, path) + http_depth = environ.get("HTTP_DEPTH", "0") + permissions_filter = None + access = Access(self._rights, user, path, permissions_filter) if not access.check("r"): return httputils.NOT_ALLOWED try: @@ -419,7 +421,7 @@ class ApplicationPartPropfind(ApplicationBase): return httputils.REQUEST_TIMEOUT with self._storage.acquire_lock("r", user): items_iter = iter(self._storage.discover( - path, environ.get("HTTP_DEPTH", "0"), + path, http_depth, None, self._rights._user_groups)) # take root item for rights checking item = next(items_iter, None) @@ -429,11 +431,11 @@ class ApplicationPartPropfind(ApplicationBase): return httputils.NOT_ALLOWED # put item back items_iter = itertools.chain([item], items_iter) - allowed_items = self._collect_allowed_items(items_iter, user) - headers = {"DAV": httputils.DAV_HEADERS, - "Content-Type": "text/xml; charset=%s" % self._encoding} - xml_answer = xml_propfind(base_prefix, path, xml_content, - allowed_items, user, self._encoding, max_resource_size=self._max_resource_size) - if xml_answer is None: - return httputils.NOT_ALLOWED - return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) + allowed_items = list(self._collect_allowed_items(items_iter, user)) + headers = {"DAV": httputils.DAV_HEADERS, + "Content-Type": "text/xml; charset=%s" % self._encoding} + xml_answer = xml_propfind(base_prefix, path, xml_content, + allowed_items, user, self._encoding, max_resource_size=self._max_resource_size) + if xml_answer is None: + return httputils.NOT_ALLOWED + return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content) diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index caaf7b7a..197591b2 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -4,7 +4,7 @@ # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2020 Unrud # Copyright © 2020-2020 Tuna Celik -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 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 @@ -75,7 +75,8 @@ class ApplicationPartProppatch(ApplicationBase): def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage PROPPATCH request.""" - access = Access(self._rights, user, path) + permissions_filter = None + access = Access(self._rights, user, path, permissions_filter) if not access.check("w"): return httputils.NOT_ALLOWED try: diff --git a/radicale/app/report.py b/radicale/app/report.py index 917a2d75..526cb4d8 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -6,7 +6,7 @@ # Copyright © 2024-2024 Pieter Hijma # Copyright © 2024-2024 Ray # Copyright © 2024-2025 Georgiy -# Copyright © 2024-2025 Peter Bieringer +# Copyright © 2024-2026 Peter Bieringer # Copyright © 2025-2025 David Greaves # # This library is free software: you can redistribute it and/or modify @@ -810,7 +810,8 @@ class ApplicationPartReport(ApplicationBase): def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse: """Manage REPORT request.""" - access = Access(self._rights, user, path) + permissions_filter = None + access = Access(self._rights, user, path, permissions_filter) if not access.check("r"): return httputils.NOT_ALLOWED try: diff --git a/radicale/pathutils.py b/radicale/pathutils.py index 3193e4a2..488dbfc0 100644 --- a/radicale/pathutils.py +++ b/radicale/pathutils.py @@ -2,7 +2,7 @@ # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2022 Unrud -# Copyright © 2025-2025 Peter Bieringer +# Copyright © 2025-2026 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 @@ -293,6 +293,10 @@ def path_to_filesystem(root: str, sane_path: str) -> str: return safe_path +def parent_path(path: str) -> str: + return unstrip_path(posixpath.dirname(strip_path(path)), True) + + class UnsafePathError(ValueError): def __init__(self, path: str) -> None: