__init__.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  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-2018 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 or the built-in server.
  22. """
  23. import base64
  24. import datetime
  25. import io
  26. import logging
  27. import pkg_resources
  28. import pprint
  29. import random
  30. import time
  31. import zlib
  32. from http import client
  33. from xml.etree import ElementTree as ET
  34. from radicale import (
  35. auth, httputils, log, pathutils, rights, storage, web, xmlutils)
  36. from radicale.app.delete import ApplicationDeleteMixin
  37. from radicale.app.get import ApplicationGetMixin
  38. from radicale.app.head import ApplicationHeadMixin
  39. from radicale.app.mkcalendar import ApplicationMkcalendarMixin
  40. from radicale.app.mkcol import ApplicationMkcolMixin
  41. from radicale.app.move import ApplicationMoveMixin
  42. from radicale.app.options import ApplicationOptionsMixin
  43. from radicale.app.propfind import ApplicationPropfindMixin
  44. from radicale.app.proppatch import ApplicationProppatchMixin
  45. from radicale.app.put import ApplicationPutMixin
  46. from radicale.app.report import ApplicationReportMixin
  47. from radicale.log import logger
  48. import posixpath # isort:skip
  49. VERSION = pkg_resources.get_distribution("radicale").version
  50. class Application(
  51. ApplicationDeleteMixin, ApplicationGetMixin, ApplicationHeadMixin,
  52. ApplicationMkcalendarMixin, ApplicationMkcolMixin,
  53. ApplicationMoveMixin, ApplicationOptionsMixin,
  54. ApplicationPropfindMixin, ApplicationProppatchMixin,
  55. ApplicationPutMixin, ApplicationReportMixin):
  56. """WSGI application managing collections."""
  57. def __init__(self, configuration):
  58. """Initialize application."""
  59. super().__init__()
  60. self.configuration = configuration
  61. self.Auth = auth.load(configuration)
  62. self.Collection = storage.load(configuration)
  63. self.Rights = rights.load(configuration)
  64. self.Web = web.load(configuration)
  65. self.encoding = configuration.get("encoding", "request")
  66. def _headers_log(self, environ):
  67. """Sanitize headers for logging."""
  68. request_environ = dict(environ)
  69. # Mask passwords
  70. mask_passwords = self.configuration.getboolean(
  71. "logging", "mask_passwords")
  72. authorization = request_environ.get("HTTP_AUTHORIZATION", "")
  73. if mask_passwords and authorization.startswith("Basic"):
  74. request_environ["HTTP_AUTHORIZATION"] = "Basic **masked**"
  75. if request_environ.get("HTTP_COOKIE"):
  76. request_environ["HTTP_COOKIE"] = "**masked**"
  77. return request_environ
  78. def decode(self, text, environ):
  79. """Try to magically decode ``text`` according to given ``environ``."""
  80. # List of charsets to try
  81. charsets = []
  82. # First append content charset given in the request
  83. content_type = environ.get("CONTENT_TYPE")
  84. if content_type and "charset=" in content_type:
  85. charsets.append(
  86. content_type.split("charset=")[1].split(";")[0].strip())
  87. # Then append default Radicale charset
  88. charsets.append(self.encoding)
  89. # Then append various fallbacks
  90. charsets.append("utf-8")
  91. charsets.append("iso8859-1")
  92. # Try to decode
  93. for charset in charsets:
  94. try:
  95. return text.decode(charset)
  96. except UnicodeDecodeError:
  97. pass
  98. raise UnicodeDecodeError
  99. def __call__(self, environ, start_response):
  100. with log.register_stream(environ["wsgi.errors"]):
  101. try:
  102. status, headers, answers = self._handle_request(environ)
  103. except Exception as e:
  104. try:
  105. method = str(environ["REQUEST_METHOD"])
  106. except Exception:
  107. method = "unknown"
  108. try:
  109. path = str(environ.get("PATH_INFO", ""))
  110. except Exception:
  111. path = ""
  112. logger.error("An exception occurred during %s request on %r: "
  113. "%s", method, path, e, exc_info=True)
  114. status, headers, answer = httputils.INTERNAL_SERVER_ERROR
  115. answer = answer.encode("ascii")
  116. status = "%d %s" % (
  117. status, client.responses.get(status, "Unknown"))
  118. headers = [
  119. ("Content-Length", str(len(answer)))] + list(headers)
  120. answers = [answer]
  121. start_response(status, headers)
  122. return answers
  123. def _handle_request(self, environ):
  124. """Manage a request."""
  125. def response(status, headers=(), answer=None):
  126. headers = dict(headers)
  127. # Set content length
  128. if answer:
  129. if hasattr(answer, "encode"):
  130. logger.debug("Response content:\n%s", answer)
  131. headers["Content-Type"] += "; charset=%s" % self.encoding
  132. answer = answer.encode(self.encoding)
  133. accept_encoding = [
  134. encoding.strip() for encoding in
  135. environ.get("HTTP_ACCEPT_ENCODING", "").split(",")
  136. if encoding.strip()]
  137. if "gzip" in accept_encoding:
  138. zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
  139. answer = zcomp.compress(answer) + zcomp.flush()
  140. headers["Content-Encoding"] = "gzip"
  141. headers["Content-Length"] = str(len(answer))
  142. # Add extra headers set in configuration
  143. if self.configuration.has_section("headers"):
  144. for key in self.configuration.options("headers"):
  145. headers[key] = self.configuration.get("headers", key)
  146. # Start response
  147. time_end = datetime.datetime.now()
  148. status = "%d %s" % (
  149. status, client.responses.get(status, "Unknown"))
  150. logger.info(
  151. "%s response status for %r%s in %.3f seconds: %s",
  152. environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""),
  153. depthinfo, (time_end - time_begin).total_seconds(), status)
  154. # Return response content
  155. return status, list(headers.items()), [answer] if answer else []
  156. remote_host = "unknown"
  157. if environ.get("REMOTE_HOST"):
  158. remote_host = repr(environ["REMOTE_HOST"])
  159. elif environ.get("REMOTE_ADDR"):
  160. remote_host = environ["REMOTE_ADDR"]
  161. if environ.get("HTTP_X_FORWARDED_FOR"):
  162. remote_host = "%r (forwarded by %s)" % (
  163. environ["HTTP_X_FORWARDED_FOR"], remote_host)
  164. remote_useragent = ""
  165. if environ.get("HTTP_USER_AGENT"):
  166. remote_useragent = " using %r" % environ["HTTP_USER_AGENT"]
  167. depthinfo = ""
  168. if environ.get("HTTP_DEPTH"):
  169. depthinfo = " with depth %r" % environ["HTTP_DEPTH"]
  170. time_begin = datetime.datetime.now()
  171. logger.info(
  172. "%s request for %r%s received from %s%s",
  173. environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo,
  174. remote_host, remote_useragent)
  175. headers = pprint.pformat(self._headers_log(environ))
  176. logger.debug("Request headers:\n%s", headers)
  177. # Let reverse proxies overwrite SCRIPT_NAME
  178. if "HTTP_X_SCRIPT_NAME" in environ:
  179. # script_name must be removed from PATH_INFO by the client.
  180. unsafe_base_prefix = environ["HTTP_X_SCRIPT_NAME"]
  181. logger.debug("Script name overwritten by client: %r",
  182. unsafe_base_prefix)
  183. else:
  184. # SCRIPT_NAME is already removed from PATH_INFO, according to the
  185. # WSGI specification.
  186. unsafe_base_prefix = environ.get("SCRIPT_NAME", "")
  187. # Sanitize base prefix
  188. base_prefix = pathutils.sanitize_path(unsafe_base_prefix).rstrip("/")
  189. logger.debug("Sanitized script name: %r", base_prefix)
  190. # Sanitize request URI (a WSGI server indicates with an empty path,
  191. # that the URL targets the application root without a trailing slash)
  192. path = pathutils.sanitize_path(environ.get("PATH_INFO", ""))
  193. logger.debug("Sanitized path: %r", path)
  194. # Get function corresponding to method
  195. function = getattr(self, "do_%s" % environ["REQUEST_METHOD"].upper())
  196. # If "/.well-known" is not available, clients query "/"
  197. if path == "/.well-known" or path.startswith("/.well-known/"):
  198. return response(*httputils.NOT_FOUND)
  199. # Ask authentication backend to check rights
  200. login = password = ""
  201. external_login = self.Auth.get_external_login(environ)
  202. authorization = environ.get("HTTP_AUTHORIZATION", "")
  203. if external_login:
  204. login, password = external_login
  205. login, password = login or "", password or ""
  206. elif authorization.startswith("Basic"):
  207. authorization = authorization[len("Basic"):].strip()
  208. login, password = self.decode(base64.b64decode(
  209. authorization.encode("ascii")), environ).split(":", 1)
  210. user = self.Auth.login(login, password) or "" if login else ""
  211. if user and login == user:
  212. logger.info("Successful login: %r", user)
  213. elif user:
  214. logger.info("Successful login: %r -> %r", login, user)
  215. elif login:
  216. logger.info("Failed login attempt: %r", login)
  217. # Random delay to avoid timing oracles and bruteforce attacks
  218. delay = self.configuration.getfloat("auth", "delay")
  219. if delay > 0:
  220. random_delay = delay * (0.5 + random.random())
  221. logger.debug("Sleeping %.3f seconds", random_delay)
  222. time.sleep(random_delay)
  223. if user and not pathutils.is_safe_path_component(user):
  224. # Prevent usernames like "user/calendar.ics"
  225. logger.info("Refused unsafe username: %r", user)
  226. user = ""
  227. # Create principal collection
  228. if user:
  229. principal_path = "/%s/" % user
  230. if self.Rights.authorized(user, principal_path, "W"):
  231. with self.Collection.acquire_lock("r", user):
  232. principal = next(
  233. self.Collection.discover(principal_path, depth="1"),
  234. None)
  235. if not principal:
  236. with self.Collection.acquire_lock("w", user):
  237. try:
  238. self.Collection.create_collection(principal_path)
  239. except ValueError as e:
  240. logger.warning("Failed to create principal "
  241. "collection %r: %s", user, e)
  242. user = ""
  243. else:
  244. logger.warning("Access to principal path %r denied by "
  245. "rights backend", principal_path)
  246. if self.configuration.getboolean("internal", "internal_server"):
  247. # Verify content length
  248. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  249. if content_length:
  250. max_content_length = self.configuration.getint(
  251. "server", "max_content_length")
  252. if max_content_length and content_length > max_content_length:
  253. logger.info("Request body too large: %d", content_length)
  254. return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
  255. if not login or user:
  256. status, headers, answer = function(
  257. environ, base_prefix, path, user)
  258. if (status, headers, answer) == httputils.NOT_ALLOWED:
  259. logger.info("Access to %r denied for %s", path,
  260. repr(user) if user else "anonymous user")
  261. else:
  262. status, headers, answer = httputils.NOT_ALLOWED
  263. if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and
  264. not external_login):
  265. # Unknown or unauthorized user
  266. logger.debug("Asking client for authentication")
  267. status = client.UNAUTHORIZED
  268. realm = self.configuration.get("auth", "realm")
  269. headers = dict(headers)
  270. headers.update({
  271. "WWW-Authenticate":
  272. "Basic realm=\"%s\"" % realm})
  273. return response(status, headers, answer)
  274. def access(self, user, path, permission, item=None):
  275. if permission not in "rw":
  276. raise ValueError("Invalid permission argument: %r" % permission)
  277. if not item:
  278. permissions = permission + permission.upper()
  279. parent_permissions = permission
  280. elif isinstance(item, storage.BaseCollection):
  281. if item.get_meta("tag"):
  282. permissions = permission
  283. else:
  284. permissions = permission.upper()
  285. parent_permissions = ""
  286. else:
  287. permissions = ""
  288. parent_permissions = permission
  289. if permissions and self.Rights.authorized(user, path, permissions):
  290. return True
  291. if parent_permissions:
  292. parent_path = pathutils.unstrip_path(
  293. posixpath.dirname(pathutils.strip_path(path)), True)
  294. if self.Rights.authorized(user, parent_path, parent_permissions):
  295. return True
  296. return False
  297. def read_raw_content(self, environ):
  298. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  299. if not content_length:
  300. return b""
  301. content = environ["wsgi.input"].read(content_length)
  302. if len(content) < content_length:
  303. raise RuntimeError("Request body too short: %d" % len(content))
  304. return content
  305. def read_content(self, environ):
  306. content = self.decode(self.read_raw_content(environ), environ)
  307. logger.debug("Request content:\n%s", content)
  308. return content
  309. def read_xml_content(self, environ):
  310. content = self.decode(self.read_raw_content(environ), environ)
  311. if not content:
  312. return None
  313. try:
  314. xml_content = ET.fromstring(content)
  315. except ET.ParseError as e:
  316. logger.debug("Request content (Invalid XML):\n%s", content)
  317. raise RuntimeError("Failed to parse XML: %s" % e) from e
  318. if logger.isEnabledFor(logging.DEBUG):
  319. logger.debug("Request content:\n%s",
  320. xmlutils.pretty_xml(xml_content))
  321. return xml_content
  322. def write_xml_content(self, xml_content):
  323. if logger.isEnabledFor(logging.DEBUG):
  324. logger.debug("Response content:\n%s",
  325. xmlutils.pretty_xml(xml_content))
  326. f = io.BytesIO()
  327. ET.ElementTree(xml_content).write(f, encoding=self.encoding,
  328. xml_declaration=True)
  329. return f.getvalue()
  330. def webdav_error_response(self, namespace, name,
  331. status=httputils.WEBDAV_PRECONDITION_FAILED[0]):
  332. """Generate XML error response."""
  333. headers = {"Content-Type": "text/xml; charset=%s" % self.encoding}
  334. content = self.write_xml_content(
  335. xmlutils.webdav_error(namespace, name))
  336. return status, headers, content