__init__.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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. # Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
  7. #
  8. # This library is free software: you can redistribute it and/or modify
  9. # it under the terms of the GNU General Public License as published by
  10. # the Free Software Foundation, either version 3 of the License, or
  11. # (at your option) any later version.
  12. #
  13. # This library is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU General Public License
  19. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  20. """
  21. Radicale WSGI application.
  22. Can be used with an external WSGI server (see ``radicale.application()``) or
  23. the built-in server (see ``radicale.server`` module).
  24. """
  25. import base64
  26. import datetime
  27. import pprint
  28. import random
  29. import time
  30. import zlib
  31. from http import client
  32. from typing import Iterable, List, Mapping, Tuple, Union
  33. from radicale import config, httputils, log, pathutils, types
  34. from radicale.app.base import ApplicationBase
  35. from radicale.app.delete import ApplicationPartDelete
  36. from radicale.app.get import ApplicationPartGet
  37. from radicale.app.head import ApplicationPartHead
  38. from radicale.app.mkcalendar import ApplicationPartMkcalendar
  39. from radicale.app.mkcol import ApplicationPartMkcol
  40. from radicale.app.move import ApplicationPartMove
  41. from radicale.app.options import ApplicationPartOptions
  42. from radicale.app.post import ApplicationPartPost
  43. from radicale.app.propfind import ApplicationPartPropfind
  44. from radicale.app.proppatch import ApplicationPartProppatch
  45. from radicale.app.put import ApplicationPartPut
  46. from radicale.app.report import ApplicationPartReport
  47. from radicale.auth import AuthContext
  48. from radicale.log import logger
  49. # Combination of types.WSGIStartResponse and WSGI application return value
  50. _IntermediateResponse = Tuple[str, List[Tuple[str, str]], Iterable[bytes]]
  51. class Application(ApplicationPartDelete, ApplicationPartHead,
  52. ApplicationPartGet, ApplicationPartMkcalendar,
  53. ApplicationPartMkcol, ApplicationPartMove,
  54. ApplicationPartOptions, ApplicationPartPropfind,
  55. ApplicationPartProppatch, ApplicationPartPost,
  56. ApplicationPartPut, ApplicationPartReport, ApplicationBase):
  57. """WSGI application."""
  58. _mask_passwords: bool
  59. _auth_delay: float
  60. _internal_server: bool
  61. _max_content_length: int
  62. _auth_realm: str
  63. _auth_type: str
  64. _web_type: str
  65. _script_name: str
  66. _extra_headers: Mapping[str, str]
  67. def __init__(self, configuration: config.Configuration) -> None:
  68. """Initialize Application.
  69. ``configuration`` see ``radicale.config`` module.
  70. The ``configuration`` must not change during the lifetime of
  71. this object, it is kept as an internal reference.
  72. """
  73. super().__init__(configuration)
  74. self._mask_passwords = configuration.get("logging", "mask_passwords")
  75. self._bad_put_request_content = configuration.get("logging", "bad_put_request_content")
  76. self._request_header_on_debug = configuration.get("logging", "request_header_on_debug")
  77. self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
  78. self._auth_delay = configuration.get("auth", "delay")
  79. self._auth_type = configuration.get("auth", "type")
  80. self._web_type = configuration.get("web", "type")
  81. self._internal_server = configuration.get("server", "_internal_server")
  82. self._script_name = configuration.get("server", "script_name")
  83. if self._script_name:
  84. if self._script_name[0] != "/":
  85. logger.error("server.script_name must start with '/': %r", self._script_name)
  86. raise RuntimeError("server.script_name option has to start with '/'")
  87. else:
  88. if self._script_name.endswith("/"):
  89. logger.error("server.script_name must not end with '/': %r", self._script_name)
  90. raise RuntimeError("server.script_name option must not end with '/'")
  91. else:
  92. logger.info("Provided script name to strip from URI if called by reverse proxy: %r", self._script_name)
  93. else:
  94. logger.info("Default script name to strip from URI if called by reverse proxy is taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME")
  95. self._max_content_length = configuration.get(
  96. "server", "max_content_length")
  97. self._auth_realm = configuration.get("auth", "realm")
  98. self._permit_delete_collection = configuration.get("rights", "permit_delete_collection")
  99. logger.info("permit delete of collection: %s", self._permit_delete_collection)
  100. self._permit_overwrite_collection = configuration.get("rights", "permit_overwrite_collection")
  101. logger.info("permit overwrite of collection: %s", self._permit_overwrite_collection)
  102. self._extra_headers = dict()
  103. for key in self.configuration.options("headers"):
  104. self._extra_headers[key] = configuration.get("headers", key)
  105. self._strict_preconditions = configuration.get("storage", "strict_preconditions")
  106. logger.info("strict preconditions check: %s", self._strict_preconditions)
  107. def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron:
  108. """Mask passwords and cookies."""
  109. headers = dict(environ)
  110. if (self._mask_passwords and
  111. headers.get("HTTP_AUTHORIZATION", "").startswith("Basic")):
  112. headers["HTTP_AUTHORIZATION"] = "Basic **masked**"
  113. if headers.get("HTTP_COOKIE"):
  114. headers["HTTP_COOKIE"] = "**masked**"
  115. return headers
  116. def __call__(self, environ: types.WSGIEnviron, start_response:
  117. types.WSGIStartResponse) -> Iterable[bytes]:
  118. with log.register_stream(environ["wsgi.errors"]):
  119. try:
  120. status_text, headers, answers = self._handle_request(environ)
  121. except Exception as e:
  122. logger.error("An exception occurred during %s request on %r: "
  123. "%s", environ.get("REQUEST_METHOD", "unknown"),
  124. environ.get("PATH_INFO", ""), e, exc_info=True)
  125. # Make minimal response
  126. status, raw_headers, raw_answer = (
  127. httputils.INTERNAL_SERVER_ERROR)
  128. assert isinstance(raw_answer, str)
  129. answer = raw_answer.encode("ascii")
  130. status_text = "%d %s" % (
  131. status, client.responses.get(status, "Unknown"))
  132. headers = [*raw_headers, ("Content-Length", str(len(answer)))]
  133. answers = [answer]
  134. start_response(status_text, headers)
  135. if environ.get("REQUEST_METHOD") == "HEAD":
  136. return []
  137. return answers
  138. def _handle_request(self, environ: types.WSGIEnviron
  139. ) -> _IntermediateResponse:
  140. time_begin = datetime.datetime.now()
  141. request_method = environ["REQUEST_METHOD"].upper()
  142. unsafe_path = environ.get("PATH_INFO", "")
  143. https = environ.get("HTTPS", "")
  144. context = AuthContext()
  145. """Manage a request."""
  146. def response(status: int, headers: types.WSGIResponseHeaders,
  147. answer: Union[None, str, bytes]) -> _IntermediateResponse:
  148. """Helper to create response from internal types.WSGIResponse"""
  149. headers = dict(headers)
  150. content_encoding = "plain"
  151. # Set content length
  152. answers = []
  153. if answer is not None:
  154. if isinstance(answer, str):
  155. if self._response_content_on_debug:
  156. logger.debug("Response content:\n%s", answer)
  157. else:
  158. logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug")
  159. headers["Content-Type"] += "; charset=%s" % self._encoding
  160. answer = answer.encode(self._encoding)
  161. accept_encoding = [
  162. encoding.strip() for encoding in
  163. environ.get("HTTP_ACCEPT_ENCODING", "").split(",")
  164. if encoding.strip()]
  165. if "gzip" in accept_encoding:
  166. zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
  167. answer = zcomp.compress(answer) + zcomp.flush()
  168. headers["Content-Encoding"] = "gzip"
  169. content_encoding = "gzip"
  170. headers["Content-Length"] = str(len(answer))
  171. answers.append(answer)
  172. # Add extra headers set in configuration
  173. headers.update(self._extra_headers)
  174. # Start response
  175. time_end = datetime.datetime.now()
  176. status_text = "%d %s" % (
  177. status, client.responses.get(status, "Unknown"))
  178. if answer is not None:
  179. logger.info("%s response status for %r%s in %.3f seconds %s %s bytes: %s",
  180. request_method, unsafe_path, depthinfo,
  181. (time_end - time_begin).total_seconds(), content_encoding, str(len(answer)), status_text)
  182. else:
  183. logger.info("%s response status for %r%s in %.3f seconds: %s",
  184. request_method, unsafe_path, depthinfo,
  185. (time_end - time_begin).total_seconds(), status_text)
  186. # Return response content
  187. return status_text, list(headers.items()), answers
  188. reverse_proxy = False
  189. remote_host = "unknown"
  190. if environ.get("REMOTE_HOST"):
  191. remote_host = repr(environ["REMOTE_HOST"])
  192. if environ.get("REMOTE_ADDR"):
  193. if remote_host == 'unknown':
  194. remote_host = environ["REMOTE_ADDR"]
  195. context.remote_addr = environ["REMOTE_ADDR"]
  196. if environ.get("HTTP_X_FORWARDED_FOR"):
  197. reverse_proxy = True
  198. remote_host = "%s (forwarded for %r)" % (
  199. remote_host, environ["HTTP_X_FORWARDED_FOR"])
  200. if environ.get("HTTP_X_REMOTE_ADDR"):
  201. context.x_remote_addr = environ["HTTP_X_REMOTE_ADDR"]
  202. if environ.get("HTTP_X_FORWARDED_HOST") or environ.get("HTTP_X_FORWARDED_PROTO") or environ.get("HTTP_X_FORWARDED_SERVER"):
  203. reverse_proxy = True
  204. remote_useragent = ""
  205. if environ.get("HTTP_USER_AGENT"):
  206. remote_useragent = " using %r" % environ["HTTP_USER_AGENT"]
  207. depthinfo = ""
  208. if environ.get("HTTP_DEPTH"):
  209. depthinfo = " with depth %r" % environ["HTTP_DEPTH"]
  210. if https:
  211. https_info = " " + environ.get("SSL_PROTOCOL", "") + " " + environ.get("SSL_CIPHER", "")
  212. else:
  213. https_info = ""
  214. logger.info("%s request for %r%s received from %s%s%s",
  215. request_method, unsafe_path, depthinfo,
  216. remote_host, remote_useragent, https_info)
  217. if self._request_header_on_debug:
  218. logger.debug("Request header:\n%s",
  219. pprint.pformat(self._scrub_headers(environ)))
  220. else:
  221. logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug")
  222. # SCRIPT_NAME is already removed from PATH_INFO, according to the
  223. # WSGI specification.
  224. # Reverse proxies can overwrite SCRIPT_NAME with X-SCRIPT-NAME header
  225. if self._script_name and (reverse_proxy is True):
  226. base_prefix_src = "config"
  227. base_prefix = self._script_name
  228. else:
  229. base_prefix_src = ("HTTP_X_SCRIPT_NAME" if "HTTP_X_SCRIPT_NAME" in
  230. environ else "SCRIPT_NAME")
  231. base_prefix = environ.get(base_prefix_src, "")
  232. if base_prefix and base_prefix[0] != "/":
  233. logger.error("Base prefix (from %s) must start with '/': %r",
  234. base_prefix_src, base_prefix)
  235. if base_prefix_src == "HTTP_X_SCRIPT_NAME":
  236. return response(*httputils.BAD_REQUEST)
  237. return response(*httputils.INTERNAL_SERVER_ERROR)
  238. if base_prefix.endswith("/"):
  239. logger.warning("Base prefix (from %s) must not end with '/': %r",
  240. base_prefix_src, base_prefix)
  241. base_prefix = base_prefix.rstrip("/")
  242. if base_prefix:
  243. logger.debug("Base prefix (from %s): %r", base_prefix_src, base_prefix)
  244. # Sanitize request URI (a WSGI server indicates with an empty path,
  245. # that the URL targets the application root without a trailing slash)
  246. path = pathutils.sanitize_path(unsafe_path)
  247. logger.debug("Sanitized path: %r", path)
  248. if (reverse_proxy is True) and (len(base_prefix) > 0):
  249. if path.startswith(base_prefix):
  250. path_new = path.removeprefix(base_prefix)
  251. logger.debug("Called by reverse proxy, remove base prefix %r from path: %r => %r", base_prefix, path, path_new)
  252. path = path_new
  253. else:
  254. if self._auth_type in ['remote_user', 'http_x_remote_user'] and self._web_type == 'internal':
  255. logger.warning("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching (may cause authentication issues using internal WebUI)", base_prefix, path)
  256. else:
  257. logger.debug("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching", base_prefix, path)
  258. # Get function corresponding to method
  259. function = getattr(self, "do_%s" % request_method, None)
  260. if not function:
  261. return response(*httputils.METHOD_NOT_ALLOWED)
  262. # Redirect all "…/.well-known/{caldav,carddav}" paths to "/".
  263. # This shouldn't be necessary but some clients like TbSync require it.
  264. # Status must be MOVED PERMANENTLY using FOUND causes problems
  265. if (path.rstrip("/").endswith("/.well-known/caldav") or
  266. path.rstrip("/").endswith("/.well-known/carddav")):
  267. return response(*httputils.redirect(
  268. base_prefix + "/", client.MOVED_PERMANENTLY))
  269. # Return NOT FOUND for all other paths containing ".well-known"
  270. if path.endswith("/.well-known") or "/.well-known/" in path:
  271. return response(*httputils.NOT_FOUND)
  272. # Ask authentication backend to check rights
  273. login = password = ""
  274. external_login = self._auth.get_external_login(environ)
  275. authorization = environ.get("HTTP_AUTHORIZATION", "")
  276. if external_login:
  277. login, password = external_login
  278. login, password = login or "", password or ""
  279. elif authorization.startswith("Basic"):
  280. authorization = authorization[len("Basic"):].strip()
  281. login, password = httputils.decode_request(
  282. self.configuration, environ, base64.b64decode(
  283. authorization.encode("ascii"))).split(":", 1)
  284. (user, info) = self._auth.login(login, password, context) or ("", "") if login else ("", "")
  285. if self.configuration.get("auth", "type") == "ldap":
  286. try:
  287. logger.debug("Groups received from LDAP: %r", ",".join(self._auth._ldap_groups))
  288. self._rights._user_groups = self._auth._ldap_groups
  289. except AttributeError:
  290. pass
  291. if user and login == user:
  292. logger.info("Successful login: %r (%s)", user, info)
  293. elif user:
  294. logger.info("Successful login: %r -> %r (%s)", login, user, info)
  295. elif login:
  296. logger.warning("Failed login attempt from %s: %r (%s)",
  297. remote_host, login, info)
  298. # Random delay to avoid timing oracles and bruteforce attacks
  299. if self._auth_delay > 0:
  300. random_delay = self._auth_delay * (0.5 + random.random())
  301. logger.debug("Failed login, sleeping random: %.3f sec", random_delay)
  302. time.sleep(random_delay)
  303. if user and not pathutils.is_safe_path_component(user):
  304. # Prevent usernames like "user/calendar.ics"
  305. logger.info("Refused unsafe username: %r", user)
  306. user = ""
  307. # Create principal collection
  308. if user:
  309. principal_path = "/%s/" % user
  310. with self._storage.acquire_lock("r", user):
  311. principal = next(iter(self._storage.discover(
  312. principal_path, depth="1")), None)
  313. if not principal:
  314. if "W" in self._rights.authorization(user, principal_path):
  315. with self._storage.acquire_lock("w", user):
  316. try:
  317. new_coll, _, _ = self._storage.create_collection(principal_path)
  318. if new_coll:
  319. jsn_coll = self.configuration.get("storage", "predefined_collections")
  320. for (name_coll, props) in jsn_coll.items():
  321. try:
  322. self._storage.create_collection(principal_path + name_coll, props=props)
  323. except ValueError as e:
  324. logger.warning("Failed to create predefined collection %r: %s", name_coll, e)
  325. except ValueError as e:
  326. logger.warning("Failed to create principal "
  327. "collection %r: %s", user, e)
  328. user = ""
  329. else:
  330. logger.warning("Access to principal path %r denied by "
  331. "rights backend", principal_path)
  332. if self._internal_server:
  333. # Verify content length
  334. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  335. if content_length:
  336. if (self._max_content_length > 0 and
  337. content_length > self._max_content_length):
  338. logger.info("Request body too large: %d", content_length)
  339. return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
  340. if not login or user:
  341. status, headers, answer = function(
  342. environ, base_prefix, path, user)
  343. if (status, headers, answer) == httputils.NOT_ALLOWED:
  344. logger.info("Access to %r denied for %s", path,
  345. repr(user) if user else "anonymous user")
  346. else:
  347. status, headers, answer = httputils.NOT_ALLOWED
  348. if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and
  349. not external_login):
  350. # Unknown or unauthorized user
  351. logger.debug("Asking client for authentication")
  352. status = client.UNAUTHORIZED
  353. headers = dict(headers)
  354. headers.update({
  355. "WWW-Authenticate":
  356. "Basic realm=\"%s\"" % self._auth_realm})
  357. return response(status, headers, answer)