__init__.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2008 Nicolas Kandel
  3. # Copyright © 2008 Pascal Halter
  4. # Copyright © 2008-2017 Guillaume Ayoub
  5. # Copyright © 2017-2019 Unrud <unrud@outlook.com>
  6. #
  7. # This library is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This library is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  19. """
  20. Radicale WSGI application.
  21. Can be used with an external WSGI server (see ``radicale.application()``) or
  22. the built-in server (see ``radicale.server`` module).
  23. """
  24. import base64
  25. import datetime
  26. import io
  27. import logging
  28. import posixpath
  29. import pprint
  30. import random
  31. import time
  32. import zlib
  33. from http import client
  34. from xml.etree import ElementTree as ET
  35. import defusedxml.ElementTree as DefusedET
  36. import pkg_resources
  37. from radicale import (auth, httputils, log, pathutils, rights, storage, web,
  38. xmlutils)
  39. from radicale.app.delete import ApplicationDeleteMixin
  40. from radicale.app.get import ApplicationGetMixin
  41. from radicale.app.head import ApplicationHeadMixin
  42. from radicale.app.mkcalendar import ApplicationMkcalendarMixin
  43. from radicale.app.mkcol import ApplicationMkcolMixin
  44. from radicale.app.move import ApplicationMoveMixin
  45. from radicale.app.options import ApplicationOptionsMixin
  46. from radicale.app.post import ApplicationPostMixin
  47. from radicale.app.propfind import ApplicationPropfindMixin
  48. from radicale.app.proppatch import ApplicationProppatchMixin
  49. from radicale.app.put import ApplicationPutMixin
  50. from radicale.app.report import ApplicationReportMixin
  51. from radicale.log import logger
  52. VERSION = pkg_resources.get_distribution("radicale").version
  53. class Application(
  54. ApplicationDeleteMixin, ApplicationGetMixin, ApplicationHeadMixin,
  55. ApplicationMkcalendarMixin, ApplicationMkcolMixin,
  56. ApplicationMoveMixin, ApplicationOptionsMixin,
  57. ApplicationPropfindMixin, ApplicationProppatchMixin,
  58. ApplicationPostMixin, ApplicationPutMixin,
  59. ApplicationReportMixin):
  60. """WSGI application."""
  61. def __init__(self, configuration):
  62. """Initialize Application.
  63. ``configuration`` see ``radicale.config`` module.
  64. The ``configuration`` must not change during the lifetime of
  65. this object, it is kept as an internal reference.
  66. """
  67. super().__init__()
  68. self.configuration = configuration
  69. self._auth = auth.load(configuration)
  70. self._storage = storage.load(configuration)
  71. self._rights = rights.load(configuration)
  72. self._web = web.load(configuration)
  73. self._encoding = configuration.get("encoding", "request")
  74. def _headers_log(self, environ):
  75. """Sanitize headers for logging."""
  76. request_environ = dict(environ)
  77. # Mask passwords
  78. mask_passwords = self.configuration.get("logging", "mask_passwords")
  79. authorization = request_environ.get("HTTP_AUTHORIZATION", "")
  80. if mask_passwords and authorization.startswith("Basic"):
  81. request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**"
  82. if request_environ.get("HTTP_COOKIE"):
  83. request_environ["HTTP_COOKIE"] = "**masked**"
  84. return request_environ
  85. def __call__(self, environ, start_response):
  86. with log.register_stream(environ["wsgi.errors"]):
  87. try:
  88. status, headers, answers = self._handle_request(environ)
  89. except Exception as e:
  90. try:
  91. method = str(environ["REQUEST_METHOD"])
  92. except Exception:
  93. method = "unknown"
  94. try:
  95. path = str(environ.get("PATH_INFO", ""))
  96. except Exception:
  97. path = ""
  98. logger.error("An exception occurred during %s request on %r: "
  99. "%s", method, path, e, exc_info=True)
  100. status, headers, answer = httputils.INTERNAL_SERVER_ERROR
  101. answer = answer.encode("ascii")
  102. status = "%d %s" % (
  103. status.value, client.responses.get(status, "Unknown"))
  104. headers = [
  105. ("Content-Length", str(len(answer)))] + list(headers)
  106. answers = [answer]
  107. start_response(status, headers)
  108. return answers
  109. def _handle_request(self, environ):
  110. """Manage a request."""
  111. def response(status, headers=(), answer=None):
  112. headers = dict(headers)
  113. # Set content length
  114. if answer:
  115. if hasattr(answer, "encode"):
  116. logger.debug("Response content:\n%s", answer)
  117. headers["Content-Type"] += "; charset=%s" % self._encoding
  118. answer = answer.encode(self._encoding)
  119. accept_encoding = [
  120. encoding.strip() for encoding in
  121. environ.get("HTTP_ACCEPT_ENCODING", "").split(",")
  122. if encoding.strip()]
  123. if "gzip" in accept_encoding:
  124. zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
  125. answer = zcomp.compress(answer) + zcomp.flush()
  126. headers["Content-Encoding"] = "gzip"
  127. headers["Content-Length"] = str(len(answer))
  128. # Add extra headers set in configuration
  129. for key in self.configuration.options("headers"):
  130. headers[key] = self.configuration.get("headers", key)
  131. # Start response
  132. time_end = datetime.datetime.now()
  133. status = "%d %s" % (
  134. status, client.responses.get(status, "Unknown"))
  135. logger.info(
  136. "%s response status for %r%s in %.3f seconds: %s",
  137. environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""),
  138. depthinfo, (time_end - time_begin).total_seconds(), status)
  139. # Return response content
  140. return status, list(headers.items()), [answer] if answer else []
  141. remote_host = "unknown"
  142. if environ.get("REMOTE_HOST"):
  143. remote_host = repr(environ["REMOTE_HOST"])
  144. elif environ.get("REMOTE_ADDR"):
  145. remote_host = environ["REMOTE_ADDR"]
  146. if environ.get("HTTP_X_FORWARDED_FOR"):
  147. remote_host = "%r (forwarded by %s)" % (
  148. environ["HTTP_X_FORWARDED_FOR"], remote_host)
  149. remote_useragent = ""
  150. if environ.get("HTTP_USER_AGENT"):
  151. remote_useragent = " using %r" % environ["HTTP_USER_AGENT"]
  152. depthinfo = ""
  153. if environ.get("HTTP_DEPTH"):
  154. depthinfo = " with depth %r" % environ["HTTP_DEPTH"]
  155. time_begin = datetime.datetime.now()
  156. logger.info(
  157. "%s request for %r%s received from %s%s",
  158. environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo,
  159. remote_host, remote_useragent)
  160. headers = pprint.pformat(self._headers_log(environ))
  161. logger.debug("Request headers:\n%s", headers)
  162. # Let reverse proxies overwrite SCRIPT_NAME
  163. if "HTTP_X_SCRIPT_NAME" in environ:
  164. # script_name must be removed from PATH_INFO by the client.
  165. unsafe_base_prefix = environ["HTTP_X_SCRIPT_NAME"]
  166. logger.debug("Script name overwritten by client: %r",
  167. unsafe_base_prefix)
  168. else:
  169. # SCRIPT_NAME is already removed from PATH_INFO, according to the
  170. # WSGI specification.
  171. unsafe_base_prefix = environ.get("SCRIPT_NAME", "")
  172. # Sanitize base prefix
  173. base_prefix = pathutils.sanitize_path(unsafe_base_prefix).rstrip("/")
  174. logger.debug("Sanitized script name: %r", base_prefix)
  175. # Sanitize request URI (a WSGI server indicates with an empty path,
  176. # that the URL targets the application root without a trailing slash)
  177. path = pathutils.sanitize_path(environ.get("PATH_INFO", ""))
  178. logger.debug("Sanitized path: %r", path)
  179. # Get function corresponding to method
  180. function = getattr(
  181. self, "do_%s" % environ["REQUEST_METHOD"].upper(), None)
  182. if not function:
  183. return response(*httputils.METHOD_NOT_ALLOWED)
  184. # If "/.well-known" is not available, clients query "/"
  185. if path == "/.well-known" or path.startswith("/.well-known/"):
  186. return response(*httputils.NOT_FOUND)
  187. # Ask authentication backend to check rights
  188. login = password = ""
  189. external_login = self._auth.get_external_login(environ)
  190. authorization = environ.get("HTTP_AUTHORIZATION", "")
  191. if external_login:
  192. login, password = external_login
  193. login, password = login or "", password or ""
  194. elif authorization.startswith("Basic"):
  195. authorization = authorization[len("Basic"):].strip()
  196. login, password = httputils.decode_request(
  197. self.configuration, environ, base64.b64decode(
  198. authorization.encode("ascii"))).split(":", 1)
  199. user = self._auth.login(login, password) or "" if login else ""
  200. if user and login == user:
  201. logger.info("Successful login: %r", user)
  202. elif user:
  203. logger.info("Successful login: %r -> %r", login, user)
  204. elif login:
  205. logger.info("Failed login attempt: %r", login)
  206. # Random delay to avoid timing oracles and bruteforce attacks
  207. delay = self.configuration.get("auth", "delay")
  208. if delay > 0:
  209. random_delay = delay * (0.5 + random.random())
  210. logger.debug("Sleeping %.3f seconds", random_delay)
  211. time.sleep(random_delay)
  212. if user and not pathutils.is_safe_path_component(user):
  213. # Prevent usernames like "user/calendar.ics"
  214. logger.info("Refused unsafe username: %r", user)
  215. user = ""
  216. # Create principal collection
  217. if user:
  218. principal_path = "/%s/" % user
  219. with self._storage.acquire_lock("r", user):
  220. principal = next(self._storage.discover(
  221. principal_path, depth="1"), None)
  222. if not principal:
  223. if "W" in self._rights.authorization(user, principal_path):
  224. with self._storage.acquire_lock("w", user):
  225. try:
  226. self._storage.create_collection(principal_path)
  227. except ValueError as e:
  228. logger.warning("Failed to create principal "
  229. "collection %r: %s", user, e)
  230. user = ""
  231. else:
  232. logger.warning("Access to principal path %r denied by "
  233. "rights backend", principal_path)
  234. if self.configuration.get("server", "_internal_server"):
  235. # Verify content length
  236. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  237. if content_length:
  238. max_content_length = self.configuration.get(
  239. "server", "max_content_length")
  240. if max_content_length and content_length > max_content_length:
  241. logger.info("Request body too large: %d", content_length)
  242. return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
  243. if not login or user:
  244. status, headers, answer = function(
  245. environ, base_prefix, path, user)
  246. if (status, headers, answer) == httputils.NOT_ALLOWED:
  247. logger.info("Access to %r denied for %s", path,
  248. repr(user) if user else "anonymous user")
  249. else:
  250. status, headers, answer = httputils.NOT_ALLOWED
  251. if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and
  252. not external_login):
  253. # Unknown or unauthorized user
  254. logger.debug("Asking client for authentication")
  255. status = client.UNAUTHORIZED
  256. realm = self.configuration.get("auth", "realm")
  257. headers = dict(headers)
  258. headers.update({
  259. "WWW-Authenticate":
  260. "Basic realm=\"%s\"" % realm})
  261. return response(status, headers, answer)
  262. def _read_xml_request_body(self, environ):
  263. content = httputils.decode_request(
  264. self.configuration, environ,
  265. httputils.read_raw_request_body(self.configuration, environ))
  266. if not content:
  267. return None
  268. try:
  269. xml_content = DefusedET.fromstring(content)
  270. except ET.ParseError as e:
  271. logger.debug("Request content (Invalid XML):\n%s", content)
  272. raise RuntimeError("Failed to parse XML: %s" % e) from e
  273. if logger.isEnabledFor(logging.DEBUG):
  274. logger.debug("Request content:\n%s",
  275. xmlutils.pretty_xml(xml_content))
  276. return xml_content
  277. def _xml_response(self, xml_content):
  278. if logger.isEnabledFor(logging.DEBUG):
  279. logger.debug("Response content:\n%s",
  280. xmlutils.pretty_xml(xml_content))
  281. f = io.BytesIO()
  282. ET.ElementTree(xml_content).write(f, encoding=self._encoding,
  283. xml_declaration=True)
  284. return f.getvalue()
  285. def _webdav_error_response(self, status, human_tag):
  286. """Generate XML error response."""
  287. headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
  288. content = self._xml_response(xmlutils.webdav_error(human_tag))
  289. return status, headers, content
  290. class Access:
  291. """Helper class to check access rights of an item"""
  292. def __init__(self, rights, user, path):
  293. self._rights = rights
  294. self.user = user
  295. self.path = path
  296. self.parent_path = pathutils.unstrip_path(
  297. posixpath.dirname(pathutils.strip_path(path)), True)
  298. self.permissions = self._rights.authorization(self.user, self.path)
  299. self._parent_permissions = None
  300. @property
  301. def parent_permissions(self):
  302. if self.path == self.parent_path:
  303. return self.permissions
  304. if self._parent_permissions is None:
  305. self._parent_permissions = self._rights.authorization(
  306. self.user, self.parent_path)
  307. return self._parent_permissions
  308. def check(self, permission, item=None):
  309. if permission not in "rw":
  310. raise ValueError("Invalid permission argument: %r" % permission)
  311. if not item:
  312. permissions = permission + permission.upper()
  313. parent_permissions = permission
  314. elif isinstance(item, storage.BaseCollection):
  315. if item.get_meta("tag"):
  316. permissions = permission
  317. else:
  318. permissions = permission.upper()
  319. parent_permissions = ""
  320. else:
  321. permissions = ""
  322. parent_permissions = permission
  323. return bool(rights.intersect(self.permissions, permissions) or (
  324. self.path != self.parent_path and
  325. rights.intersect(self.parent_permissions, parent_permissions)))