__init__.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  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 cProfile
  27. import datetime
  28. import io
  29. import logging
  30. import pprint
  31. import pstats
  32. import random
  33. import time
  34. import zlib
  35. from http import client
  36. from typing import Iterable, List, Mapping, Tuple, Union
  37. from radicale import config, httputils, log, pathutils, types, utils
  38. from radicale.app.base import ApplicationBase
  39. from radicale.app.delete import ApplicationPartDelete
  40. from radicale.app.get import ApplicationPartGet
  41. from radicale.app.head import ApplicationPartHead
  42. from radicale.app.mkcalendar import ApplicationPartMkcalendar
  43. from radicale.app.mkcol import ApplicationPartMkcol
  44. from radicale.app.move import ApplicationPartMove
  45. from radicale.app.options import ApplicationPartOptions
  46. from radicale.app.post import ApplicationPartPost
  47. from radicale.app.propfind import ApplicationPartPropfind
  48. from radicale.app.proppatch import ApplicationPartProppatch
  49. from radicale.app.put import ApplicationPartPut
  50. from radicale.app.report import ApplicationPartReport
  51. from radicale.auth import AuthContext
  52. from radicale.log import logger
  53. # Combination of types.WSGIStartResponse and WSGI application return value
  54. _IntermediateResponse = Tuple[str, List[Tuple[str, str]], Iterable[bytes]]
  55. REQUEST_METHODS = ["DELETE", "GET", "HEAD", "MKCALENDAR", "MKCOL", "MOVE", "OPTIONS", "POST", "PROPFIND", "PROPPATCH", "PUT", "REPORT"]
  56. class Application(ApplicationPartDelete, ApplicationPartHead,
  57. ApplicationPartGet, ApplicationPartMkcalendar,
  58. ApplicationPartMkcol, ApplicationPartMove,
  59. ApplicationPartOptions, ApplicationPartPropfind,
  60. ApplicationPartProppatch, ApplicationPartPost,
  61. ApplicationPartPut, ApplicationPartReport, ApplicationBase):
  62. """WSGI application."""
  63. _mask_passwords: bool
  64. _auth_delay: float
  65. _internal_server: bool
  66. _max_content_length: int
  67. _max_resource_size: int
  68. _auth_realm: str
  69. _auth_type: str
  70. _web_type: str
  71. _script_name: str
  72. _extra_headers: Mapping[str, str]
  73. _profiling_per_request: bool = False
  74. _profiling_per_request_method: bool = False
  75. profiler_per_request_method: dict[str, cProfile.Profile] = {}
  76. profiler_per_request_method_counter: dict[str, int] = {}
  77. profiler_per_request_method_starttime: datetime.datetime
  78. profiler_per_request_method_logtime: datetime.datetime
  79. def __init__(self, configuration: config.Configuration) -> None:
  80. """Initialize Application.
  81. ``configuration`` see ``radicale.config`` module.
  82. The ``configuration`` must not change during the lifetime of
  83. this object, it is kept as an internal reference.
  84. """
  85. super().__init__(configuration)
  86. self._mask_passwords = configuration.get("logging", "mask_passwords")
  87. self._max_content_length = configuration.get("server", "max_content_length")
  88. self._max_resource_size = configuration.get("server", "max_resource_size")
  89. logger.info("max_content_length set to: %d bytes (%sbytes)", self._max_content_length, utils.format_unit(self._max_content_length, binary=True))
  90. if (self._max_resource_size > (self._max_content_length * 0.8)):
  91. max_resource_size_limited = int(self._max_content_length * 0.8)
  92. logger.warning("max_resource_size set to: %d bytes (%sbytes) (capped from %d to 80%% of max_content_length)", max_resource_size_limited, utils.format_unit(max_resource_size_limited, binary=True), self._max_resource_size)
  93. self._max_resource_size = max_resource_size_limited
  94. else:
  95. logger.info("max_resource_size set to: %d bytes (%sbytes)", self._max_resource_size, utils.format_unit(self._max_resource_size, binary=True))
  96. self._bad_put_request_content = configuration.get("logging", "bad_put_request_content")
  97. logger.info("log bad put request content: %s", self._bad_put_request_content)
  98. self._request_header_on_debug = configuration.get("logging", "request_header_on_debug")
  99. self._request_content_on_debug = configuration.get("logging", "request_content_on_debug")
  100. self._response_header_on_debug = configuration.get("logging", "response_header_on_debug")
  101. self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
  102. logger.debug("log request header on debug: %s", self._request_header_on_debug)
  103. logger.debug("log request content on debug: %s", self._request_content_on_debug)
  104. logger.debug("log response header on debug: %s", self._response_header_on_debug)
  105. logger.debug("log response content on debug: %s", self._response_content_on_debug)
  106. self._auth_delay = configuration.get("auth", "delay")
  107. self._auth_type = configuration.get("auth", "type")
  108. self._web_type = configuration.get("web", "type")
  109. self._internal_server = configuration.get("server", "_internal_server")
  110. self._script_name = configuration.get("server", "script_name")
  111. if self._script_name:
  112. if self._script_name[0] != "/":
  113. logger.error("server.script_name must start with '/': %r", self._script_name)
  114. raise RuntimeError("server.script_name option has to start with '/'")
  115. else:
  116. if self._script_name.endswith("/"):
  117. logger.error("server.script_name must not end with '/': %r", self._script_name)
  118. raise RuntimeError("server.script_name option must not end with '/'")
  119. else:
  120. logger.info("Provided script name to strip from URI if called by reverse proxy: %r", self._script_name)
  121. else:
  122. logger.info("Default script name to strip from URI if called by reverse proxy is taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME")
  123. self._max_content_length = configuration.get(
  124. "server", "max_content_length")
  125. self._auth_realm = configuration.get("auth", "realm")
  126. self._permit_delete_collection = configuration.get("rights", "permit_delete_collection")
  127. logger.info("permit delete of collection: %s", self._permit_delete_collection)
  128. self._permit_overwrite_collection = configuration.get("rights", "permit_overwrite_collection")
  129. logger.info("permit overwrite of collection: %s", self._permit_overwrite_collection)
  130. self._extra_headers = dict()
  131. for key in self.configuration.options("headers"):
  132. self._extra_headers[key] = configuration.get("headers", key)
  133. self._strict_preconditions = configuration.get("storage", "strict_preconditions")
  134. logger.info("strict preconditions check: %s", self._strict_preconditions)
  135. # Profiling options
  136. self._profiling = configuration.get("logging", "profiling")
  137. self._profiling_per_request_min_duration = configuration.get("logging", "profiling_per_request_min_duration")
  138. self._profiling_per_request_header = configuration.get("logging", "profiling_per_request_header")
  139. self._profiling_per_request_xml = configuration.get("logging", "profiling_per_request_xml")
  140. self._profiling_per_request_method_interval = configuration.get("logging", "profiling_per_request_method_interval")
  141. self._profiling_top_x_functions = configuration.get("logging", "profiling_top_x_functions")
  142. if self._profiling in config.PROFILING:
  143. logger.info("profiling: %r", self._profiling)
  144. if self._profiling == "per_request":
  145. self._profiling_per_request = True
  146. elif self._profiling == "per_request_method":
  147. self._profiling_per_request_method = True
  148. if self._profiling_per_request or self._profiling_per_request_method:
  149. logger.info("profiling top X functions: %d", self._profiling_top_x_functions)
  150. if self._profiling_per_request:
  151. logger.info("profiling per request minimum duration: %d (below are skipped)", self._profiling_per_request_min_duration)
  152. logger.info("profiling per request header: %s", self._profiling_per_request_header)
  153. logger.info("profiling per request xml : %s", self._profiling_per_request_xml)
  154. if self._profiling_per_request_method:
  155. logger.info("profiling per request method interval: %d seconds", self._profiling_per_request_method_interval)
  156. # Profiling per request method initialization
  157. if self._profiling_per_request_method:
  158. for method in REQUEST_METHODS:
  159. self.profiler_per_request_method[method] = cProfile.Profile()
  160. self.profiler_per_request_method_counter[method] = False
  161. self.profiler_per_request_method_starttime = datetime.datetime.now()
  162. self.profiler_per_request_method_logtime = self.profiler_per_request_method_starttime
  163. def __del__(self) -> None:
  164. """Shutdown application."""
  165. if self._profiling_per_request_method:
  166. # Profiling since startup
  167. self._profiler_per_request_method(True)
  168. def _profiler_per_request_method(self, shutdown: bool = False) -> None:
  169. """Display profiler data per method."""
  170. profiler_timedelta_start = (datetime.datetime.now() - self.profiler_per_request_method_starttime).total_seconds()
  171. for method in REQUEST_METHODS:
  172. if self.profiler_per_request_method_counter[method] > 0:
  173. s = io.StringIO()
  174. s.write("**Profiling statistics BEGIN**\n")
  175. stats = pstats.Stats(self.profiler_per_request_method[method], stream=s).sort_stats('cumulative')
  176. stats.print_stats(self._profiling_top_x_functions) # Print top X functions
  177. s.write("**Profiling statistics END**\n")
  178. logger.info("Profiling data per request method %s after %d seconds and %d requests:\n%s", method, profiler_timedelta_start, self.profiler_per_request_method_counter[method], utils.textwrap_str(s.getvalue(), -1))
  179. else:
  180. if shutdown:
  181. logger.info("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start)
  182. else:
  183. logger.debug("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start)
  184. def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron:
  185. """Mask passwords and cookies."""
  186. headers = dict(environ)
  187. if (self._mask_passwords and
  188. headers.get("HTTP_AUTHORIZATION", "").startswith("Basic")):
  189. headers["HTTP_AUTHORIZATION"] = "Basic **masked**"
  190. if headers.get("HTTP_COOKIE"):
  191. headers["HTTP_COOKIE"] = "**masked**"
  192. return headers
  193. def __call__(self, environ: types.WSGIEnviron, start_response:
  194. types.WSGIStartResponse) -> Iterable[bytes]:
  195. with log.register_stream(environ["wsgi.errors"]):
  196. try:
  197. status_text, headers, answers = self._handle_request(environ)
  198. except Exception as e:
  199. logger.error("An exception occurred during %s request on %r: "
  200. "%s", environ.get("REQUEST_METHOD", "unknown"),
  201. environ.get("PATH_INFO", ""), e, exc_info=True)
  202. # Make minimal response
  203. status, raw_headers, raw_answer, xml_request = (
  204. httputils.INTERNAL_SERVER_ERROR)
  205. assert isinstance(raw_answer, str)
  206. answer = raw_answer.encode("ascii")
  207. status_text = "%d %s" % (
  208. status, client.responses.get(status, "Unknown"))
  209. headers = [*raw_headers, ("Content-Length", str(len(answer)))]
  210. answers = [answer]
  211. start_response(status_text, headers)
  212. if environ.get("REQUEST_METHOD") == "HEAD":
  213. return []
  214. return answers
  215. def _handle_request(self, environ: types.WSGIEnviron
  216. ) -> _IntermediateResponse:
  217. time_begin = datetime.datetime.now()
  218. request_method = environ["REQUEST_METHOD"].upper()
  219. unsafe_path = environ.get("PATH_INFO", "")
  220. https = environ.get("HTTPS", "")
  221. profiler = None
  222. profiler_active = False
  223. xml_request = None
  224. context = AuthContext()
  225. """Manage a request."""
  226. def response(status: int, headers: types.WSGIResponseHeaders,
  227. answer: Union[None, str, bytes],
  228. xml_request: Union[None, str] = None) -> _IntermediateResponse:
  229. """Helper to create response from internal types.WSGIResponse"""
  230. headers = dict(headers)
  231. content_encoding = "plain"
  232. # Set content length
  233. answers = []
  234. if answer is not None:
  235. if isinstance(answer, str):
  236. if self._response_content_on_debug:
  237. if logger.isEnabledFor(logging.DEBUG):
  238. logger.debug("Response content (nonXML):\n%s", utils.textwrap_str(answer))
  239. else:
  240. if logger.isEnabledFor(logging.DEBUG):
  241. logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug")
  242. headers["Content-Type"] += "; charset=%s" % self._encoding
  243. answer = answer.encode(self._encoding)
  244. accept_encoding = [
  245. encoding.strip() for encoding in
  246. environ.get("HTTP_ACCEPT_ENCODING", "").split(",")
  247. if encoding.strip()]
  248. if "gzip" in accept_encoding:
  249. zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
  250. answer = zcomp.compress(answer) + zcomp.flush()
  251. headers["Content-Encoding"] = "gzip"
  252. content_encoding = "gzip"
  253. headers["Content-Length"] = str(len(answer))
  254. answers.append(answer)
  255. # Add extra headers set in configuration
  256. headers.update(self._extra_headers)
  257. if self._response_header_on_debug:
  258. if logger.isEnabledFor(logging.DEBUG):
  259. logger.debug("Response header:\n%s", utils.textwrap_str(pprint.pformat(headers)))
  260. else:
  261. if logger.isEnabledFor(logging.DEBUG):
  262. logger.debug("Response header: suppressed by config/option [logging] response_header_on_debug")
  263. # Start response
  264. time_end = datetime.datetime.now()
  265. time_delta_seconds = (time_end - time_begin).total_seconds()
  266. status_text = "%d %s" % (
  267. status, client.responses.get(status, "Unknown"))
  268. flags = []
  269. if xml_request is not None:
  270. if "<sync-token />" in xml_request:
  271. flags.append("sync-token")
  272. if "<getetag />" in xml_request:
  273. flags.append("getetag")
  274. if "<CS:getctag />" in xml_request:
  275. flags.append("getctag")
  276. if "<sync-collection " in xml_request:
  277. flags.append("sync-collection")
  278. if flags:
  279. flags_text = " (" + " ".join(flags) + ")"
  280. else:
  281. flags_text = ""
  282. if answer is not None:
  283. logger.info("%s response status for %r%s in %.3f seconds %s %s bytes%s: %s",
  284. request_method, unsafe_path, depthinfo,
  285. (time_end - time_begin).total_seconds(), content_encoding, str(len(answer)),
  286. flags_text,
  287. status_text)
  288. else:
  289. logger.info("%s response status for %r%s in %.3f seconds: %s",
  290. request_method, unsafe_path, depthinfo,
  291. time_delta_seconds, status_text)
  292. # Profiling end
  293. if self._profiling_per_request:
  294. if profiler_active is True:
  295. if profiler is not None:
  296. # Profiling per request
  297. if time_delta_seconds < self._profiling_per_request_min_duration:
  298. logger.debug("Profiling data per request %s for %r%s: (suppressed because duration below minimum %.3f < %.3f)", request_method, unsafe_path, depthinfo, time_delta_seconds, self._profiling_per_request_min_duration)
  299. else:
  300. s = io.StringIO()
  301. s.write("**Profiling statistics BEGIN**\n")
  302. stats = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
  303. stats.print_stats(self._profiling_top_x_functions) # Print top X functions
  304. s.write("**Profiling statistics END**\n")
  305. if self._profiling_per_request_header:
  306. s.write("**Profiling request header BEGIN**\n")
  307. s.write(pprint.pformat(self._scrub_headers(environ)))
  308. s.write("\n**Profiling request header END**")
  309. if self._profiling_per_request_xml:
  310. if xml_request is not None:
  311. s.write("\n**Profiling request content (XML) BEGIN**\n")
  312. if xml_request is not None:
  313. s.write(xml_request)
  314. s.write("**Profiling request content (XML) END**")
  315. logger.info("Profiling data per request %s for %r%s:\n%s", request_method, unsafe_path, depthinfo, utils.textwrap_str(s.getvalue(), -1))
  316. else:
  317. logger.debug("Profiling data per request %s for %r%s: (suppressed because of no data)", request_method, unsafe_path, depthinfo)
  318. else:
  319. logger.info("Profiling data per request %s for %r%s: (not available because of concurrent running profiling request)", request_method, unsafe_path, depthinfo)
  320. elif self._profiling_per_request_method:
  321. self.profiler_per_request_method[request_method].disable()
  322. self.profiler_per_request_method_counter[request_method] += 1
  323. profiler_timedelta = (datetime.datetime.now() - self.profiler_per_request_method_logtime).total_seconds()
  324. if profiler_timedelta > self._profiling_per_request_method_interval:
  325. self._profiler_per_request_method()
  326. self.profiler_per_request_method_logtime = datetime.datetime.now()
  327. # Return response content
  328. return status_text, list(headers.items()), answers
  329. reverse_proxy = False
  330. remote_host = "unknown"
  331. if environ.get("REMOTE_HOST"):
  332. remote_host = repr(environ["REMOTE_HOST"])
  333. if environ.get("REMOTE_ADDR"):
  334. if remote_host == 'unknown':
  335. remote_host = environ["REMOTE_ADDR"]
  336. context.remote_addr = environ["REMOTE_ADDR"]
  337. if environ.get("HTTP_X_FORWARDED_FOR"):
  338. reverse_proxy = True
  339. remote_host = "%s (forwarded for %r)" % (
  340. remote_host, environ["HTTP_X_FORWARDED_FOR"])
  341. if environ.get("HTTP_X_REMOTE_ADDR"):
  342. context.x_remote_addr = environ["HTTP_X_REMOTE_ADDR"]
  343. if environ.get("HTTP_X_FORWARDED_HOST") or environ.get("HTTP_X_FORWARDED_PROTO") or environ.get("HTTP_X_FORWARDED_SERVER"):
  344. reverse_proxy = True
  345. remote_useragent = ""
  346. if environ.get("HTTP_USER_AGENT"):
  347. remote_useragent = " using %r" % environ["HTTP_USER_AGENT"]
  348. depthinfo = ""
  349. if environ.get("HTTP_DEPTH"):
  350. depthinfo = " with depth %r" % environ["HTTP_DEPTH"]
  351. if https:
  352. https_info = " " + environ.get("SSL_PROTOCOL", "") + " " + environ.get("SSL_CIPHER", "")
  353. else:
  354. https_info = ""
  355. logger.info("%s request for %r%s received from %s%s%s",
  356. request_method, unsafe_path, depthinfo,
  357. remote_host, remote_useragent, https_info)
  358. if self._request_header_on_debug:
  359. logger.debug("Request header:\n%s",
  360. utils.textwrap_str(pprint.pformat(self._scrub_headers(environ))))
  361. else:
  362. logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug")
  363. # SCRIPT_NAME is already removed from PATH_INFO, according to the
  364. # WSGI specification.
  365. # Reverse proxies can overwrite SCRIPT_NAME with X-SCRIPT-NAME header
  366. if self._script_name and (reverse_proxy is True):
  367. base_prefix_src = "config"
  368. base_prefix = self._script_name
  369. else:
  370. base_prefix_src = ("HTTP_X_SCRIPT_NAME" if "HTTP_X_SCRIPT_NAME" in
  371. environ else "SCRIPT_NAME")
  372. base_prefix = environ.get(base_prefix_src, "")
  373. if base_prefix and base_prefix[0] != "/":
  374. logger.error("Base prefix (from %s) must start with '/': %r",
  375. base_prefix_src, base_prefix)
  376. if base_prefix_src == "HTTP_X_SCRIPT_NAME":
  377. return response(*httputils.BAD_REQUEST)
  378. return response(*httputils.INTERNAL_SERVER_ERROR)
  379. if base_prefix.endswith("/"):
  380. logger.warning("Base prefix (from %s) must not end with '/': %r",
  381. base_prefix_src, base_prefix)
  382. base_prefix = base_prefix.rstrip("/")
  383. if base_prefix:
  384. logger.debug("Base prefix (from %s): %r", base_prefix_src, base_prefix)
  385. # Sanitize request URI (a WSGI server indicates with an empty path,
  386. # that the URL targets the application root without a trailing slash)
  387. path = pathutils.sanitize_path(unsafe_path)
  388. logger.debug("Sanitized path: %r", path)
  389. if (reverse_proxy is True) and (len(base_prefix) > 0):
  390. if path.startswith(base_prefix):
  391. path_new = path.removeprefix(base_prefix)
  392. logger.debug("Called by reverse proxy, remove base prefix %r from path: %r => %r", base_prefix, path, path_new)
  393. path = path_new
  394. else:
  395. if self._auth_type in ['remote_user', 'http_remote_user', 'http_x_remote_user'] and self._web_type == 'internal':
  396. 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)
  397. else:
  398. logger.debug("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching", base_prefix, path)
  399. # Get function corresponding to method
  400. function = getattr(self, "do_%s" % request_method, None)
  401. if not function:
  402. return response(*httputils.METHOD_NOT_ALLOWED)
  403. # Redirect all "…/.well-known/{caldav,carddav}" paths to "/".
  404. # This shouldn't be necessary but some clients like TbSync require it.
  405. # Status must be MOVED PERMANENTLY using FOUND causes problems
  406. if (path.rstrip("/").endswith("/.well-known/caldav") or
  407. path.rstrip("/").endswith("/.well-known/carddav")):
  408. return response(*httputils.redirect(
  409. base_prefix + "/", client.MOVED_PERMANENTLY))
  410. # Return NOT FOUND for all other paths containing ".well-known"
  411. if path.endswith("/.well-known") or "/.well-known/" in path:
  412. return response(*httputils.NOT_FOUND)
  413. # Ask authentication backend to check rights
  414. login = password = ""
  415. external_login = self._auth.get_external_login(environ)
  416. authorization = environ.get("HTTP_AUTHORIZATION", "")
  417. if external_login:
  418. login, password = external_login
  419. login, password = login or "", password or ""
  420. elif authorization.startswith("Basic"):
  421. authorization = authorization[len("Basic"):].strip()
  422. login, password = httputils.decode_request(
  423. self.configuration, environ, base64.b64decode(
  424. authorization.encode("ascii"))).split(":", 1)
  425. (user, info) = self._auth.login(login, password, context) or ("", "") if login else ("", "")
  426. if self.configuration.get("auth", "type") == "ldap":
  427. try:
  428. logger.debug("Groups received from LDAP: %r", ",".join(self._auth._ldap_groups))
  429. self._rights._user_groups = self._auth._ldap_groups
  430. except AttributeError:
  431. pass
  432. if user and login == user:
  433. logger.info("Successful login: %r (%s)", user, info)
  434. elif user:
  435. logger.info("Successful login: %r -> %r (%s)", login, user, info)
  436. elif login:
  437. logger.warning("Failed login attempt from %s: %r (%s)",
  438. remote_host, login, info)
  439. # Random delay to avoid timing oracles and bruteforce attacks
  440. if self._auth_delay > 0:
  441. random_delay = self._auth_delay * (0.5 + random.random())
  442. logger.debug("Failed login, sleeping random: %.3f sec", random_delay)
  443. time.sleep(random_delay)
  444. if user and not pathutils.is_safe_path_component(user):
  445. # Prevent usernames like "user/calendar.ics"
  446. logger.info("Refused unsafe username: %r", user)
  447. user = ""
  448. # Create principal collection
  449. if user:
  450. principal_path = "/%s/" % user
  451. with self._storage.acquire_lock("r", user):
  452. principal = next(iter(self._storage.discover(
  453. principal_path, depth="1")), None)
  454. if not principal:
  455. if "W" in self._rights.authorization(user, principal_path):
  456. with self._storage.acquire_lock("w", user):
  457. try:
  458. new_coll, _, _ = self._storage.create_collection(principal_path)
  459. if new_coll:
  460. jsn_coll = self.configuration.get("storage", "predefined_collections")
  461. for (name_coll, props) in jsn_coll.items():
  462. try:
  463. self._storage.create_collection(principal_path + name_coll, props=props)
  464. except ValueError as e:
  465. logger.warning("Failed to create predefined collection %r: %s", name_coll, e)
  466. except ValueError as e:
  467. logger.warning("Failed to create principal "
  468. "collection %r: %s", user, e)
  469. user = ""
  470. else:
  471. logger.warning("Access to principal path %r denied by "
  472. "rights backend", principal_path)
  473. if self._internal_server:
  474. # Verify content length
  475. content_length = int(environ.get("CONTENT_LENGTH") or 0)
  476. if content_length:
  477. if (self._max_content_length > 0 and
  478. content_length > self._max_content_length):
  479. logger.info("Request body too large: %d", content_length)
  480. return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
  481. if not login or user:
  482. # Profiling
  483. if self._profiling_per_request:
  484. profiler = cProfile.Profile()
  485. try:
  486. profiler.enable()
  487. except ValueError:
  488. profiler_active = False
  489. else:
  490. profiler_active = True
  491. elif self._profiling_per_request_method:
  492. try:
  493. self.profiler_per_request_method[request_method].enable()
  494. except ValueError:
  495. profiler_active = False
  496. else:
  497. profiler_active = True
  498. status, headers, answer, xml_request = function(
  499. environ, base_prefix, path, user, remote_host, remote_useragent)
  500. # Profiling
  501. if self._profiling_per_request:
  502. if profiler is not None:
  503. if profiler_active is True:
  504. profiler.disable()
  505. elif self._profiling_per_request_method:
  506. if profiler_active is True:
  507. self.profiler_per_request_method[request_method].disable()
  508. if (status, headers, answer, xml_request) == httputils.NOT_ALLOWED:
  509. logger.info("Access to %r denied for %s", path,
  510. repr(user) if user else "anonymous user")
  511. else:
  512. status, headers, answer, xml_request = httputils.NOT_ALLOWED
  513. if ((status, headers, answer, xml_request) == httputils.NOT_ALLOWED and not user and
  514. not external_login):
  515. # Unknown or unauthorized user
  516. logger.debug("Asking client for authentication")
  517. status = client.UNAUTHORIZED
  518. headers = dict(headers)
  519. headers.update({
  520. "WWW-Authenticate":
  521. "Basic realm=\"%s\"" % self._auth_realm})
  522. return response(status, headers, answer, xml_request)