def __init__(self, storage_, path): self._storage = storage_ # Path should already be sanitized self._path = pathutils.sanitize_path(path).strip("/") attributes = _get_attributes_from_path(path) self.etesync = self._storage.etesync if len(attributes) == 2: self.uid = attributes[-1] self.journal = self.etesync.get(self.uid) self.collection = self.journal.collection if isinstance(self.collection, api.Calendar): self.tag = "VCALENDAR" self.meta_mappings = MetaMappingCalendar() self.content_suffix = ".ics" elif isinstance(self.collection, api.TaskList): self.tag = "VCALENDAR" self.meta_mappings = MetaMappingTaskList() self.content_suffix = ".ics" elif isinstance(self.collection, api.AddressBook): self.tag = "VADDRESSBOOK" self.meta_mappings = MetaMappingContacts() self.content_suffix = ".vcf" self.is_fake = False else: self.is_fake = True super().__init__()
def get(self, environ, base_prefix, path, user): assert path == "/.web" or path.startswith("/.web/") assert pathutils.sanitize_path(path) == path try: filesystem_path = pathutils.path_to_filesystem( self.folder, path[len("/.web"):].strip("/")) except ValueError as e: logger.debug("Web content with unsafe path %r requested: %s", path, e, exc_info=True) return httputils.NOT_FOUND if os.path.isdir(filesystem_path) and not path.endswith("/"): location = posixpath.basename(path) + "/" return (client.FOUND, {"Location": location, "Content-Type": "text/plain"}, "Redirected to %s" % location) if os.path.isdir(filesystem_path): filesystem_path = os.path.join(filesystem_path, "index.html") if not os.path.isfile(filesystem_path): return httputils.NOT_FOUND content_type = MIMETYPES.get( os.path.splitext(filesystem_path)[1].lower(), FALLBACK_MIMETYPE) with open(filesystem_path, "rb") as f: answer = f.read() last_modified = time.strftime( "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(os.fstat(f.fileno()).st_mtime)) headers = { "Content-Type": content_type, "Last-Modified": last_modified} return client.OK, headers, answer
def do_MOVE(self, environ, base_prefix, path, user): """Manage MOVE request.""" raw_dest = environ.get("HTTP_DESTINATION", "") to_url = urlparse(raw_dest) if to_url.netloc != environ["HTTP_HOST"]: logger.info("Unsupported destination address: %r", raw_dest) # Remote destination server, not supported return httputils.REMOTE_DESTINATION if not self.access(user, path, "w"): return httputils.NOT_ALLOWED to_path = pathutils.sanitize_path(to_url.path) if not (to_path + "/").startswith(base_prefix + "/"): logger.warning( "Destination %r from MOVE request on %r doesn't " "start with base prefix", to_path, path) return httputils.NOT_ALLOWED to_path = to_path[len(base_prefix):] if not self.access(user, to_path, "w"): return httputils.NOT_ALLOWED with self.Collection.acquire_lock("w", user): item = next(self.Collection.discover(path), None) if not item: return httputils.NOT_FOUND if (not self.access(user, path, "w", item) or not self.access(user, to_path, "w", item)): return httputils.NOT_ALLOWED if isinstance(item, storage.BaseCollection): # TODO: support moving collections return httputils.METHOD_NOT_ALLOWED to_item = next(self.Collection.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_collection = next(self.Collection.discover(to_parent_path), None) if not to_collection: return httputils.CONFLICT tag = item.collection.get_meta("tag") if not tag or tag != to_collection.get_meta("tag"): return httputils.FORBIDDEN if to_item and environ.get("HTTP_OVERWRITE", "F") != "T": return httputils.PRECONDITION_FAILED if (to_item and item.uid != to_item.uid or not to_item and to_collection.path != item.collection.path and to_collection.has_uid(item.uid)): return self.webdav_error_response( "C" if tag == "VCALENDAR" else "CR", "no-uid-conflict") to_href = posixpath.basename(pathutils.strip_path(to_path)) try: self.Collection.move(item, to_collection, to_href) except ValueError as e: logger.warning("Bad MOVE request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST return client.NO_CONTENT if to_item else client.CREATED, {}, None
def _get_attributes_from_path(path): sane_path = pathutils.sanitize_path(path).strip("/") attributes = sane_path.split("/", 2) if not attributes[0]: attributes.pop() return attributes
def get(self, environ, base_prefix, path, user): assert path == "/.web" or path.startswith("/.web/") assert pathutils.sanitize_path(path) == path try: filesystem_path = pathutils.path_to_filesystem( self.folder, path[len("/.web"):].strip("/")) except ValueError as e: logger.debug("Web content with unsafe path %r requested: %s", path, e, exc_info=True) return httputils.NOT_FOUND if os.path.isdir(filesystem_path) and not path.endswith("/"): location = posixpath.basename(path) + "/" return (client.FOUND, { "Location": location, "Content-Type": "text/plain" }, "Redirected to %s" % location) if os.path.isdir(filesystem_path): filesystem_path = os.path.join(filesystem_path, "index.html") if not os.path.isfile(filesystem_path): return httputils.NOT_FOUND content_type = MIMETYPES.get( os.path.splitext(filesystem_path)[1].lower(), FALLBACK_MIMETYPE) with open(filesystem_path, "rb") as f: answer = f.read() last_modified = time.strftime( "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(os.fstat(f.fileno()).st_mtime)) headers = { "Content-Type": content_type, "Last-Modified": last_modified } return client.OK, headers, answer
def __init__(self, storage_, path): self._storage = storage_ # Path should already be sanitized self._path = pathutils.sanitize_path(path).strip("/") attributes = _get_attributes_from_path(path) self.etesync = self._storage.etesync if len(attributes) == 2: self.uid = attributes[-1] self.collection = self.etesync.get(self.uid) col_type = self.collection.col_type if col_type == "etebase.vevent": self.tag = "VCALENDAR" self.meta_mappings = MetaMappingCalendar() self.content_suffix = ".ics" elif col_type == "etebase.vtodo": self.tag = "VCALENDAR" self.meta_mappings = MetaMappingTaskList() self.content_suffix = ".ics" elif col_type == "etebase.vcard": self.tag = "VADDRESSBOOK" self.meta_mappings = MetaMappingContacts() self.content_suffix = ".vcf" self.is_fake = False else: self.is_fake = True super().__init__()
def _is_principal(path): sane_path = pathutils.sanitize_path(path).strip("/") attributes = sane_path.split("/") if not attributes[0]: attributes.pop() # It's a principal if all we have is the user return len(attributes) == 1
def do_MOVE(self, environ, base_prefix, path, user): """Manage MOVE request.""" raw_dest = environ.get("HTTP_DESTINATION", "") to_url = urlparse(raw_dest) if to_url.netloc != environ["HTTP_HOST"]: logger.info("Unsupported destination address: %r", raw_dest) # Remote destination server, not supported return httputils.REMOTE_DESTINATION if not self.access(user, path, "w"): return httputils.NOT_ALLOWED to_path = pathutils.sanitize_path(to_url.path) if not (to_path + "/").startswith(base_prefix + "/"): logger.warning("Destination %r from MOVE request on %r doesn't " "start with base prefix", to_path, path) return httputils.NOT_ALLOWED to_path = to_path[len(base_prefix):] if not self.access(user, to_path, "w"): return httputils.NOT_ALLOWED with self.Collection.acquire_lock("w", user): item = next(self.Collection.discover(path), None) if not item: return httputils.NOT_FOUND if (not self.access(user, path, "w", item) or not self.access(user, to_path, "w", item)): return httputils.NOT_ALLOWED if isinstance(item, storage.BaseCollection): # TODO: support moving collections return httputils.METHOD_NOT_ALLOWED to_item = next(self.Collection.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_collection = next( self.Collection.discover(to_parent_path), None) if not to_collection: return httputils.CONFLICT tag = item.collection.get_meta("tag") if not tag or tag != to_collection.get_meta("tag"): return httputils.FORBIDDEN if to_item and environ.get("HTTP_OVERWRITE", "F") != "T": return httputils.PRECONDITION_FAILED if (to_item and item.uid != to_item.uid or not to_item and to_collection.path != item.collection.path and to_collection.has_uid(item.uid)): return self.webdav_error_response( "C" if tag == "VCALENDAR" else "CR", "no-uid-conflict") to_href = posixpath.basename(pathutils.strip_path(to_path)) try: self.Collection.move(item, to_collection, to_href) except ValueError as e: logger.warning( "Bad MOVE request on %r: %s", path, e, exc_info=True) return httputils.BAD_REQUEST return client.NO_CONTENT if to_item else client.CREATED, {}, None
def __init__(self, collection_path=None, collection=None, vobject_item=None, href=None, last_modified=None, text=None, etag=None, uid=None, name=None, component_name=None, time_range=None): """Initialize an item. ``collection_path`` the path of the parent collection (optional if ``collection`` is set). ``collection`` the parent collection (optional). ``href`` the href of the item. ``last_modified`` the HTTP-datetime of when the item was modified. ``text`` the text representation of the item (optional if ``vobject_item`` is set). ``vobject_item`` the vobject item (optional if ``text`` is set). ``etag`` the etag of the item (optional). See ``get_etag``. ``uid`` the UID of the object (optional). See ``get_uid_from_object``. ``name`` the name of the item (optional). See ``vobject_item.name``. ``component_name`` the name of the primary component (optional). See ``find_tag``. ``time_range`` the enclosing time range. See ``find_tag_and_time_range``. """ if text is None and vobject_item is None: raise ValueError( "at least one of 'text' or 'vobject_item' must be set") if collection_path is None: if collection is None: raise ValueError("at least one of 'collection_path' or " "'collection' must be set") collection_path = collection.path assert collection_path == pathutils.strip_path( pathutils.sanitize_path(collection_path)) self._collection_path = collection_path self.collection = collection self.href = href self.last_modified = last_modified self._text = text self._vobject_item = vobject_item self._etag = etag self._uid = uid self._name = name self._component_name = component_name self._time_range = time_range
def __init__(self, collection_path=None, collection=None, vobject_item=None, href=None, last_modified=None, text=None, etag=None, uid=None, name=None, component_name=None, time_range=None): """Initialize an item. ``collection_path`` the path of the parent collection (optional if ``collection`` is set). ``collection`` the parent collection (optional). ``href`` the href of the item. ``last_modified`` the HTTP-datetime of when the item was modified. ``text`` the text representation of the item (optional if ``vobject_item`` is set). ``vobject_item`` the vobject item (optional if ``text`` is set). ``etag`` the etag of the item (optional). See ``get_etag``. ``uid`` the UID of the object (optional). See ``get_uid_from_object``. ``name`` the name of the item (optional). See ``vobject_item.name``. ``component_name`` the name of the primary component (optional). See ``find_tag``. ``time_range`` the enclosing time range. See ``find_tag_and_time_range``. """ if text is None and vobject_item is None: raise ValueError( "at least one of 'text' or 'vobject_item' must be set") if collection_path is None: if collection is None: raise ValueError("at least one of 'collection_path' or " "'collection' must be set") collection_path = collection.path assert collection_path == pathutils.strip_path( pathutils.sanitize_path(collection_path)) self._collection_path = collection_path self.collection = collection self.href = href self.last_modified = last_modified self._text = text self._vobject_item = vobject_item self._etag = etag self._uid = uid self._name = name self._component_name = component_name self._time_range = time_range
def xml_report(base_prefix, path, xml_request, collection, encoding, unlock_storage_fn): """Read and answer REPORT requests. Read rfc3253-3.6 for info. """ multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) if xml_request is None: return client.MULTI_STATUS, multistatus root = xml_request if root.tag in (xmlutils.make_clark("D:principal-search-property-set"), xmlutils.make_clark("D:principal-property-search"), xmlutils.make_clark("D:expand-property")): # We don't support searching for principals or indirect retrieving of # properties, just return an empty result. # InfCloud asks for expand-property reports (even if we don't announce # support for them) and stops working if an error code is returned. logger.warning("Unsupported REPORT method %r on %r requested", xmlutils.make_human_tag(root.tag), path) return client.MULTI_STATUS, multistatus if (root.tag == xmlutils.make_clark("C:calendar-multiget") and collection.get_meta("tag") != "VCALENDAR" or root.tag == xmlutils.make_clark("CR:addressbook-multiget") and collection.get_meta("tag") != "VADDRESSBOOK" or root.tag == xmlutils.make_clark("D:sync-collection") and collection.get_meta("tag") not in ("VADDRESSBOOK", "VCALENDAR")): logger.warning("Invalid REPORT method %r on %r requested", xmlutils.make_human_tag(root.tag), path) return (client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")) prop_element = root.find(xmlutils.make_clark("D:prop")) props = ([prop.tag for prop in prop_element] if prop_element is not None else []) if root.tag in (xmlutils.make_clark("C:calendar-multiget"), xmlutils.make_clark("CR:addressbook-multiget")): # Read rfc4791-7.9 for info hreferences = set() for href_element in root.findall(xmlutils.make_clark("D:href")): href_path = pathutils.sanitize_path( unquote(urlparse(href_element.text).path)) if (href_path + "/").startswith(base_prefix + "/"): hreferences.add(href_path[len(base_prefix):]) else: logger.warning( "Skipping invalid path %r in REPORT request on " "%r", href_path, path) elif root.tag == xmlutils.make_clark("D:sync-collection"): old_sync_token_element = root.find(xmlutils.make_clark("D:sync-token")) old_sync_token = "" if old_sync_token_element is not None and old_sync_token_element.text: old_sync_token = old_sync_token_element.text.strip() logger.debug("Client provided sync token: %r", old_sync_token) try: 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) # client.CONFLICT doesn't work with some clients (e.g. InfCloud) return (client.FORBIDDEN, xmlutils.webdav_error("D:valid-sync-token")) hreferences = (pathutils.unstrip_path( posixpath.join(collection.path, n)) for n in names) # Append current sync token to response sync_token_element = ET.Element(xmlutils.make_clark("D:sync-token")) sync_token_element.text = sync_token multistatus.append(sync_token_element) else: hreferences = (path, ) filters = (root.findall(xmlutils.make_clark("C:filter")) + root.findall(xmlutils.make_clark("CR:filter"))) def retrieve_items(collection, hreferences, multistatus): """Retrieves all items that are referenced in ``hreferences`` from ``collection`` and adds 404 responses for missing and invalid items to ``multistatus``.""" collection_requested = False def get_names(): """Extracts all names from references in ``hreferences`` and adds 404 responses for invalid references to ``multistatus``. If the whole collections is referenced ``collection_requested`` gets set to ``True``.""" nonlocal collection_requested for hreference in hreferences: try: name = pathutils.name_from_path(hreference, collection) except ValueError as e: logger.warning( "Skipping invalid path %r in REPORT request" " on %r: %s", hreference, path, e) response = xml_item_response(base_prefix, hreference, found_item=False) multistatus.append(response) continue if name: # Reference is an item yield name else: # Reference is a collection collection_requested = True for name, item in collection.get_multi(get_names()): if not item: uri = pathutils.unstrip_path( posixpath.join(collection.path, name)) response = xml_item_response(base_prefix, uri, found_item=False) multistatus.append(response) else: yield item, False if collection_requested: yield from collection.get_filtered(filters) # Retrieve everything required for finishing the request. retrieved_items = list(retrieve_items(collection, hreferences, multistatus)) collection_tag = collection.get_meta("tag") # Don't access storage after this! unlock_storage_fn() def match(item, filter_): tag = collection_tag if (tag == "VCALENDAR" and filter_.tag != xmlutils.make_clark("C:%s" % filter_)): if len(filter_) == 0: return True if len(filter_) > 1: raise ValueError("Filter with %d children" % len(filter_)) if filter_[0].tag != xmlutils.make_clark("C:comp-filter"): raise ValueError("Unexpected %r in filter" % filter_[0].tag) return radicale_filter.comp_match(item, filter_[0]) if (tag == "VADDRESSBOOK" and filter_.tag != xmlutils.make_clark("CR:%s" % filter_)): for child in filter_: if child.tag != xmlutils.make_clark("CR:prop-filter"): raise ValueError("Unexpected %r in filter" % child.tag) test = filter_.get("test", "anyof") if test == "anyof": return any( radicale_filter.prop_match(item.vobject_item, f, "CR") for f in filter_) if test == "allof": return all( radicale_filter.prop_match(item.vobject_item, f, "CR") for f in filter_) raise ValueError("Unsupported filter test: %r" % test) raise ValueError("Unsupported filter %r for %r" % (filter_.tag, tag)) while retrieved_items: # ``item.vobject_item`` might be accessed during filtering. # Don't keep reference to ``item``, because VObject requires a lot of # memory. item, filters_matched = retrieved_items.pop(0) if filters and not filters_matched: try: if not all(match(item, filter_) for filter_ in filters): continue except ValueError as e: raise ValueError("Failed to filter item %r from %r: %s" % (item.href, collection.path, e)) from e except Exception as e: raise RuntimeError("Failed to filter item %r from %r: %s" % (item.href, collection.path, e)) from e found_props = [] not_found_props = [] for tag in props: element = ET.Element(tag) if tag == xmlutils.make_clark("D:getetag"): element.text = item.etag found_props.append(element) elif tag == xmlutils.make_clark("D:getcontenttype"): element.text = xmlutils.get_content_type(item, encoding) found_props.append(element) elif tag in (xmlutils.make_clark("C:calendar-data"), xmlutils.make_clark("CR:address-data")): element.text = item.serialize() found_props.append(element) else: not_found_props.append(element) uri = pathutils.unstrip_path(posixpath.join(collection.path, item.href)) multistatus.append( xml_item_response(base_prefix, uri, found_props=found_props, not_found_props=not_found_props, found_item=True)) return client.MULTI_STATUS, multistatus
def get(self, environ, base_prefix, path, user): assert path == "/.web" or path.startswith("/.web/") assert pathutils.sanitize_path(path) == path if path != "/.web": return httputils.NOT_FOUND return client.OK, {"Content-Type": "text/plain"}, "Radicale works!"
def make_href(base_prefix, href): """Return prefixed href.""" assert href == pathutils.sanitize_path(href) return quote("%s%s" % (base_prefix, href))
def _handle_request(self, environ): """Manage a request.""" def response(status, headers=(), answer=None): headers = dict(headers) # Set content length if answer: if hasattr(answer, "encode"): logger.debug("Response content:\n%s", answer) headers["Content-Type"] += "; charset=%s" % self.encoding answer = answer.encode(self.encoding) accept_encoding = [ encoding.strip() for encoding in environ.get("HTTP_ACCEPT_ENCODING", "").split(",") if encoding.strip()] if "gzip" in accept_encoding: zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS) answer = zcomp.compress(answer) + zcomp.flush() headers["Content-Encoding"] = "gzip" headers["Content-Length"] = str(len(answer)) # Add extra headers set in configuration if self.configuration.has_section("headers"): for key in self.configuration.options("headers"): headers[key] = self.configuration.get("headers", key) # Start response time_end = datetime.datetime.now() status = "%d %s" % ( status, client.responses.get(status, "Unknown")) logger.info( "%s response status for %r%s in %.3f seconds: %s", environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo, (time_end - time_begin).total_seconds(), status) # Return response content return status, list(headers.items()), [answer] if answer else [] remote_host = "unknown" if environ.get("REMOTE_HOST"): remote_host = repr(environ["REMOTE_HOST"]) elif environ.get("REMOTE_ADDR"): remote_host = environ["REMOTE_ADDR"] if environ.get("HTTP_X_FORWARDED_FOR"): remote_host = "%r (forwarded by %s)" % ( environ["HTTP_X_FORWARDED_FOR"], remote_host) remote_useragent = "" if environ.get("HTTP_USER_AGENT"): remote_useragent = " using %r" % environ["HTTP_USER_AGENT"] depthinfo = "" if environ.get("HTTP_DEPTH"): depthinfo = " with depth %r" % environ["HTTP_DEPTH"] time_begin = datetime.datetime.now() logger.info( "%s request for %r%s received from %s%s", environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo, remote_host, remote_useragent) headers = pprint.pformat(self._headers_log(environ)) logger.debug("Request headers:\n%s", headers) # Let reverse proxies overwrite SCRIPT_NAME if "HTTP_X_SCRIPT_NAME" in environ: # script_name must be removed from PATH_INFO by the client. unsafe_base_prefix = environ["HTTP_X_SCRIPT_NAME"] logger.debug("Script name overwritten by client: %r", unsafe_base_prefix) else: # SCRIPT_NAME is already removed from PATH_INFO, according to the # WSGI specification. unsafe_base_prefix = environ.get("SCRIPT_NAME", "") # Sanitize base prefix base_prefix = pathutils.sanitize_path(unsafe_base_prefix).rstrip("/") logger.debug("Sanitized script name: %r", base_prefix) # Sanitize request URI (a WSGI server indicates with an empty path, # that the URL targets the application root without a trailing slash) path = pathutils.sanitize_path(environ.get("PATH_INFO", "")) logger.debug("Sanitized path: %r", path) # Get function corresponding to method function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper()) # If "/.well-known" is not available, clients query "/" if path == "/.well-known" or path.startswith("/.well-known/"): return response(*httputils.NOT_FOUND) # Ask authentication backend to check rights login = password = "" external_login = self.Auth.get_external_login(environ) authorization = environ.get("HTTP_AUTHORIZATION", "") if external_login: login, password = external_login login, password = login or "", password or "" elif authorization.startswith("Basic"): authorization = authorization[len("Basic"):].strip() login, password = self.decode(base64.b64decode( authorization.encode("ascii")), environ).split(":", 1) user = self.Auth.login(login, password) or "" if login else "" if user and login == user: logger.info("Successful login: %r", user) elif user: logger.info("Successful login: %r -> %r", login, user) elif login: logger.info("Failed login attempt: %r", login) # Random delay to avoid timing oracles and bruteforce attacks delay = self.configuration.getfloat("auth", "delay") if delay > 0: random_delay = delay * (0.5 + random.random()) logger.debug("Sleeping %.3f seconds", random_delay) time.sleep(random_delay) if user and not pathutils.is_safe_path_component(user): # Prevent usernames like "user/calendar.ics" logger.info("Refused unsafe username: %r", user) user = "" # Create principal collection if user: principal_path = "/%s/" % user if self.Rights.authorized(user, principal_path, "W"): with self.Collection.acquire_lock("r", user): principal = next( self.Collection.discover(principal_path, depth="1"), None) if not principal: with self.Collection.acquire_lock("w", user): try: self.Collection.create_collection(principal_path) except ValueError as e: logger.warning("Failed to create principal " "collection %r: %s", user, e) user = "" else: logger.warning("Access to principal path %r denied by " "rights backend", principal_path) if self.configuration.getboolean("internal", "internal_server"): # Verify content length content_length = int(environ.get("CONTENT_LENGTH") or 0) if content_length: max_content_length = self.configuration.getint( "server", "max_content_length") if max_content_length and content_length > max_content_length: logger.info("Request body too large: %d", content_length) return response(*httputils.REQUEST_ENTITY_TOO_LARGE) if not login or user: status, headers, answer = function( environ, base_prefix, path, user) if (status, headers, answer) == httputils.NOT_ALLOWED: logger.info("Access to %r denied for %s", path, repr(user) if user else "anonymous user") else: status, headers, answer = httputils.NOT_ALLOWED if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and not external_login): # Unknown or unauthorized user logger.debug("Asking client for authentication") status = client.UNAUTHORIZED realm = self.configuration.get("auth", "realm") headers = dict(headers) headers.update({ "WWW-Authenticate": "Basic realm=\"%s\"" % realm}) return response(status, headers, answer)
def xml_report(base_prefix, path, xml_request, collection, unlock_storage_fn): """Read and answer REPORT requests. Read rfc3253-3.6 for info. """ multistatus = ET.Element(xmlutils.make_tag("D", "multistatus")) if xml_request is None: return client.MULTI_STATUS, multistatus root = xml_request if root.tag in ( xmlutils.make_tag("D", "principal-search-property-set"), xmlutils.make_tag("D", "principal-property-search"), xmlutils.make_tag("D", "expand-property")): # We don't support searching for principals or indirect retrieving of # properties, just return an empty result. # InfCloud asks for expand-property reports (even if we don't announce # support for them) and stops working if an error code is returned. logger.warning("Unsupported REPORT method %r on %r requested", xmlutils.tag_from_clark(root.tag), path) return client.MULTI_STATUS, multistatus if (root.tag == xmlutils.make_tag("C", "calendar-multiget") and collection.get_meta("tag") != "VCALENDAR" or root.tag == xmlutils.make_tag("CR", "addressbook-multiget") and collection.get_meta("tag") != "VADDRESSBOOK" or root.tag == xmlutils.make_tag("D", "sync-collection") and collection.get_meta("tag") not in ("VADDRESSBOOK", "VCALENDAR")): logger.warning("Invalid REPORT method %r on %r requested", xmlutils.tag_from_clark(root.tag), path) return (client.CONFLICT, xmlutils.webdav_error("D", "supported-report")) prop_element = root.find(xmlutils.make_tag("D", "prop")) props = ( [prop.tag for prop in prop_element] if prop_element is not None else []) if root.tag in ( xmlutils.make_tag("C", "calendar-multiget"), xmlutils.make_tag("CR", "addressbook-multiget")): # Read rfc4791-7.9 for info hreferences = set() for href_element in root.findall(xmlutils.make_tag("D", "href")): href_path = pathutils.sanitize_path( unquote(urlparse(href_element.text).path)) if (href_path + "/").startswith(base_prefix + "/"): hreferences.add(href_path[len(base_prefix):]) else: logger.warning("Skipping invalid path %r in REPORT request on " "%r", href_path, path) elif root.tag == xmlutils.make_tag("D", "sync-collection"): old_sync_token_element = root.find( xmlutils.make_tag("D", "sync-token")) old_sync_token = "" if old_sync_token_element is not None and old_sync_token_element.text: old_sync_token = old_sync_token_element.text.strip() logger.debug("Client provided sync token: %r", old_sync_token) try: 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) return (client.CONFLICT, xmlutils.webdav_error("D", "valid-sync-token")) hreferences = (pathutils.unstrip_path( posixpath.join(collection.path, n)) for n in names) # Append current sync token to response sync_token_element = ET.Element(xmlutils.make_tag("D", "sync-token")) sync_token_element.text = sync_token multistatus.append(sync_token_element) else: hreferences = (path,) filters = ( root.findall("./%s" % xmlutils.make_tag("C", "filter")) + root.findall("./%s" % xmlutils.make_tag("CR", "filter"))) def retrieve_items(collection, hreferences, multistatus): """Retrieves all items that are referenced in ``hreferences`` from ``collection`` and adds 404 responses for missing and invalid items to ``multistatus``.""" collection_requested = False def get_names(): """Extracts all names from references in ``hreferences`` and adds 404 responses for invalid references to ``multistatus``. If the whole collections is referenced ``collection_requested`` gets set to ``True``.""" nonlocal collection_requested for hreference in hreferences: try: name = pathutils.name_from_path(hreference, collection) except ValueError as e: logger.warning("Skipping invalid path %r in REPORT request" " on %r: %s", hreference, path, e) response = xml_item_response(base_prefix, hreference, found_item=False) multistatus.append(response) continue if name: # Reference is an item yield name else: # Reference is a collection collection_requested = True for name, item in collection.get_multi(get_names()): if not item: uri = pathutils.unstrip_path( posixpath.join(collection.path, name)) response = xml_item_response(base_prefix, uri, found_item=False) multistatus.append(response) else: yield item, False if collection_requested: yield from collection.get_filtered(filters) # Retrieve everything required for finishing the request. retrieved_items = list(retrieve_items(collection, hreferences, multistatus)) collection_tag = collection.get_meta("tag") # Don't access storage after this! unlock_storage_fn() def match(item, filter_): tag = collection_tag if (tag == "VCALENDAR" and filter_.tag != xmlutils.make_tag("C", filter_)): if len(filter_) == 0: return True if len(filter_) > 1: raise ValueError("Filter with %d children" % len(filter_)) if filter_[0].tag != xmlutils.make_tag("C", "comp-filter"): raise ValueError("Unexpected %r in filter" % filter_[0].tag) return radicale_filter.comp_match(item, filter_[0]) if (tag == "VADDRESSBOOK" and filter_.tag != xmlutils.make_tag("CR", filter_)): for child in filter_: if child.tag != xmlutils.make_tag("CR", "prop-filter"): raise ValueError("Unexpected %r in filter" % child.tag) test = filter_.get("test", "anyof") if test == "anyof": return any( radicale_filter.prop_match(item.vobject_item, f, "CR") for f in filter_) if test == "allof": return all( radicale_filter.prop_match(item.vobject_item, f, "CR") for f in filter_) raise ValueError("Unsupported filter test: %r" % test) return all(radicale_filter.prop_match(item.vobject_item, f, "CR") for f in filter_) raise ValueError("unsupported filter %r for %r" % (filter_.tag, tag)) while retrieved_items: # ``item.vobject_item`` might be accessed during filtering. # Don't keep reference to ``item``, because VObject requires a lot of # memory. item, filters_matched = retrieved_items.pop(0) if filters and not filters_matched: try: if not all(match(item, filter_) for filter_ in filters): continue except ValueError as e: raise ValueError("Failed to filter item %r from %r: %s" % (item.href, collection.path, e)) from e except Exception as e: raise RuntimeError("Failed to filter item %r from %r: %s" % (item.href, collection.path, e)) from e found_props = [] not_found_props = [] for tag in props: element = ET.Element(tag) if tag == xmlutils.make_tag("D", "getetag"): element.text = item.etag found_props.append(element) elif tag == xmlutils.make_tag("D", "getcontenttype"): element.text = xmlutils.get_content_type(item) found_props.append(element) elif tag in ( xmlutils.make_tag("C", "calendar-data"), xmlutils.make_tag("CR", "address-data")): element.text = item.serialize() found_props.append(element) else: not_found_props.append(element) uri = pathutils.unstrip_path( posixpath.join(collection.path, item.href)) multistatus.append(xml_item_response( base_prefix, uri, found_props=found_props, not_found_props=not_found_props, found_item=True)) return client.MULTI_STATUS, multistatus
def __init__(self, path, filename=None, adapter=None): self._path = sanitize_path(path).strip('/') self.filename = filename self.adapter = adapter
def _handle_request(self, environ): """Manage a request.""" def response(status, headers=(), answer=None): headers = dict(headers) # Set content length if answer: if hasattr(answer, "encode"): logger.debug("Response content:\n%s", answer) headers["Content-Type"] += "; charset=%s" % self._encoding answer = answer.encode(self._encoding) accept_encoding = [ encoding.strip() for encoding in environ.get( "HTTP_ACCEPT_ENCODING", "").split(",") if encoding.strip() ] if "gzip" in accept_encoding: zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS) answer = zcomp.compress(answer) + zcomp.flush() headers["Content-Encoding"] = "gzip" headers["Content-Length"] = str(len(answer)) # Add extra headers set in configuration for key in self.configuration.options("headers"): headers[key] = self.configuration.get("headers", key) # Start response time_end = datetime.datetime.now() status = "%d %s" % (status, client.responses.get( status, "Unknown")) logger.info("%s response status for %r%s in %.3f seconds: %s", environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo, (time_end - time_begin).total_seconds(), status) # Return response content return status, list(headers.items()), [answer] if answer else [] remote_host = "unknown" if environ.get("REMOTE_HOST"): remote_host = repr(environ["REMOTE_HOST"]) elif environ.get("REMOTE_ADDR"): remote_host = environ["REMOTE_ADDR"] if environ.get("HTTP_X_FORWARDED_FOR"): remote_host = "%s (forwarded for %r)" % ( remote_host, environ["HTTP_X_FORWARDED_FOR"]) remote_useragent = "" if environ.get("HTTP_USER_AGENT"): remote_useragent = " using %r" % environ["HTTP_USER_AGENT"] depthinfo = "" if environ.get("HTTP_DEPTH"): depthinfo = " with depth %r" % environ["HTTP_DEPTH"] time_begin = datetime.datetime.now() logger.info("%s request for %r%s received from %s%s", environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo, remote_host, remote_useragent) headers = pprint.pformat(self._headers_log(environ)) logger.debug("Request headers:\n%s", headers) # Let reverse proxies overwrite SCRIPT_NAME if "HTTP_X_SCRIPT_NAME" in environ: # script_name must be removed from PATH_INFO by the client. unsafe_base_prefix = environ["HTTP_X_SCRIPT_NAME"] logger.debug("Script name overwritten by client: %r", unsafe_base_prefix) else: # SCRIPT_NAME is already removed from PATH_INFO, according to the # WSGI specification. unsafe_base_prefix = environ.get("SCRIPT_NAME", "") # Sanitize base prefix base_prefix = pathutils.sanitize_path(unsafe_base_prefix).rstrip("/") logger.debug("Sanitized script name: %r", base_prefix) # Sanitize request URI (a WSGI server indicates with an empty path, # that the URL targets the application root without a trailing slash) path = pathutils.sanitize_path(environ.get("PATH_INFO", "")) logger.debug("Sanitized path: %r", path) # Get function corresponding to method function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper(), None) if not function: return response(*httputils.METHOD_NOT_ALLOWED) # If "/.well-known" is not available, clients query "/" if path == "/.well-known" or path.startswith("/.well-known/"): return response(*httputils.NOT_FOUND) # Ask authentication backend to check rights login = password = "" external_login = self._auth.get_external_login(environ) authorization = environ.get("HTTP_AUTHORIZATION", "") if external_login: login, password = external_login login, password = login or "", password or "" elif authorization.startswith("Basic"): authorization = authorization[len("Basic"):].strip() login, password = httputils.decode_request( self.configuration, environ, base64.b64decode(authorization.encode("ascii"))).split(":", 1) user = self._auth.login(login, password) or "" if login else "" if user and login == user: logger.info("Successful login: %r", user) elif user: logger.info("Successful login: %r -> %r", login, user) elif login: logger.warning("Failed login attempt from %s: %r", remote_host, login) # Random delay to avoid timing oracles and bruteforce attacks delay = self.configuration.get("auth", "delay") if delay > 0: random_delay = delay * (0.5 + random.random()) logger.debug("Sleeping %.3f seconds", random_delay) time.sleep(random_delay) if user and not pathutils.is_safe_path_component(user): # Prevent usernames like "user/calendar.ics" logger.info("Refused unsafe username: %r", user) user = "" # Create principal collection if user: principal_path = "/%s/" % user with self._storage.acquire_lock("r", user): principal = next( self._storage.discover(principal_path, depth="1"), None) if not principal: if "W" in self._rights.authorization(user, principal_path): with self._storage.acquire_lock("w", user): try: self._storage.create_collection(principal_path) except ValueError as e: logger.warning( "Failed to create principal " "collection %r: %s", user, e) user = "" else: logger.warning( "Access to principal path %r denied by " "rights backend", principal_path) if self.configuration.get("server", "_internal_server"): # Verify content length content_length = int(environ.get("CONTENT_LENGTH") or 0) if content_length: max_content_length = self.configuration.get( "server", "max_content_length") if max_content_length and content_length > max_content_length: logger.info("Request body too large: %d", content_length) return response(*httputils.REQUEST_ENTITY_TOO_LARGE) if not login or user: status, headers, answer = function(environ, base_prefix, path, user) if (status, headers, answer) == httputils.NOT_ALLOWED: logger.info("Access to %r denied for %s", path, repr(user) if user else "anonymous user") else: status, headers, answer = httputils.NOT_ALLOWED if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and not external_login): # Unknown or unauthorized user logger.debug("Asking client for authentication") status = client.UNAUTHORIZED realm = self.configuration.get("auth", "realm") headers = dict(headers) headers.update({"WWW-Authenticate": "Basic realm=\"%s\"" % realm}) return response(status, headers, answer)
def make_href(base_prefix, href): """Return prefixed href.""" assert href == pathutils.sanitize_path(href) return quote("%s%s" % (base_prefix, href))