handlers.py 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211
  1. """Base Tornado handlers for the Jupyter server."""
  2. # Copyright (c) Jupyter Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. from __future__ import annotations
  5. import functools
  6. import inspect
  7. import ipaddress
  8. import json
  9. import mimetypes
  10. import os
  11. import re
  12. import types
  13. import warnings
  14. from collections.abc import Awaitable, Coroutine, Sequence
  15. from http.client import responses
  16. from logging import Logger
  17. from typing import TYPE_CHECKING, Any, cast
  18. from urllib.parse import urlparse
  19. import prometheus_client
  20. from jinja2 import TemplateNotFound
  21. from jupyter_core.paths import is_hidden
  22. from tornado import web
  23. from tornado.log import app_log
  24. from traitlets.config import Application
  25. import jupyter_server
  26. from jupyter_server import CallContext
  27. from jupyter_server._sysinfo import get_sys_info
  28. from jupyter_server._tz import utcnow
  29. from jupyter_server.auth.decorator import allow_unauthenticated, authorized
  30. from jupyter_server.auth.identity import User
  31. from jupyter_server.i18n import combine_translations
  32. from jupyter_server.services.security import csp_report_uri
  33. from jupyter_server.utils import (
  34. ensure_async,
  35. filefind,
  36. url_escape,
  37. url_is_absolute,
  38. url_path_join,
  39. urldecode_unix_socket_path,
  40. )
  41. if TYPE_CHECKING:
  42. from jupyter_client.kernelspec import KernelSpecManager
  43. from jupyter_events import EventLogger
  44. from jupyter_server_terminals.terminalmanager import TerminalManager
  45. from tornado.concurrent import Future
  46. from jupyter_server.auth.authorizer import Authorizer
  47. from jupyter_server.auth.identity import IdentityProvider
  48. from jupyter_server.serverapp import ServerApp
  49. from jupyter_server.services.config.manager import ConfigManager
  50. from jupyter_server.services.contents.manager import ContentsManager
  51. from jupyter_server.services.kernels.kernelmanager import AsyncMappingKernelManager
  52. from jupyter_server.services.sessions.sessionmanager import SessionManager
  53. # -----------------------------------------------------------------------------
  54. # Top-level handlers
  55. # -----------------------------------------------------------------------------
  56. _sys_info_cache = None
  57. def json_sys_info():
  58. """Get sys info as json."""
  59. global _sys_info_cache # noqa: PLW0603
  60. if _sys_info_cache is None:
  61. _sys_info_cache = json.dumps(get_sys_info())
  62. return _sys_info_cache
  63. def log() -> Logger:
  64. """Get the application log."""
  65. if Application.initialized():
  66. return cast(Logger, Application.instance().log)
  67. else:
  68. return app_log
  69. class AuthenticatedHandler(web.RequestHandler):
  70. """A RequestHandler with an authenticated user."""
  71. @property
  72. def base_url(self) -> str:
  73. return cast(str, self.settings.get("base_url", "/"))
  74. @property
  75. def content_security_policy(self) -> str:
  76. """The default Content-Security-Policy header
  77. Can be overridden by defining Content-Security-Policy in settings['headers']
  78. """
  79. if "Content-Security-Policy" in self.settings.get("headers", {}):
  80. # user-specified, don't override
  81. return cast(str, self.settings["headers"]["Content-Security-Policy"])
  82. return "; ".join(
  83. [
  84. "frame-ancestors 'self'",
  85. # Make sure the report-uri is relative to the base_url
  86. "report-uri "
  87. + self.settings.get("csp_report_uri", url_path_join(self.base_url, csp_report_uri)),
  88. ]
  89. )
  90. def set_default_headers(self) -> None:
  91. """Set the default headers."""
  92. headers = {}
  93. headers["X-Content-Type-Options"] = "nosniff"
  94. headers.update(self.settings.get("headers", {}))
  95. headers["Content-Security-Policy"] = self.content_security_policy
  96. # Allow for overriding headers
  97. for header_name, value in headers.items():
  98. try:
  99. self.set_header(header_name, value)
  100. except Exception as e:
  101. # tornado raise Exception (not a subclass)
  102. # if method is unsupported (websocket and Access-Control-Allow-Origin
  103. # for example, so just ignore)
  104. self.log.exception( # type:ignore[attr-defined]
  105. "Could not set default headers: %s", e
  106. )
  107. @property
  108. def cookie_name(self) -> str:
  109. warnings.warn(
  110. """JupyterHandler.login_handler is deprecated in 2.0,
  111. use JupyterHandler.identity_provider.
  112. """,
  113. DeprecationWarning,
  114. stacklevel=2,
  115. )
  116. return self.identity_provider.get_cookie_name(self)
  117. def force_clear_cookie(self, name: str, path: str = "/", domain: str | None = None) -> None:
  118. """Force a cookie clear."""
  119. warnings.warn(
  120. """JupyterHandler.login_handler is deprecated in 2.0,
  121. use JupyterHandler.identity_provider.
  122. """,
  123. DeprecationWarning,
  124. stacklevel=2,
  125. )
  126. self.identity_provider._force_clear_cookie(self, name, path=path, domain=domain)
  127. def clear_login_cookie(self) -> None:
  128. """Clear a login cookie."""
  129. warnings.warn(
  130. """JupyterHandler.login_handler is deprecated in 2.0,
  131. use JupyterHandler.identity_provider.
  132. """,
  133. DeprecationWarning,
  134. stacklevel=2,
  135. )
  136. self.identity_provider.clear_login_cookie(self)
  137. def get_current_user(self) -> str:
  138. """Get the current user."""
  139. clsname = self.__class__.__name__
  140. msg = (
  141. f"Calling `{clsname}.get_current_user()` directly is deprecated in jupyter-server 2.0."
  142. " Use `self.current_user` instead (works in all versions)."
  143. )
  144. if hasattr(self, "_jupyter_current_user"):
  145. # backward-compat: return _jupyter_current_user
  146. warnings.warn(
  147. msg,
  148. DeprecationWarning,
  149. stacklevel=2,
  150. )
  151. return cast(str, self._jupyter_current_user)
  152. # haven't called get_user in prepare, raise
  153. raise RuntimeError(msg)
  154. def skip_check_origin(self) -> bool:
  155. """Ask my login_handler if I should skip the origin_check
  156. For example: in the default LoginHandler, if a request is token-authenticated,
  157. origin checking should be skipped.
  158. """
  159. if self.request.method == "OPTIONS":
  160. # no origin-check on options requests, which are used to check origins!
  161. return True
  162. return not self.identity_provider.should_check_origin(self)
  163. @property
  164. def token_authenticated(self) -> bool:
  165. """Have I been authenticated with a token?"""
  166. return self.identity_provider.is_token_authenticated(self)
  167. @property
  168. def logged_in(self) -> bool:
  169. """Is a user currently logged in?"""
  170. user = self.current_user
  171. return bool(user and user != "anonymous")
  172. @property
  173. def login_handler(self) -> Any:
  174. """Return the login handler for this application, if any."""
  175. warnings.warn(
  176. """JupyterHandler.login_handler is deprecated in 2.0,
  177. use JupyterHandler.identity_provider.
  178. """,
  179. DeprecationWarning,
  180. stacklevel=2,
  181. )
  182. return self.identity_provider.login_handler_class
  183. @property
  184. def token(self) -> str | None:
  185. """Return the login token for this application, if any."""
  186. return self.identity_provider.token
  187. @property
  188. def login_available(self) -> bool:
  189. """May a user proceed to log in?
  190. This returns True if login capability is available, irrespective of
  191. whether the user is already logged in or not.
  192. """
  193. return cast(bool, self.identity_provider.login_available)
  194. @property
  195. def authorizer(self) -> Authorizer:
  196. if "authorizer" not in self.settings:
  197. warnings.warn(
  198. "The Tornado web application does not have an 'authorizer' defined "
  199. "in its settings. In future releases of jupyter_server, this will "
  200. "be a required key for all subclasses of `JupyterHandler`. For an "
  201. "example, see the jupyter_server source code for how to "
  202. "add an authorizer to the tornado settings: "
  203. "https://github.com/jupyter-server/jupyter_server/blob/"
  204. "653740cbad7ce0c8a8752ce83e4d3c2c754b13cb/jupyter_server/serverapp.py"
  205. "#L234-L256",
  206. stacklevel=2,
  207. )
  208. from jupyter_server.auth import AllowAllAuthorizer
  209. self.settings["authorizer"] = AllowAllAuthorizer(
  210. config=self.settings.get("config", None),
  211. identity_provider=self.identity_provider,
  212. )
  213. return cast("Authorizer", self.settings.get("authorizer"))
  214. @property
  215. def identity_provider(self) -> IdentityProvider:
  216. if "identity_provider" not in self.settings:
  217. warnings.warn(
  218. "The Tornado web application does not have an 'identity_provider' defined "
  219. "in its settings. In future releases of jupyter_server, this will "
  220. "be a required key for all subclasses of `JupyterHandler`. For an "
  221. "example, see the jupyter_server source code for how to "
  222. "add an identity provider to the tornado settings: "
  223. "https://github.com/jupyter-server/jupyter_server/blob/v2.0.0/"
  224. "jupyter_server/serverapp.py#L242",
  225. stacklevel=2,
  226. )
  227. from jupyter_server.auth import IdentityProvider
  228. # no identity provider set, load default
  229. self.settings["identity_provider"] = IdentityProvider(
  230. config=self.settings.get("config", None)
  231. )
  232. return cast("IdentityProvider", self.settings["identity_provider"])
  233. class JupyterHandler(AuthenticatedHandler):
  234. """Jupyter-specific extensions to authenticated handling
  235. Mostly property shortcuts to Jupyter-specific settings.
  236. """
  237. @property
  238. def config(self) -> dict[str, Any] | None:
  239. return cast("dict[str, Any] | None", self.settings.get("config", None))
  240. @property
  241. def log(self) -> Logger:
  242. """use the Jupyter log by default, falling back on tornado's logger"""
  243. return log()
  244. @property
  245. def jinja_template_vars(self) -> dict[str, Any]:
  246. """User-supplied values to supply to jinja templates."""
  247. return cast("dict[str, Any]", self.settings.get("jinja_template_vars", {}))
  248. @property
  249. def serverapp(self) -> ServerApp | None:
  250. return cast("ServerApp | None", self.settings["serverapp"])
  251. # ---------------------------------------------------------------
  252. # URLs
  253. # ---------------------------------------------------------------
  254. @property
  255. def version_hash(self) -> str:
  256. """The version hash to use for cache hints for static files"""
  257. return cast(str, self.settings.get("version_hash", ""))
  258. @property
  259. def mathjax_url(self) -> str:
  260. url = cast(str, self.settings.get("mathjax_url", ""))
  261. if not url or url_is_absolute(url):
  262. return url
  263. return url_path_join(self.base_url, url)
  264. @property
  265. def mathjax_config(self) -> str:
  266. return cast(str, self.settings.get("mathjax_config", "TeX-AMS-MML_HTMLorMML-full,Safe"))
  267. @property
  268. def default_url(self) -> str:
  269. return cast(str, self.settings.get("default_url", ""))
  270. @property
  271. def ws_url(self) -> str:
  272. return cast(str, self.settings.get("websocket_url", ""))
  273. @property
  274. def contents_js_source(self) -> str:
  275. self.log.debug(
  276. "Using contents: %s",
  277. self.settings.get("contents_js_source", "services/contents"),
  278. )
  279. return cast(str, self.settings.get("contents_js_source", "services/contents"))
  280. # ---------------------------------------------------------------
  281. # Manager objects
  282. # ---------------------------------------------------------------
  283. @property
  284. def kernel_manager(self) -> AsyncMappingKernelManager:
  285. return cast("AsyncMappingKernelManager", self.settings["kernel_manager"])
  286. @property
  287. def contents_manager(self) -> ContentsManager:
  288. return cast("ContentsManager", self.settings["contents_manager"])
  289. @property
  290. def session_manager(self) -> SessionManager:
  291. return cast("SessionManager", self.settings["session_manager"])
  292. @property
  293. def terminal_manager(self) -> TerminalManager:
  294. return cast("TerminalManager", self.settings["terminal_manager"])
  295. @property
  296. def kernel_spec_manager(self) -> KernelSpecManager:
  297. return cast("KernelSpecManager", self.settings["kernel_spec_manager"])
  298. @property
  299. def config_manager(self) -> ConfigManager:
  300. return cast("ConfigManager", self.settings["config_manager"])
  301. @property
  302. def event_logger(self) -> EventLogger:
  303. return cast("EventLogger", self.settings["event_logger"])
  304. # ---------------------------------------------------------------
  305. # CORS
  306. # ---------------------------------------------------------------
  307. @property
  308. def allow_origin(self) -> str:
  309. """Normal Access-Control-Allow-Origin"""
  310. return cast(str, self.settings.get("allow_origin", ""))
  311. @property
  312. def allow_origin_pat(self) -> str | None:
  313. """Regular expression version of allow_origin"""
  314. return cast("str | None", self.settings.get("allow_origin_pat", None))
  315. @property
  316. def allow_credentials(self) -> bool:
  317. """Whether to set Access-Control-Allow-Credentials"""
  318. return cast(bool, self.settings.get("allow_credentials", False))
  319. def set_default_headers(self) -> None:
  320. """Add CORS headers, if defined"""
  321. super().set_default_headers()
  322. def set_cors_headers(self) -> None:
  323. """Add CORS headers, if defined
  324. Now that current_user is async (jupyter-server 2.0),
  325. must be called at the end of prepare(), instead of in set_default_headers.
  326. """
  327. if self.allow_origin:
  328. self.set_header("Access-Control-Allow-Origin", self.allow_origin)
  329. elif self.allow_origin_pat:
  330. origin = self.get_origin()
  331. if origin and re.match(self.allow_origin_pat, origin):
  332. self.set_header("Access-Control-Allow-Origin", origin)
  333. elif self.token_authenticated and "Access-Control-Allow-Origin" not in self.settings.get(
  334. "headers", {}
  335. ):
  336. # allow token-authenticated requests cross-origin by default.
  337. # only apply this exception if allow-origin has not been specified.
  338. self.set_header("Access-Control-Allow-Origin", self.request.headers.get("Origin", ""))
  339. if self.allow_credentials:
  340. self.set_header("Access-Control-Allow-Credentials", "true")
  341. def set_attachment_header(self, filename: str) -> None:
  342. """Set Content-Disposition: attachment header
  343. As a method to ensure handling of filename encoding
  344. """
  345. escaped_filename = url_escape(filename)
  346. self.set_header(
  347. "Content-Disposition",
  348. f"attachment; filename*=utf-8''{escaped_filename}",
  349. )
  350. def get_origin(self) -> str | None:
  351. # Handle WebSocket Origin naming convention differences
  352. # The difference between version 8 and 13 is that in 8 the
  353. # client sends a "Sec-Websocket-Origin" header and in 13 it's
  354. # simply "Origin".
  355. if "Origin" in self.request.headers:
  356. origin = self.request.headers.get("Origin")
  357. else:
  358. origin = self.request.headers.get("Sec-Websocket-Origin", None)
  359. return origin
  360. # origin_to_satisfy_tornado is present because tornado requires
  361. # check_origin to take an origin argument, but we don't use it
  362. def check_origin(self, origin_to_satisfy_tornado: str = "") -> bool:
  363. """Check Origin for cross-site API requests, including websockets
  364. Copied from WebSocket with changes:
  365. - allow unspecified host/origin (e.g. scripts)
  366. - allow token-authenticated requests
  367. """
  368. if self.allow_origin == "*" or self.skip_check_origin():
  369. return True
  370. host = self.request.headers.get("Host")
  371. origin = self.request.headers.get("Origin")
  372. # If no header is provided, let the request through.
  373. # Origin can be None for:
  374. # - same-origin (IE, Firefox)
  375. # - Cross-site POST form (IE, Firefox)
  376. # - Scripts
  377. # The cross-site POST (XSRF) case is handled by tornado's xsrf_token
  378. if origin is None or host is None:
  379. return True
  380. origin = origin.lower()
  381. origin_host = urlparse(origin).netloc
  382. # OK if origin matches host
  383. if origin_host == host:
  384. return True
  385. # Check CORS headers
  386. if self.allow_origin:
  387. allow = bool(self.allow_origin == origin)
  388. elif self.allow_origin_pat:
  389. allow = bool(re.match(self.allow_origin_pat, origin))
  390. else:
  391. # No CORS headers deny the request
  392. allow = False
  393. if not allow:
  394. self.log.warning(
  395. "Blocking Cross Origin API request for %s. Origin: %s, Host: %s",
  396. self.request.path,
  397. origin,
  398. host,
  399. )
  400. return allow
  401. def check_referer(self) -> bool:
  402. """Check Referer for cross-site requests.
  403. Disables requests to certain endpoints with
  404. external or missing Referer.
  405. If set, allow_origin settings are applied to the Referer
  406. to whitelist specific cross-origin sites.
  407. Used on GET for api endpoints and /files/
  408. to block cross-site inclusion (XSSI).
  409. """
  410. if self.allow_origin == "*" or self.skip_check_origin():
  411. return True
  412. host = self.request.headers.get("Host")
  413. referer = self.request.headers.get("Referer")
  414. if not host:
  415. self.log.warning("Blocking request with no host")
  416. return False
  417. if not referer:
  418. self.log.warning("Blocking request with no referer")
  419. return False
  420. referer_url = urlparse(referer)
  421. referer_host = referer_url.netloc
  422. if referer_host == host:
  423. return True
  424. # apply cross-origin checks to Referer:
  425. origin = f"{referer_url.scheme}://{referer_url.netloc}"
  426. if self.allow_origin:
  427. allow = self.allow_origin == origin
  428. elif self.allow_origin_pat:
  429. allow = bool(re.match(self.allow_origin_pat, origin))
  430. else:
  431. # No CORS settings, deny the request
  432. allow = False
  433. if not allow:
  434. self.log.warning(
  435. "Blocking Cross Origin request for %s. Referer: %s, Host: %s",
  436. self.request.path,
  437. origin,
  438. host,
  439. )
  440. return allow
  441. def check_xsrf_cookie(self) -> None:
  442. """Bypass xsrf cookie checks when token-authenticated"""
  443. if not hasattr(self, "_jupyter_current_user"):
  444. # Called too early, will be checked later
  445. return None
  446. if self.token_authenticated or self.settings.get("disable_check_xsrf", False):
  447. # Token-authenticated requests do not need additional XSRF-check
  448. # Servers without authentication are vulnerable to XSRF
  449. return None
  450. try:
  451. if not self.check_origin():
  452. raise web.HTTPError(404)
  453. return super().check_xsrf_cookie()
  454. except web.HTTPError as e:
  455. if self.request.method in {"GET", "HEAD"}:
  456. # Consider Referer a sufficient cross-origin check for GET requests
  457. if not self.check_referer():
  458. referer = self.request.headers.get("Referer")
  459. if referer:
  460. msg = f"Blocking Cross Origin request from {referer}."
  461. else:
  462. msg = "Blocking request from unknown origin"
  463. raise web.HTTPError(403, msg) from e
  464. else:
  465. raise
  466. def check_host(self) -> bool:
  467. """Check the host header if remote access disallowed.
  468. Returns True if the request should continue, False otherwise.
  469. """
  470. if self.settings.get("allow_remote_access", False):
  471. return True
  472. # Remove port (e.g. ':8888') from host
  473. match = re.match(r"^(.*?)(:\d+)?$", self.request.host)
  474. assert match is not None
  475. host = match.group(1)
  476. # Browsers format IPv6 addresses like [::1]; we need to remove the []
  477. if host.startswith("[") and host.endswith("]"):
  478. host = host[1:-1]
  479. # UNIX socket handling
  480. check_host = urldecode_unix_socket_path(host)
  481. if check_host.startswith("/") and os.path.exists(check_host):
  482. allow = True
  483. else:
  484. try:
  485. addr = ipaddress.ip_address(host)
  486. except ValueError:
  487. # Not an IP address: check against hostnames
  488. allow = host in self.settings.get("local_hostnames", ["localhost"])
  489. else:
  490. allow = addr.is_loopback
  491. if not allow:
  492. self.log.warning(
  493. (
  494. "Blocking request with non-local 'Host' %s (%s). "
  495. "If the server should be accessible at that name, "
  496. "set ServerApp.allow_remote_access to disable the check."
  497. ),
  498. host,
  499. self.request.host,
  500. )
  501. return allow
  502. async def prepare(self, *, _redirect_to_login=True) -> Awaitable[None] | None: # type:ignore[override]
  503. """Prepare a response."""
  504. # Set the current Jupyter Handler context variable.
  505. CallContext.set(CallContext.JUPYTER_HANDLER, self)
  506. if not self.check_host():
  507. self.current_user = self._jupyter_current_user = None
  508. raise web.HTTPError(403)
  509. from jupyter_server.auth import IdentityProvider
  510. mod_obj = inspect.getmodule(self.get_current_user)
  511. assert mod_obj is not None
  512. user: User | None = None
  513. if type(self.identity_provider) is IdentityProvider and mod_obj.__name__ != __name__:
  514. # check for overridden get_current_user + default IdentityProvider
  515. # deprecated way to override auth (e.g. JupyterHub < 3.0)
  516. # allow deprecated, overridden get_current_user
  517. warnings.warn(
  518. "Overriding JupyterHandler.get_current_user is deprecated in jupyter-server 2.0."
  519. " Use an IdentityProvider class.",
  520. DeprecationWarning,
  521. stacklevel=1,
  522. )
  523. user = User(self.get_current_user())
  524. else:
  525. _user = self.identity_provider.get_user(self)
  526. if isinstance(_user, Awaitable):
  527. # IdentityProvider.get_user _may_ be async
  528. _user = await _user
  529. user = _user
  530. # self.current_user for tornado's @web.authenticated
  531. # self._jupyter_current_user for backward-compat in deprecated get_current_user calls
  532. # and our own private checks for whether .current_user has been set
  533. self.current_user = self._jupyter_current_user = user
  534. # complete initial steps which require auth to resolve first:
  535. self.set_cors_headers()
  536. if self.request.method not in {"GET", "HEAD", "OPTIONS"}:
  537. self.check_xsrf_cookie()
  538. if not self.settings.get("allow_unauthenticated_access", False):
  539. if not self.request.method:
  540. raise HTTPError(403)
  541. method = getattr(self, self.request.method.lower())
  542. if not getattr(method, "__allow_unauthenticated", False):
  543. if _redirect_to_login:
  544. # reuse `web.authenticated` logic, which redirects to the login
  545. # page on GET and HEAD and otherwise raises 403
  546. return web.authenticated(lambda _: super().prepare())(self)
  547. else:
  548. # raise 403 if user is not known without redirecting to login page
  549. user = self.current_user
  550. if user is None:
  551. self.log.warning(
  552. f"Couldn't authenticate {self.__class__.__name__} connection"
  553. )
  554. raise web.HTTPError(403)
  555. return super().prepare()
  556. # ---------------------------------------------------------------
  557. # template rendering
  558. # ---------------------------------------------------------------
  559. def get_template(self, name):
  560. """Return the jinja template object for a given name"""
  561. return self.settings["jinja2_env"].get_template(name)
  562. def render_template(self, name, **ns):
  563. """Render a template by name."""
  564. ns.update(self.template_namespace)
  565. template = self.get_template(name)
  566. return template.render(**ns)
  567. @property
  568. def template_namespace(self) -> dict[str, Any]:
  569. return dict(
  570. base_url=self.base_url,
  571. default_url=self.default_url,
  572. ws_url=self.ws_url,
  573. logged_in=self.logged_in,
  574. allow_password_change=getattr(self.identity_provider, "allow_password_change", False),
  575. auth_enabled=self.identity_provider.auth_enabled,
  576. login_available=self.identity_provider.login_available,
  577. token_available=bool(self.token),
  578. static_url=self.static_url,
  579. sys_info=json_sys_info(),
  580. contents_js_source=self.contents_js_source,
  581. version_hash=self.version_hash,
  582. xsrf_form_html=self.xsrf_form_html,
  583. token=self.token,
  584. xsrf_token=self.xsrf_token.decode("utf8"),
  585. nbjs_translations=json.dumps(
  586. combine_translations(self.request.headers.get("Accept-Language", ""))
  587. ),
  588. **self.jinja_template_vars,
  589. )
  590. def get_json_body(self) -> dict[str, Any] | None:
  591. """Return the body of the request as JSON data."""
  592. if not self.request.body:
  593. return None
  594. # Do we need to call body.decode('utf-8') here?
  595. body = self.request.body.strip().decode("utf-8")
  596. try:
  597. model = json.loads(body)
  598. except Exception as e:
  599. self.log.debug("Bad JSON: %r", body)
  600. self.log.error("Couldn't parse JSON", exc_info=True)
  601. raise web.HTTPError(400, "Invalid JSON in body of request") from e
  602. return cast("dict[str, Any]", model)
  603. def write_error(self, status_code: int, **kwargs: Any) -> None:
  604. """render custom error pages"""
  605. exc_info = kwargs.get("exc_info")
  606. message = ""
  607. status_message = responses.get(status_code, "Unknown HTTP Error")
  608. if exc_info:
  609. exception = exc_info[1]
  610. # get the custom message, if defined
  611. try:
  612. message = exception.log_message % exception.args
  613. except Exception:
  614. pass
  615. # construct the custom reason, if defined
  616. reason = getattr(exception, "reason", "")
  617. if reason:
  618. status_message = reason
  619. else:
  620. exception = "(unknown)"
  621. # build template namespace
  622. ns = {
  623. "status_code": status_code,
  624. "status_message": status_message,
  625. "message": message,
  626. "exception": exception,
  627. }
  628. self.set_header("Content-Type", "text/html")
  629. # render the template
  630. try:
  631. html = self.render_template("%s.html" % status_code, **ns)
  632. except TemplateNotFound:
  633. html = self.render_template("error.html", **ns)
  634. self.write(html)
  635. class APIHandler(JupyterHandler):
  636. """Base class for API handlers"""
  637. async def prepare(self) -> None: # type:ignore[override]
  638. """Prepare an API response."""
  639. await super().prepare()
  640. if not self.check_origin():
  641. raise web.HTTPError(404)
  642. def write_error(self, status_code: int, **kwargs: Any) -> None:
  643. """APIHandler errors are JSON, not human pages"""
  644. self.set_header("Content-Type", "application/json")
  645. message = responses.get(status_code, "Unknown HTTP Error")
  646. reply: dict[str, Any] = {
  647. "message": message,
  648. }
  649. exc_info = kwargs.get("exc_info")
  650. if exc_info:
  651. e = exc_info[1]
  652. if isinstance(e, HTTPError):
  653. reply["message"] = e.log_message or message
  654. reply["reason"] = e.reason
  655. else:
  656. reply["message"] = "Unhandled error"
  657. reply["reason"] = None
  658. # backward-compatibility: traceback field is present,
  659. # but always empty
  660. reply["traceback"] = ""
  661. self.log.warning("wrote error: %r", reply["message"], exc_info=True)
  662. self.finish(json.dumps(reply))
  663. def get_login_url(self) -> str:
  664. """Get the login url."""
  665. # if get_login_url is invoked in an API handler,
  666. # that means @web.authenticated is trying to trigger a redirect.
  667. # instead of redirecting, raise 403 instead.
  668. if not self.current_user:
  669. raise web.HTTPError(403)
  670. return super().get_login_url()
  671. @property
  672. def content_security_policy(self) -> str:
  673. csp = "; ".join( # noqa: FLY002
  674. [
  675. super().content_security_policy,
  676. "default-src 'none'",
  677. ]
  678. )
  679. return csp
  680. # set _track_activity = False on API handlers that shouldn't track activity
  681. _track_activity = True
  682. def update_api_activity(self) -> None:
  683. """Update last_activity of API requests"""
  684. # record activity of authenticated requests
  685. if (
  686. self._track_activity
  687. and getattr(self, "_jupyter_current_user", None)
  688. and self.get_argument("no_track_activity", None) is None
  689. ):
  690. self.settings["api_last_activity"] = utcnow()
  691. def finish(self, *args: Any, **kwargs: Any) -> Future[Any]:
  692. """Finish an API response."""
  693. self.update_api_activity()
  694. # Allow caller to indicate content-type...
  695. set_content_type = kwargs.pop("set_content_type", "application/json")
  696. self.set_header("Content-Type", set_content_type)
  697. return super().finish(*args, **kwargs)
  698. @allow_unauthenticated
  699. def options(self, *args: Any, **kwargs: Any) -> None:
  700. """Get the options."""
  701. if "Access-Control-Allow-Headers" in self.settings.get("headers", {}):
  702. self.set_header(
  703. "Access-Control-Allow-Headers",
  704. self.settings["headers"]["Access-Control-Allow-Headers"],
  705. )
  706. else:
  707. self.set_header(
  708. "Access-Control-Allow-Headers",
  709. "accept, content-type, authorization, x-xsrftoken",
  710. )
  711. self.set_header("Access-Control-Allow-Methods", "GET, PUT, POST, PATCH, DELETE, OPTIONS")
  712. # if authorization header is requested,
  713. # that means the request is token-authenticated.
  714. # avoid browser-side rejection of the preflight request.
  715. # only allow this exception if allow_origin has not been specified
  716. # and Jupyter server authentication is enabled.
  717. # If the token is not valid, the 'real' request will still be rejected.
  718. requested_headers = self.request.headers.get("Access-Control-Request-Headers", "").split(
  719. ","
  720. )
  721. if (
  722. requested_headers
  723. and any(h.strip().lower() == "authorization" for h in requested_headers)
  724. and (
  725. # FIXME: it would be even better to check specifically for token-auth,
  726. # but there is currently no API for this.
  727. self.login_available
  728. )
  729. and (
  730. self.allow_origin
  731. or self.allow_origin_pat
  732. or "Access-Control-Allow-Origin" in self.settings.get("headers", {})
  733. )
  734. ):
  735. self.set_header("Access-Control-Allow-Origin", self.request.headers.get("Origin", ""))
  736. class Template404(JupyterHandler):
  737. """Render our 404 template"""
  738. async def prepare(self) -> None: # type:ignore[override]
  739. """Prepare a 404 response."""
  740. await super().prepare()
  741. raise web.HTTPError(404)
  742. class AuthenticatedFileHandler(JupyterHandler, web.StaticFileHandler):
  743. """static files should only be accessible when logged in"""
  744. auth_resource = "contents"
  745. @property
  746. def content_security_policy(self) -> str:
  747. # In case we're serving HTML/SVG, confine any Javascript to a unique
  748. # origin so it can't interact with the Jupyter server.
  749. return super().content_security_policy + "; sandbox allow-scripts"
  750. @web.authenticated
  751. @authorized
  752. def head(self, path: str) -> Awaitable[None]: # type:ignore[override]
  753. """Get the head response for a path."""
  754. self.check_xsrf_cookie()
  755. return super().head(path)
  756. @web.authenticated
  757. @authorized
  758. def get( # type:ignore[override]
  759. self, path: str, **kwargs: Any
  760. ) -> Awaitable[None]:
  761. """Get a file by path."""
  762. self.check_xsrf_cookie()
  763. if os.path.splitext(path)[1] == ".ipynb" or self.get_argument("download", None):
  764. name = path.rsplit("/", 1)[-1]
  765. self.set_attachment_header(name)
  766. return web.StaticFileHandler.get(self, path, **kwargs)
  767. def get_content_type(self) -> str:
  768. """Get the content type."""
  769. assert self.absolute_path is not None
  770. path = self.absolute_path.strip("/")
  771. if "/" in path:
  772. _, name = path.rsplit("/", 1)
  773. else:
  774. name = path
  775. if name.endswith(".ipynb"):
  776. return "application/x-ipynb+json"
  777. else:
  778. cur_mime = mimetypes.guess_type(name)[0]
  779. if cur_mime == "text/plain":
  780. return "text/plain; charset=UTF-8"
  781. else:
  782. return super().get_content_type()
  783. def set_headers(self) -> None:
  784. """Set the headers."""
  785. super().set_headers()
  786. # disable browser caching, rely on 304 replies for savings
  787. if "v" not in self.request.arguments:
  788. self.add_header("Cache-Control", "no-cache")
  789. def compute_etag(self) -> str | None:
  790. """Compute the etag."""
  791. return None
  792. def validate_absolute_path(self, root: str, absolute_path: str) -> str:
  793. """Validate and return the absolute path.
  794. Requires tornado 3.1
  795. Adding to tornado's own handling, forbids the serving of hidden files.
  796. """
  797. abs_path = super().validate_absolute_path(root, absolute_path)
  798. abs_root = os.path.abspath(root)
  799. assert abs_path is not None
  800. if not self.contents_manager.allow_hidden and is_hidden(abs_path, abs_root):
  801. self.log.info(
  802. "Refusing to serve hidden file, via 404 Error, use flag 'ContentsManager.allow_hidden' to enable"
  803. )
  804. raise web.HTTPError(404)
  805. return abs_path
  806. def json_errors(method: Any) -> Any: # pragma: no cover
  807. """Decorate methods with this to return GitHub style JSON errors.
  808. This should be used on any JSON API on any handler method that can raise HTTPErrors.
  809. This will grab the latest HTTPError exception using sys.exc_info
  810. and then:
  811. 1. Set the HTTP status code based on the HTTPError
  812. 2. Create and return a JSON body with a message field describing
  813. the error in a human readable form.
  814. """
  815. warnings.warn(
  816. "@json_errors is deprecated in notebook 5.2.0. Subclass APIHandler instead.",
  817. DeprecationWarning,
  818. stacklevel=2,
  819. )
  820. @functools.wraps(method)
  821. def wrapper(self, *args, **kwargs):
  822. self.write_error = types.MethodType(APIHandler.write_error, self)
  823. return method(self, *args, **kwargs)
  824. return wrapper
  825. # -----------------------------------------------------------------------------
  826. # File handler
  827. # -----------------------------------------------------------------------------
  828. # to minimize subclass changes:
  829. HTTPError = web.HTTPError
  830. class FileFindHandler(JupyterHandler, web.StaticFileHandler):
  831. """subclass of StaticFileHandler for serving files from a search path
  832. The setting "static_immutable_cache" can be set up to serve some static
  833. file as immutable (e.g. file name containing a hash). The setting is a
  834. list of base URL, every static file URL starting with one of those will
  835. be immutable.
  836. """
  837. # cache search results, don't search for files more than once
  838. _static_paths: dict[str, str] = {}
  839. root: tuple[str] # type:ignore[assignment]
  840. def set_headers(self) -> None:
  841. """Set the headers."""
  842. super().set_headers()
  843. immutable_paths = self.settings.get("static_immutable_cache", [])
  844. # allow immutable cache for files
  845. if any(self.request.path.startswith(path) for path in immutable_paths):
  846. self.set_header("Cache-Control", "public, max-age=31536000, immutable")
  847. # disable browser caching, rely on 304 replies for savings
  848. elif "v" not in self.request.arguments or any(
  849. self.request.path.startswith(path) for path in self.no_cache_paths
  850. ):
  851. self.set_header("Cache-Control", "no-cache")
  852. def initialize(
  853. self,
  854. path: str | list[str],
  855. default_filename: str | None = None,
  856. no_cache_paths: list[str] | None = None,
  857. ) -> None:
  858. """Initialize the file find handler."""
  859. self.no_cache_paths = no_cache_paths or []
  860. if isinstance(path, str):
  861. path = [path]
  862. self.root = tuple(os.path.abspath(os.path.expanduser(p)) + os.sep for p in path) # type:ignore[assignment]
  863. self.default_filename = default_filename
  864. def compute_etag(self) -> str | None:
  865. """Compute the etag."""
  866. return None
  867. # access is allowed as this class is used to serve static assets on login page
  868. # TODO: create an allow-list of files used on login page and remove this decorator
  869. @allow_unauthenticated
  870. def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]:
  871. return super().get(path, include_body)
  872. # access is allowed as this class is used to serve static assets on login page
  873. # TODO: create an allow-list of files used on login page and remove this decorator
  874. @allow_unauthenticated
  875. def head(self, path: str) -> Awaitable[None]:
  876. return super().head(path)
  877. @classmethod
  878. def get_absolute_path(cls, roots: Sequence[str], path: str) -> str:
  879. """locate a file to serve on our static file search path"""
  880. with cls._lock:
  881. if path in cls._static_paths:
  882. return cls._static_paths[path]
  883. try:
  884. abspath = os.path.abspath(filefind(path, roots))
  885. except OSError:
  886. # IOError means not found
  887. return ""
  888. cls._static_paths[path] = abspath
  889. log().debug(f"Path {path} served from {abspath}")
  890. return abspath
  891. def validate_absolute_path(self, root: str, absolute_path: str) -> str | None:
  892. """check if the file should be served (raises 404, 403, etc.)"""
  893. if not absolute_path:
  894. raise web.HTTPError(404)
  895. for root in self.root:
  896. if (absolute_path + os.sep).startswith(root):
  897. break
  898. return super().validate_absolute_path(root, absolute_path)
  899. class APIVersionHandler(APIHandler):
  900. """An API handler for the server version."""
  901. _track_activity = False
  902. @allow_unauthenticated
  903. def get(self) -> None:
  904. """Get the server version info."""
  905. # not authenticated, so give as few info as possible
  906. self.finish(json.dumps({"version": jupyter_server.__version__}))
  907. class TrailingSlashHandler(web.RequestHandler):
  908. """Simple redirect handler that strips trailing slashes
  909. This should be the first, highest priority handler.
  910. """
  911. @allow_unauthenticated
  912. def get(self) -> None:
  913. """Handle trailing slashes in a get."""
  914. assert self.request.uri is not None
  915. path, *rest = self.request.uri.partition("?")
  916. # trim trailing *and* leading /
  917. # to avoid misinterpreting repeated '//'
  918. path = "/" + path.strip("/")
  919. new_uri = "".join([path, *rest])
  920. self.redirect(new_uri)
  921. post = put = get
  922. class MainHandler(JupyterHandler):
  923. """Simple handler for base_url."""
  924. @allow_unauthenticated
  925. def get(self) -> None:
  926. """Get the main template."""
  927. html = self.render_template("main.html")
  928. self.write(html)
  929. post = put = get
  930. class FilesRedirectHandler(JupyterHandler):
  931. """Handler for redirecting relative URLs to the /files/ handler"""
  932. @staticmethod
  933. async def redirect_to_files(self: Any, path: str) -> None:
  934. """make redirect logic a reusable static method
  935. so it can be called from other handlers.
  936. """
  937. cm = self.contents_manager
  938. if await ensure_async(cm.dir_exists(path)):
  939. # it's a *directory*, redirect to /tree
  940. url = url_path_join(self.base_url, "tree", url_escape(path))
  941. else:
  942. orig_path = path
  943. # otherwise, redirect to /files
  944. parts = path.split("/")
  945. if not await ensure_async(cm.file_exists(path=path)) and "files" in parts:
  946. # redirect without files/ iff it would 404
  947. # this preserves pre-2.0-style 'files/' links
  948. self.log.warning("Deprecated files/ URL: %s", orig_path)
  949. parts.remove("files")
  950. path = "/".join(parts)
  951. if not await ensure_async(cm.file_exists(path=path)):
  952. raise web.HTTPError(404)
  953. url = url_path_join(self.base_url, "files", url_escape(path))
  954. self.log.debug("Redirecting %s to %s", self.request.path, url)
  955. self.redirect(url)
  956. @allow_unauthenticated
  957. async def get(self, path: str = "") -> None:
  958. return await self.redirect_to_files(self, path)
  959. class RedirectWithParams(web.RequestHandler):
  960. """Same as web.RedirectHandler, but preserves URL parameters"""
  961. def initialize(self, url: str, permanent: bool = True) -> None:
  962. """Initialize a redirect handler."""
  963. self._url = url
  964. self._permanent = permanent
  965. @allow_unauthenticated
  966. def get(self) -> None:
  967. """Get a redirect."""
  968. sep = "&" if "?" in self._url else "?"
  969. url = sep.join([self._url, self.request.query])
  970. self.redirect(url, permanent=self._permanent)
  971. class PrometheusMetricsHandler(JupyterHandler):
  972. """
  973. Return prometheus metrics for this server
  974. """
  975. @allow_unauthenticated
  976. def get(self) -> None:
  977. """Get prometheus metrics."""
  978. if self.settings["authenticate_prometheus"] and not self.logged_in:
  979. raise web.HTTPError(403)
  980. self.set_header("Content-Type", prometheus_client.CONTENT_TYPE_LATEST)
  981. self.write(prometheus_client.generate_latest(prometheus_client.REGISTRY))
  982. class PublicStaticFileHandler(web.StaticFileHandler):
  983. """Same as web.StaticFileHandler, but decorated to acknowledge that auth is not required."""
  984. @allow_unauthenticated
  985. def head(self, path: str) -> Awaitable[None]:
  986. return super().head(path)
  987. @allow_unauthenticated
  988. def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]:
  989. return super().get(path, include_body)
  990. # -----------------------------------------------------------------------------
  991. # URL pattern fragments for reuse
  992. # -----------------------------------------------------------------------------
  993. # path matches any number of `/foo[/bar...]` or just `/` or ''
  994. path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))"
  995. # -----------------------------------------------------------------------------
  996. # URL to handler mappings
  997. # -----------------------------------------------------------------------------
  998. default_handlers = [
  999. (r".*/", TrailingSlashHandler),
  1000. (r"api", APIVersionHandler),
  1001. (r"/(robots\.txt|favicon\.ico)", PublicStaticFileHandler),
  1002. (r"/metrics", PrometheusMetricsHandler),
  1003. ]