__init__.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. # This file is part of Radicale - CalDAV and CardDAV 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 pprint
  27. import random
  28. import time
  29. import zlib
  30. from http import client
  31. from typing import Iterable, List, Mapping, Tuple, Union
  32. from radicale import config, httputils, log, pathutils, types
  33. from radicale.app.base import ApplicationBase
  34. from radicale.app.delete import ApplicationPartDelete
  35. from radicale.app.get import ApplicationPartGet
  36. from radicale.app.head import ApplicationPartHead
  37. from radicale.app.mkcalendar import ApplicationPartMkcalendar
  38. from radicale.app.mkcol import ApplicationPartMkcol
  39. from radicale.app.move import ApplicationPartMove
  40. from radicale.app.options import ApplicationPartOptions
  41. from radicale.app.post import ApplicationPartPost
  42. from radicale.app.propfind import ApplicationPartPropfind
  43. from radicale.app.proppatch import ApplicationPartProppatch
  44. from radicale.app.put import ApplicationPartPut
  45. from radicale.app.report import ApplicationPartReport
  46. from radicale.log import logger
  47. # Combination of types.WSGIStartResponse and WSGI application return value
  48. _IntermediateResponse = Tuple[str, List[Tuple[str, str]], Iterable[bytes]]
  49. class Application(ApplicationPartDelete, ApplicationPartHead,
  50. ApplicationPartGet, ApplicationPartMkcalendar,
  51. ApplicationPartMkcol, ApplicationPartMove,
  52. ApplicationPartOptions, ApplicationPartPropfind,
  53. ApplicationPartProppatch, ApplicationPartPost,
  54. ApplicationPartPut, ApplicationPartReport, ApplicationBase):
  55. """WSGI application."""
  56. _mask_passwords: bool
  57. _auth_delay: float
  58. _internal_server: bool
  59. _max_content_length: int
  60. _auth_realm: str
  61. _extra_headers: Mapping[str, str]
  62. def __init__(self, configuration: config.Configuration) -> None:
  63. """Initialize Application.
  64. ``configuration`` see ``radicale.config`` module.
  65. The ``configuration`` must not change during the lifetime of
  66. this object, it is kept as an internal reference.
  67. """
  68. super().__init__(configuration)
  69. self._mask_passwords = configuration.get("logging", "mask_passwords")
  70. self._auth_delay = configuration.get("auth", "delay")
  71. self._internal_server = configuration.get("server", "_internal_server")
  72. self._max_content_length = configuration.get(
  73. "server", "max_content_length")
  74. self._auth_realm = configuration.get("auth", "realm")
  75. self._extra_headers = dict()
  76. for key in self.configuration.options("headers"):
  77. self._extra_headers[key] = configuration.get("headers", key)
  78. def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron:
  79. """Mask passwords and cookies."""
  80. headers = dict(environ)
  81. if (self._mask_passwords and
  82. headers.get("HTTP_AUTHORIZATION", "").startswith("Basic")):
  83. headers["HTTP_AUTHORIZATION"] = "Basic **masked**"
  84. if headers.get("HTTP_COOKIE"):
  85. headers["HTTP_COOKIE"] = "**masked**"
  86. return headers
  87. def __call__(self, environ: types.WSGIEnviron, start_response:
  88. types.WSGIStartResponse) -> Iterable[bytes]:
  89. with log.register_stream(environ["wsgi.errors"]):
  90. try:
  91. status_text, headers, answers = self._handle_request(environ)
  92. except Exception as e:
  93. logger.error("An exception occurred during %s request on %r: "
  94. "%s", environ.get("REQUEST_METHOD", "unknown"),
  95. environ.get("PATH_INFO", ""), e, exc_info=True)
  96. # Make minimal response
  97. status, raw_headers, raw_answer = (
  98. httputils.INTERNAL_SERVER_ERROR)
  99. assert isinstance(raw_answer, str)
  100. answer = raw_answer.encode("ascii")
  101. status_text = "%d %s" % (
  102. status, client.responses.get(status, "Unknown"))
  103. headers = [*raw_headers, ("Content-Length", str(len(answer)))]
  104. answers = [answer]
  105. start_response(status_text, headers)
  106. return answers
  107. def _handle_request(self, environ: types.WSGIEnviron
  108. ) -> _IntermediateResponse:
  109. """Manage a request."""
  110. def response(status: int, headers: types.WSGIResponseHeaders,
  111. answer: Union[None, str, bytes]) -> _IntermediateResponse:
  112. """Helper to create response from internal types.WSGIResponse"""
  113. headers = dict(headers)
  114. # Set content length
  115. answers = []
  116. if answer is not None:
  117. if isinstance(answer, str):
  118. logger.debug("Response content:\n%s", answer)
  119. headers["Content-Type"] += "; charset=%s" % self._encoding
  120. answer = answer.encode(self._encoding)
  121. accept_encoding = [
  122. encoding.strip() for encoding in
  123. environ.get("HTTP_ACCEPT_ENCODING", "").split(",")
  124. if encoding.strip()]
  125. if "gzip" in accept_encoding:
  126. zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
  127. answer = zcomp.compress(answer) + zcomp.flush()
  128. headers["Content-Encoding"] = "gzip"
  129. headers["Content-Length"] = str(len(answer))
  130. answers.append(answer)
  131. # Add extra headers set in configuration
  132. headers.update(self._extra_headers)
  133. # Start response
  134. time_end = datetime.datetime.now()
  135. status_text = "%d %s" % (
  136. status, client.responses.get(status, "Unknown"))
  137. logger.info(
  138. "%s response status for %r%s in %.3f seconds: %s",
  139. environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""),
  140. depthinfo, (time_end - time_begin).total_seconds(),
  141. status_text)
  142. # Return response content
  143. return status_text, list(headers.items()), answers
  144. remote_host = "unknown"
  145. if environ.get("REMOTE_HOST"):
  146. remote_host = repr(environ["REMOTE_HOST"])
  147. elif environ.get("REMOTE_ADDR"):
  148. remote_host = environ["REMOTE_ADDR"]
  149. if environ.get("HTTP_X_FORWARDED_FOR"):
  150. remote_host = "%s (forwarded for %r)" % (
  151. remote_host, environ["HTTP_X_FORWARDED_FOR"])
  152. remote_useragent = ""
  153. if environ.get("HTTP_USER_AGENT"):
  154. remote_useragent = " using %r" % environ["HTTP_USER_AGENT"]
  155. depthinfo = ""
  156. if environ.get("HTTP_DEPTH"):
  157. depthinfo = " with depth %r" % environ["HTTP_DEPTH"]
  158. time_begin = datetime.datetime.now()
  159. logger.info(
  160. "%s request for %r%s received from %s%s",
  161. environ["REQUEST_METHOD"], environ.get("PATH_INFO", ""), depthinfo,
  162. remote_host, remote_useragent)
  163. logger.debug("Request headers:\n%s",
  164. pprint.pformat(self._scrub_headers(environ)))
  165. # Let reverse proxies overwrite SCRIPT_NAME
  166. if "HTTP_X_SCRIPT_NAME" in environ:
  167. # script_name must be removed from PATH_INFO by the client.
  168. unsafe_base_prefix = environ["HTTP_X_SCRIPT_NAME"]
  169. logger.debug("Script name overwritten by client: %r",
  170. unsafe_base_prefix)
  171. else:
  172. # SCRIPT_NAME is already removed from PATH_INFO, according to the
  173. # WSGI specification.
  174. unsafe_base_prefix = environ.get("SCRIPT_NAME", "")
  175. # Sanitize base prefix
  176. base_prefix = pathutils.sanitize_path(unsafe_base_prefix).rstrip("/")
  177. logger.debug("Sanitized script name: %r", base_prefix)
  178. # Sanitize request URI (a WSGI server indicates with an empty path,
  179. # that the URL targets the application root without a trailing slash)
  180. path = pathutils.sanitize_path(environ.get("PATH_INFO", ""))
  181. logger.debug("Sanitized path: %r", path)
  182. # Get function corresponding to method
  183. function = getattr(
  184. self, "do_%s" % environ["REQUEST_METHOD"].upper(), None)
  185. if not function:
  186. return response(*httputils.METHOD_NOT_ALLOWED)
  187. # If "/.well-known" is not available, clients query "/"
  188. if path == "/.well-known" or path.startswith("/.well-known/"):
  189. return response(*httputils.NOT_FOUND)
  190. # Ask authentication backend to check rights
  191. login = password = ""
  192. external_login = self._auth.get_external_login(environ)
  193. authorization = environ.get("HTTP_AUTHORIZATION", "")
  194. if external_login:
  195. login, password = external_login
  196. login, password = login or "", password or ""
  197. elif authorization.startswith("Basic"):
  198. authorization = authorization[len("Basic"):].strip()
  199. login, password = httputils.decode_request(
  200. self.configuration, environ, base64.b64decode(
  201. authorization.encode("ascii"))).split(":", 1)
  202. user = self._auth.login(login, password) or "" if login else ""
  203. if user and login == user:
  204. logger.info("Successful login: %r", user)
  205. elif user:
  206. logger.info("Successful login: %r -> %r", login, user)
  207. elif login:
  208. logger.warning("Failed login attempt from %s: %r",
  209. remote_host, login)
  210. # Random delay to avoid timing oracles and bruteforce attacks
  211. if self._auth_delay > 0:
  212. random_delay = self._auth_delay * (0.5 + random.random())
  213. logger.debug("Sleeping %.3f seconds", random_delay)
  214. time.sleep(random_delay)
  215. if user and not pathutils.is_safe_path_component(user):
  216. # Prevent usernames like "user/calendar.ics"
  217. logger.info("Refused unsafe username: %r", user)
  218. user = ""
  219. # Create principal collection
  220. if user:
  221. principal_path = "/%s/" % user
  222. with self._storage.acquire_lock("r", user):
  223. principal = next(iter(self._storage.discover(
  224. principal_path, depth="1")), None)
  225. if not principal:
  226. if "W" in self._rights.authorization(user, principal_path):
  227. with self._storage.acquire_lock("w", user):
  228. try:
  229. self._storage.create_collection(principal_path)
  230. except ValueError as e:
  231. logger.warning("Failed to create principal "
  232. "collection %r: %s", user, e)
  233. user = ""
  234. else:
  235. logger.warning("Access to principal path %r denied by "
  236. "rights backend", principal_path)
  237. if self._internal_server:
  238. # Verify content length
  239. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  240. if content_length:
  241. if (self._max_content_length > 0 and
  242. content_length > self._max_content_length):
  243. logger.info("Request body too large: %d", content_length)
  244. return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
  245. if not login or user:
  246. status, headers, answer = function(
  247. environ, base_prefix, path, user)
  248. if (status, headers, answer) == httputils.NOT_ALLOWED:
  249. logger.info("Access to %r denied for %s", path,
  250. repr(user) if user else "anonymous user")
  251. else:
  252. status, headers, answer = httputils.NOT_ALLOWED
  253. if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and
  254. not external_login):
  255. # Unknown or unauthorized user
  256. logger.debug("Asking client for authentication")
  257. status = client.UNAUTHORIZED
  258. headers = dict(headers)
  259. headers.update({
  260. "WWW-Authenticate":
  261. "Basic realm=\"%s\"" % self._auth_realm})
  262. return response(status, headers, answer)