mirror of
https://github.com/Kozea/Radicale.git
synced 2025-09-15 20:36:55 +00:00
Synced with origin
This commit is contained in:
parent
9b3bb2de2b
commit
cf81d1f9a7
94 changed files with 5096 additions and 3560 deletions
|
@ -1,4 +1,4 @@
|
|||
# This file is part of Radicale Server - Calendar Server
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2008 Nicolas Kandel
|
||||
# Copyright © 2008 Pascal Halter
|
||||
# Copyright © 2008-2017 Guillaume Ayoub
|
||||
|
@ -27,47 +27,49 @@ the built-in server (see ``radicale.server`` module).
|
|||
|
||||
import base64
|
||||
import datetime
|
||||
import io
|
||||
import logging
|
||||
import posixpath
|
||||
import pprint
|
||||
import random
|
||||
import time
|
||||
import zlib
|
||||
from http import client
|
||||
from xml.etree import ElementTree as ET
|
||||
from typing import Iterable, List, Mapping, Tuple, Union
|
||||
|
||||
import defusedxml.ElementTree as DefusedET
|
||||
import pkg_resources
|
||||
|
||||
from radicale import (auth, hook, httputils, log, pathutils, rights, storage,
|
||||
web, xmlutils)
|
||||
from radicale.app.delete import ApplicationDeleteMixin
|
||||
from radicale.app.get import ApplicationGetMixin
|
||||
from radicale.app.head import ApplicationHeadMixin
|
||||
from radicale.app.mkcalendar import ApplicationMkcalendarMixin
|
||||
from radicale.app.mkcol import ApplicationMkcolMixin
|
||||
from radicale.app.move import ApplicationMoveMixin
|
||||
from radicale.app.options import ApplicationOptionsMixin
|
||||
from radicale.app.propfind import ApplicationPropfindMixin
|
||||
from radicale.app.proppatch import ApplicationProppatchMixin
|
||||
from radicale.app.put import ApplicationPutMixin
|
||||
from radicale.app.report import ApplicationReportMixin
|
||||
from radicale import config, httputils, log, pathutils, types
|
||||
from radicale.app.base import ApplicationBase
|
||||
from radicale.app.delete import ApplicationPartDelete
|
||||
from radicale.app.get import ApplicationPartGet
|
||||
from radicale.app.head import ApplicationPartHead
|
||||
from radicale.app.mkcalendar import ApplicationPartMkcalendar
|
||||
from radicale.app.mkcol import ApplicationPartMkcol
|
||||
from radicale.app.move import ApplicationPartMove
|
||||
from radicale.app.options import ApplicationPartOptions
|
||||
from radicale.app.post import ApplicationPartPost
|
||||
from radicale.app.propfind import ApplicationPartPropfind
|
||||
from radicale.app.proppatch import ApplicationPartProppatch
|
||||
from radicale.app.put import ApplicationPartPut
|
||||
from radicale.app.report import ApplicationPartReport
|
||||
from radicale.log import logger
|
||||
|
||||
VERSION = pkg_resources.get_distribution("radicale").version
|
||||
# Combination of types.WSGIStartResponse and WSGI application return value
|
||||
_IntermediateResponse = Tuple[str, List[Tuple[str, str]], Iterable[bytes]]
|
||||
|
||||
|
||||
class Application(
|
||||
ApplicationDeleteMixin, ApplicationGetMixin, ApplicationHeadMixin,
|
||||
ApplicationMkcalendarMixin, ApplicationMkcolMixin,
|
||||
ApplicationMoveMixin, ApplicationOptionsMixin,
|
||||
ApplicationPropfindMixin, ApplicationProppatchMixin,
|
||||
ApplicationPutMixin, ApplicationReportMixin):
|
||||
|
||||
class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
ApplicationPartGet, ApplicationPartMkcalendar,
|
||||
ApplicationPartMkcol, ApplicationPartMove,
|
||||
ApplicationPartOptions, ApplicationPartPropfind,
|
||||
ApplicationPartProppatch, ApplicationPartPost,
|
||||
ApplicationPartPut, ApplicationPartReport, ApplicationBase):
|
||||
"""WSGI application."""
|
||||
|
||||
def __init__(self, configuration):
|
||||
_mask_passwords: bool
|
||||
_auth_delay: float
|
||||
_internal_server: bool
|
||||
_max_content_length: int
|
||||
_auth_realm: str
|
||||
_extra_headers: Mapping[str, str]
|
||||
|
||||
def __init__(self, configuration: config.Configuration) -> None:
|
||||
"""Initialize Application.
|
||||
|
||||
``configuration`` see ``radicale.config`` module.
|
||||
|
@ -75,85 +77,65 @@ class Application(
|
|||
this object, it is kept as an internal reference.
|
||||
|
||||
"""
|
||||
super().__init__()
|
||||
self.configuration = configuration
|
||||
self._auth = auth.load(configuration)
|
||||
self._storage = storage.load(configuration)
|
||||
self._rights = rights.load(configuration)
|
||||
self._web = web.load(configuration)
|
||||
self._encoding = configuration.get("encoding", "request")
|
||||
self._hook = hook.load(configuration)
|
||||
super().__init__(configuration)
|
||||
self._mask_passwords = configuration.get("logging", "mask_passwords")
|
||||
self._auth_delay = configuration.get("auth", "delay")
|
||||
self._internal_server = configuration.get("server", "_internal_server")
|
||||
self._max_content_length = configuration.get(
|
||||
"server", "max_content_length")
|
||||
self._auth_realm = configuration.get("auth", "realm")
|
||||
self._extra_headers = dict()
|
||||
for key in self.configuration.options("headers"):
|
||||
self._extra_headers[key] = configuration.get("headers", key)
|
||||
|
||||
def _headers_log(self, environ):
|
||||
"""Sanitize headers for logging."""
|
||||
request_environ = dict(environ)
|
||||
def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron:
|
||||
"""Mask passwords and cookies."""
|
||||
headers = dict(environ)
|
||||
if (self._mask_passwords and
|
||||
headers.get("HTTP_AUTHORIZATION", "").startswith("Basic")):
|
||||
headers["HTTP_AUTHORIZATION"] = "Basic **masked**"
|
||||
if headers.get("HTTP_COOKIE"):
|
||||
headers["HTTP_COOKIE"] = "**masked**"
|
||||
return headers
|
||||
|
||||
# Mask passwords
|
||||
mask_passwords = self.configuration.get("logging", "mask_passwords")
|
||||
authorization = request_environ.get("HTTP_AUTHORIZATION", "")
|
||||
if mask_passwords and authorization.startswith("Basic"):
|
||||
request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**"
|
||||
if request_environ.get("HTTP_COOKIE"):
|
||||
request_environ["HTTP_COOKIE"] = "**masked**"
|
||||
|
||||
return request_environ
|
||||
|
||||
def _decode(self, text, environ):
|
||||
"""Try to magically decode ``text`` according to given ``environ``."""
|
||||
# List of charsets to try
|
||||
charsets = []
|
||||
|
||||
# First append content charset given in the request
|
||||
content_type = environ.get("CONTENT_TYPE")
|
||||
if content_type and "charset=" in content_type:
|
||||
charsets.append(
|
||||
content_type.split("charset=")[1].split(";")[0].strip())
|
||||
# Then append default Radicale charset
|
||||
charsets.append(self._encoding)
|
||||
# Then append various fallbacks
|
||||
charsets.append("utf-8")
|
||||
charsets.append("iso8859-1")
|
||||
|
||||
# Try to decode
|
||||
for charset in charsets:
|
||||
try:
|
||||
return text.decode(charset)
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
raise UnicodeDecodeError
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
def __call__(self, environ: types.WSGIEnviron, start_response:
|
||||
types.WSGIStartResponse) -> Iterable[bytes]:
|
||||
with log.register_stream(environ["wsgi.errors"]):
|
||||
try:
|
||||
status, headers, answers = self._handle_request(environ)
|
||||
status_text, headers, answers = self._handle_request(environ)
|
||||
except Exception as e:
|
||||
try:
|
||||
method = str(environ["REQUEST_METHOD"])
|
||||
except Exception:
|
||||
method = "unknown"
|
||||
try:
|
||||
path = str(environ.get("PATH_INFO", ""))
|
||||
except Exception:
|
||||
path = ""
|
||||
logger.error("An exception occurred during %s request on %r: "
|
||||
"%s", method, path, e, exc_info=True)
|
||||
status, headers, answer = httputils.INTERNAL_SERVER_ERROR
|
||||
answer = answer.encode("ascii")
|
||||
status = "%d %s" % (
|
||||
status.value, client.responses.get(status, "Unknown"))
|
||||
headers = [
|
||||
("Content-Length", str(len(answer)))] + list(headers)
|
||||
"%s", environ.get("REQUEST_METHOD", "unknown"),
|
||||
environ.get("PATH_INFO", ""), e, exc_info=True)
|
||||
# Make minimal response
|
||||
status, raw_headers, raw_answer = (
|
||||
httputils.INTERNAL_SERVER_ERROR)
|
||||
assert isinstance(raw_answer, str)
|
||||
answer = raw_answer.encode("ascii")
|
||||
status_text = "%d %s" % (
|
||||
status, client.responses.get(status, "Unknown"))
|
||||
headers = [*raw_headers, ("Content-Length", str(len(answer)))]
|
||||
answers = [answer]
|
||||
start_response(status, headers)
|
||||
start_response(status_text, headers)
|
||||
if environ.get("REQUEST_METHOD") == "HEAD":
|
||||
return []
|
||||
return answers
|
||||
|
||||
def _handle_request(self, environ):
|
||||
def _handle_request(self, environ: types.WSGIEnviron
|
||||
) -> _IntermediateResponse:
|
||||
time_begin = datetime.datetime.now()
|
||||
request_method = environ["REQUEST_METHOD"].upper()
|
||||
unsafe_path = environ.get("PATH_INFO", "")
|
||||
|
||||
"""Manage a request."""
|
||||
def response(status, headers=(), answer=None):
|
||||
def response(status: int, headers: types.WSGIResponseHeaders,
|
||||
answer: Union[None, str, bytes]) -> _IntermediateResponse:
|
||||
"""Helper to create response from internal types.WSGIResponse"""
|
||||
headers = dict(headers)
|
||||
# Set content length
|
||||
if answer:
|
||||
if hasattr(answer, "encode"):
|
||||
answers = []
|
||||
if answer is not None:
|
||||
if isinstance(answer, str):
|
||||
logger.debug("Response content:\n%s", answer)
|
||||
headers["Content-Type"] += "; charset=%s" % self._encoding
|
||||
answer = answer.encode(self._encoding)
|
||||
|
@ -168,21 +150,20 @@ class Application(
|
|||
headers["Content-Encoding"] = "gzip"
|
||||
|
||||
headers["Content-Length"] = str(len(answer))
|
||||
answers.append(answer)
|
||||
|
||||
# Add extra headers set in configuration
|
||||
for key in self.configuration.options("headers"):
|
||||
headers[key] = self.configuration.get("headers", key)
|
||||
headers.update(self._extra_headers)
|
||||
|
||||
# Start response
|
||||
time_end = datetime.datetime.now()
|
||||
status = "%d %s" % (
|
||||
status_text = "%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)
|
||||
logger.info("%s response status for %r%s in %.3f seconds: %s",
|
||||
request_method, unsafe_path, depthinfo,
|
||||
(time_end - time_begin).total_seconds(), status_text)
|
||||
# Return response content
|
||||
return status, list(headers.items()), [answer] if answer else []
|
||||
return status_text, list(headers.items()), answers
|
||||
|
||||
remote_host = "unknown"
|
||||
if environ.get("REMOTE_HOST"):
|
||||
|
@ -190,45 +171,56 @@ class Application(
|
|||
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_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)
|
||||
logger.info("%s request for %r%s received from %s%s",
|
||||
request_method, unsafe_path, depthinfo,
|
||||
remote_host, remote_useragent)
|
||||
logger.debug("Request headers:\n%s",
|
||||
pprint.pformat(self._scrub_headers(environ)))
|
||||
|
||||
# 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)
|
||||
# SCRIPT_NAME is already removed from PATH_INFO, according to the
|
||||
# WSGI specification.
|
||||
# Reverse proxies can overwrite SCRIPT_NAME with X-SCRIPT-NAME header
|
||||
base_prefix_src = ("HTTP_X_SCRIPT_NAME" if "HTTP_X_SCRIPT_NAME" in
|
||||
environ else "SCRIPT_NAME")
|
||||
base_prefix = environ.get(base_prefix_src, "")
|
||||
if base_prefix and base_prefix[0] != "/":
|
||||
logger.error("Base prefix (from %s) must start with '/': %r",
|
||||
base_prefix_src, base_prefix)
|
||||
if base_prefix_src == "HTTP_X_SCRIPT_NAME":
|
||||
return response(*httputils.BAD_REQUEST)
|
||||
return response(*httputils.INTERNAL_SERVER_ERROR)
|
||||
if base_prefix.endswith("/"):
|
||||
logger.warning("Base prefix (from %s) must not end with '/': %r",
|
||||
base_prefix_src, base_prefix)
|
||||
base_prefix = base_prefix.rstrip("/")
|
||||
logger.debug("Base prefix (from %s): %r", base_prefix_src, 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", ""))
|
||||
path = pathutils.sanitize_path(unsafe_path)
|
||||
logger.debug("Sanitized path: %r", path)
|
||||
|
||||
# Get function corresponding to method
|
||||
function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper())
|
||||
function = getattr(self, "do_%s" % request_method, 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/"):
|
||||
# Redirect all "…/.well-known/{caldav,carddav}" paths to "/".
|
||||
# This shouldn't be necessary but some clients like TbSync require it.
|
||||
# Status must be MOVED PERMANENTLY using FOUND causes problems
|
||||
if (path.rstrip("/").endswith("/.well-known/caldav") or
|
||||
path.rstrip("/").endswith("/.well-known/carddav")):
|
||||
return response(*httputils.redirect(
|
||||
base_prefix + "/", client.MOVED_PERMANENTLY))
|
||||
# Return NOT FOUND for all other paths containing ".well-knwon"
|
||||
if path.endswith("/.well-known") or "/.well-known/" in path:
|
||||
return response(*httputils.NOT_FOUND)
|
||||
|
||||
# Ask authentication backend to check rights
|
||||
|
@ -240,8 +232,9 @@ class Application(
|
|||
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)
|
||||
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:
|
||||
|
@ -249,11 +242,11 @@ class Application(
|
|||
elif user:
|
||||
logger.info("Successful login: %r -> %r", login, user)
|
||||
elif login:
|
||||
logger.info("Failed login attempt: %r", 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())
|
||||
if self._auth_delay > 0:
|
||||
random_delay = self._auth_delay * (0.5 + random.random())
|
||||
logger.debug("Sleeping %.3f seconds", random_delay)
|
||||
time.sleep(random_delay)
|
||||
|
||||
|
@ -266,8 +259,8 @@ class Application(
|
|||
if user:
|
||||
principal_path = "/%s/" % user
|
||||
with self._storage.acquire_lock("r", user):
|
||||
principal = next(self._storage.discover(
|
||||
principal_path, depth="1"), None)
|
||||
principal = next(iter(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):
|
||||
|
@ -281,13 +274,12 @@ class Application(
|
|||
logger.warning("Access to principal path %r denied by "
|
||||
"rights backend", principal_path)
|
||||
|
||||
if self.configuration.get("server", "_internal_server"):
|
||||
if self._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:
|
||||
if (self._max_content_length > 0 and
|
||||
content_length > self._max_content_length):
|
||||
logger.info("Request body too large: %d", content_length)
|
||||
return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
|
||||
|
||||
|
@ -305,94 +297,9 @@ class Application(
|
|||
# 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})
|
||||
"Basic realm=\"%s\"" % self._auth_realm})
|
||||
|
||||
return response(status, headers, answer)
|
||||
|
||||
def _read_raw_content(self, environ):
|
||||
content_length = int(environ.get("CONTENT_LENGTH") or 0)
|
||||
if not content_length:
|
||||
return b""
|
||||
content = environ["wsgi.input"].read(content_length)
|
||||
if len(content) < content_length:
|
||||
raise RuntimeError("Request body too short: %d" % len(content))
|
||||
return content
|
||||
|
||||
def _read_content(self, environ):
|
||||
content = self._decode(self._read_raw_content(environ), environ)
|
||||
logger.debug("Request content:\n%s", content)
|
||||
return content
|
||||
|
||||
def _read_xml_content(self, environ):
|
||||
content = self._decode(self._read_raw_content(environ), environ)
|
||||
if not content:
|
||||
return None
|
||||
try:
|
||||
xml_content = DefusedET.fromstring(content)
|
||||
except ET.ParseError as e:
|
||||
logger.debug("Request content (Invalid XML):\n%s", content)
|
||||
raise RuntimeError("Failed to parse XML: %s" % e) from e
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Request content:\n%s",
|
||||
xmlutils.pretty_xml(xml_content))
|
||||
return xml_content
|
||||
|
||||
def _write_xml_content(self, xml_content):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Response content:\n%s",
|
||||
xmlutils.pretty_xml(xml_content))
|
||||
f = io.BytesIO()
|
||||
ET.ElementTree(xml_content).write(f, encoding=self._encoding,
|
||||
xml_declaration=True)
|
||||
return f.getvalue()
|
||||
|
||||
def _webdav_error_response(self, status, human_tag):
|
||||
"""Generate XML error response."""
|
||||
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||
content = self._write_xml_content(xmlutils.webdav_error(human_tag))
|
||||
return status, headers, content
|
||||
|
||||
|
||||
class Access:
|
||||
"""Helper class to check access rights of an item"""
|
||||
|
||||
def __init__(self, rights, user, path):
|
||||
self._rights = rights
|
||||
self.user = user
|
||||
self.path = path
|
||||
self.parent_path = pathutils.unstrip_path(
|
||||
posixpath.dirname(pathutils.strip_path(path)), True)
|
||||
self.permissions = self._rights.authorization(self.user, self.path)
|
||||
self._parent_permissions = None
|
||||
|
||||
@property
|
||||
def parent_permissions(self):
|
||||
if self.path == self.parent_path:
|
||||
return self.permissions
|
||||
if self._parent_permissions is None:
|
||||
self._parent_permissions = self._rights.authorization(
|
||||
self.user, self.parent_path)
|
||||
return self._parent_permissions
|
||||
|
||||
def check(self, permission, item=None):
|
||||
if permission not in "rw":
|
||||
raise ValueError("Invalid permission argument: %r" % permission)
|
||||
if not item:
|
||||
permissions = permission + permission.upper()
|
||||
parent_permissions = permission
|
||||
elif isinstance(item, storage.BaseCollection):
|
||||
if item.get_meta("tag"):
|
||||
permissions = permission
|
||||
else:
|
||||
permissions = permission.upper()
|
||||
parent_permissions = ""
|
||||
else:
|
||||
permissions = ""
|
||||
parent_permissions = permission
|
||||
return bool(rights.intersect(self.permissions, permissions) or (
|
||||
self.path != self.parent_path and
|
||||
rights.intersect(self.parent_permissions, parent_permissions)))
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue