You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1212 lines
44 KiB
Python
1212 lines
44 KiB
Python
"""Base Tornado handlers for the Jupyter server."""
|
|
|
|
# Copyright (c) Jupyter Development Team.
|
|
# Distributed under the terms of the Modified BSD License.
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import inspect
|
|
import ipaddress
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import types
|
|
import warnings
|
|
from collections.abc import Awaitable, Coroutine, Sequence
|
|
from http.client import responses
|
|
from logging import Logger
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
from urllib.parse import urlparse
|
|
|
|
import prometheus_client
|
|
from jinja2 import TemplateNotFound
|
|
from jupyter_core.paths import is_hidden
|
|
from tornado import web
|
|
from tornado.log import app_log
|
|
from traitlets.config import Application
|
|
|
|
import jupyter_server
|
|
from jupyter_server import CallContext
|
|
from jupyter_server._sysinfo import get_sys_info
|
|
from jupyter_server._tz import utcnow
|
|
from jupyter_server.auth.decorator import allow_unauthenticated, authorized
|
|
from jupyter_server.auth.identity import User
|
|
from jupyter_server.i18n import combine_translations
|
|
from jupyter_server.services.security import csp_report_uri
|
|
from jupyter_server.utils import (
|
|
ensure_async,
|
|
filefind,
|
|
url_escape,
|
|
url_is_absolute,
|
|
url_path_join,
|
|
urldecode_unix_socket_path,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from jupyter_client.kernelspec import KernelSpecManager
|
|
from jupyter_events import EventLogger
|
|
from jupyter_server_terminals.terminalmanager import TerminalManager
|
|
from tornado.concurrent import Future
|
|
|
|
from jupyter_server.auth.authorizer import Authorizer
|
|
from jupyter_server.auth.identity import IdentityProvider
|
|
from jupyter_server.serverapp import ServerApp
|
|
from jupyter_server.services.config.manager import ConfigManager
|
|
from jupyter_server.services.contents.manager import ContentsManager
|
|
from jupyter_server.services.kernels.kernelmanager import AsyncMappingKernelManager
|
|
from jupyter_server.services.sessions.sessionmanager import SessionManager
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Top-level handlers
|
|
# -----------------------------------------------------------------------------
|
|
|
|
_sys_info_cache = None
|
|
|
|
|
|
def json_sys_info():
|
|
"""Get sys info as json."""
|
|
global _sys_info_cache # noqa: PLW0603
|
|
if _sys_info_cache is None:
|
|
_sys_info_cache = json.dumps(get_sys_info())
|
|
return _sys_info_cache
|
|
|
|
|
|
def log() -> Logger:
|
|
"""Get the application log."""
|
|
if Application.initialized():
|
|
return cast(Logger, Application.instance().log)
|
|
else:
|
|
return app_log
|
|
|
|
|
|
class AuthenticatedHandler(web.RequestHandler):
|
|
"""A RequestHandler with an authenticated user."""
|
|
|
|
@property
|
|
def base_url(self) -> str:
|
|
return cast(str, self.settings.get("base_url", "/"))
|
|
|
|
@property
|
|
def content_security_policy(self) -> str:
|
|
"""The default Content-Security-Policy header
|
|
|
|
Can be overridden by defining Content-Security-Policy in settings['headers']
|
|
"""
|
|
if "Content-Security-Policy" in self.settings.get("headers", {}):
|
|
# user-specified, don't override
|
|
return cast(str, self.settings["headers"]["Content-Security-Policy"])
|
|
|
|
return "; ".join(
|
|
[
|
|
"frame-ancestors 'self'",
|
|
# Make sure the report-uri is relative to the base_url
|
|
"report-uri "
|
|
+ self.settings.get("csp_report_uri", url_path_join(self.base_url, csp_report_uri)),
|
|
]
|
|
)
|
|
|
|
def set_default_headers(self) -> None:
|
|
"""Set the default headers."""
|
|
headers = {}
|
|
headers["X-Content-Type-Options"] = "nosniff"
|
|
headers.update(self.settings.get("headers", {}))
|
|
|
|
headers["Content-Security-Policy"] = self.content_security_policy
|
|
|
|
# Allow for overriding headers
|
|
for header_name, value in headers.items():
|
|
try:
|
|
self.set_header(header_name, value)
|
|
except Exception as e:
|
|
# tornado raise Exception (not a subclass)
|
|
# if method is unsupported (websocket and Access-Control-Allow-Origin
|
|
# for example, so just ignore)
|
|
self.log.exception( # type:ignore[attr-defined]
|
|
"Could not set default headers: %s", e
|
|
)
|
|
|
|
@property
|
|
def cookie_name(self) -> str:
|
|
warnings.warn(
|
|
"""JupyterHandler.login_handler is deprecated in 2.0,
|
|
use JupyterHandler.identity_provider.
|
|
""",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
return self.identity_provider.get_cookie_name(self)
|
|
|
|
def force_clear_cookie(self, name: str, path: str = "/", domain: str | None = None) -> None:
|
|
"""Force a cookie clear."""
|
|
warnings.warn(
|
|
"""JupyterHandler.login_handler is deprecated in 2.0,
|
|
use JupyterHandler.identity_provider.
|
|
""",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
self.identity_provider._force_clear_cookie(self, name, path=path, domain=domain)
|
|
|
|
def clear_login_cookie(self) -> None:
|
|
"""Clear a login cookie."""
|
|
warnings.warn(
|
|
"""JupyterHandler.login_handler is deprecated in 2.0,
|
|
use JupyterHandler.identity_provider.
|
|
""",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
self.identity_provider.clear_login_cookie(self)
|
|
|
|
def get_current_user(self) -> str:
|
|
"""Get the current user."""
|
|
clsname = self.__class__.__name__
|
|
msg = (
|
|
f"Calling `{clsname}.get_current_user()` directly is deprecated in jupyter-server 2.0."
|
|
" Use `self.current_user` instead (works in all versions)."
|
|
)
|
|
if hasattr(self, "_jupyter_current_user"):
|
|
# backward-compat: return _jupyter_current_user
|
|
warnings.warn(
|
|
msg,
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
return cast(str, self._jupyter_current_user)
|
|
# haven't called get_user in prepare, raise
|
|
raise RuntimeError(msg)
|
|
|
|
def skip_check_origin(self) -> bool:
|
|
"""Ask my login_handler if I should skip the origin_check
|
|
|
|
For example: in the default LoginHandler, if a request is token-authenticated,
|
|
origin checking should be skipped.
|
|
"""
|
|
if self.request.method == "OPTIONS":
|
|
# no origin-check on options requests, which are used to check origins!
|
|
return True
|
|
return not self.identity_provider.should_check_origin(self)
|
|
|
|
@property
|
|
def token_authenticated(self) -> bool:
|
|
"""Have I been authenticated with a token?"""
|
|
return self.identity_provider.is_token_authenticated(self)
|
|
|
|
@property
|
|
def logged_in(self) -> bool:
|
|
"""Is a user currently logged in?"""
|
|
user = self.current_user
|
|
return bool(user and user != "anonymous")
|
|
|
|
@property
|
|
def login_handler(self) -> Any:
|
|
"""Return the login handler for this application, if any."""
|
|
warnings.warn(
|
|
"""JupyterHandler.login_handler is deprecated in 2.0,
|
|
use JupyterHandler.identity_provider.
|
|
""",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
return self.identity_provider.login_handler_class
|
|
|
|
@property
|
|
def token(self) -> str | None:
|
|
"""Return the login token for this application, if any."""
|
|
return self.identity_provider.token
|
|
|
|
@property
|
|
def login_available(self) -> bool:
|
|
"""May a user proceed to log in?
|
|
|
|
This returns True if login capability is available, irrespective of
|
|
whether the user is already logged in or not.
|
|
|
|
"""
|
|
return cast(bool, self.identity_provider.login_available)
|
|
|
|
@property
|
|
def authorizer(self) -> Authorizer:
|
|
if "authorizer" not in self.settings:
|
|
warnings.warn(
|
|
"The Tornado web application does not have an 'authorizer' defined "
|
|
"in its settings. In future releases of jupyter_server, this will "
|
|
"be a required key for all subclasses of `JupyterHandler`. For an "
|
|
"example, see the jupyter_server source code for how to "
|
|
"add an authorizer to the tornado settings: "
|
|
"https://github.com/jupyter-server/jupyter_server/blob/"
|
|
"653740cbad7ce0c8a8752ce83e4d3c2c754b13cb/jupyter_server/serverapp.py"
|
|
"#L234-L256",
|
|
stacklevel=2,
|
|
)
|
|
from jupyter_server.auth import AllowAllAuthorizer
|
|
|
|
self.settings["authorizer"] = AllowAllAuthorizer(
|
|
config=self.settings.get("config", None),
|
|
identity_provider=self.identity_provider,
|
|
)
|
|
|
|
return cast("Authorizer", self.settings.get("authorizer"))
|
|
|
|
@property
|
|
def identity_provider(self) -> IdentityProvider:
|
|
if "identity_provider" not in self.settings:
|
|
warnings.warn(
|
|
"The Tornado web application does not have an 'identity_provider' defined "
|
|
"in its settings. In future releases of jupyter_server, this will "
|
|
"be a required key for all subclasses of `JupyterHandler`. For an "
|
|
"example, see the jupyter_server source code for how to "
|
|
"add an identity provider to the tornado settings: "
|
|
"https://github.com/jupyter-server/jupyter_server/blob/v2.0.0/"
|
|
"jupyter_server/serverapp.py#L242",
|
|
stacklevel=2,
|
|
)
|
|
from jupyter_server.auth import IdentityProvider
|
|
|
|
# no identity provider set, load default
|
|
self.settings["identity_provider"] = IdentityProvider(
|
|
config=self.settings.get("config", None)
|
|
)
|
|
return cast("IdentityProvider", self.settings["identity_provider"])
|
|
|
|
|
|
class JupyterHandler(AuthenticatedHandler):
|
|
"""Jupyter-specific extensions to authenticated handling
|
|
|
|
Mostly property shortcuts to Jupyter-specific settings.
|
|
"""
|
|
|
|
@property
|
|
def config(self) -> dict[str, Any] | None:
|
|
return cast("dict[str, Any] | None", self.settings.get("config", None))
|
|
|
|
@property
|
|
def log(self) -> Logger:
|
|
"""use the Jupyter log by default, falling back on tornado's logger"""
|
|
return log()
|
|
|
|
@property
|
|
def jinja_template_vars(self) -> dict[str, Any]:
|
|
"""User-supplied values to supply to jinja templates."""
|
|
return cast("dict[str, Any]", self.settings.get("jinja_template_vars", {}))
|
|
|
|
@property
|
|
def serverapp(self) -> ServerApp | None:
|
|
return cast("ServerApp | None", self.settings["serverapp"])
|
|
|
|
# ---------------------------------------------------------------
|
|
# URLs
|
|
# ---------------------------------------------------------------
|
|
|
|
@property
|
|
def version_hash(self) -> str:
|
|
"""The version hash to use for cache hints for static files"""
|
|
return cast(str, self.settings.get("version_hash", ""))
|
|
|
|
@property
|
|
def mathjax_url(self) -> str:
|
|
url = cast(str, self.settings.get("mathjax_url", ""))
|
|
if not url or url_is_absolute(url):
|
|
return url
|
|
return url_path_join(self.base_url, url)
|
|
|
|
@property
|
|
def mathjax_config(self) -> str:
|
|
return cast(str, self.settings.get("mathjax_config", "TeX-AMS-MML_HTMLorMML-full,Safe"))
|
|
|
|
@property
|
|
def default_url(self) -> str:
|
|
return cast(str, self.settings.get("default_url", ""))
|
|
|
|
@property
|
|
def ws_url(self) -> str:
|
|
return cast(str, self.settings.get("websocket_url", ""))
|
|
|
|
@property
|
|
def contents_js_source(self) -> str:
|
|
self.log.debug(
|
|
"Using contents: %s",
|
|
self.settings.get("contents_js_source", "services/contents"),
|
|
)
|
|
return cast(str, self.settings.get("contents_js_source", "services/contents"))
|
|
|
|
# ---------------------------------------------------------------
|
|
# Manager objects
|
|
# ---------------------------------------------------------------
|
|
|
|
@property
|
|
def kernel_manager(self) -> AsyncMappingKernelManager:
|
|
return cast("AsyncMappingKernelManager", self.settings["kernel_manager"])
|
|
|
|
@property
|
|
def contents_manager(self) -> ContentsManager:
|
|
return cast("ContentsManager", self.settings["contents_manager"])
|
|
|
|
@property
|
|
def session_manager(self) -> SessionManager:
|
|
return cast("SessionManager", self.settings["session_manager"])
|
|
|
|
@property
|
|
def terminal_manager(self) -> TerminalManager:
|
|
return cast("TerminalManager", self.settings["terminal_manager"])
|
|
|
|
@property
|
|
def kernel_spec_manager(self) -> KernelSpecManager:
|
|
return cast("KernelSpecManager", self.settings["kernel_spec_manager"])
|
|
|
|
@property
|
|
def config_manager(self) -> ConfigManager:
|
|
return cast("ConfigManager", self.settings["config_manager"])
|
|
|
|
@property
|
|
def event_logger(self) -> EventLogger:
|
|
return cast("EventLogger", self.settings["event_logger"])
|
|
|
|
# ---------------------------------------------------------------
|
|
# CORS
|
|
# ---------------------------------------------------------------
|
|
|
|
@property
|
|
def allow_origin(self) -> str:
|
|
"""Normal Access-Control-Allow-Origin"""
|
|
return cast(str, self.settings.get("allow_origin", ""))
|
|
|
|
@property
|
|
def allow_origin_pat(self) -> str | None:
|
|
"""Regular expression version of allow_origin"""
|
|
return cast("str | None", self.settings.get("allow_origin_pat", None))
|
|
|
|
@property
|
|
def allow_credentials(self) -> bool:
|
|
"""Whether to set Access-Control-Allow-Credentials"""
|
|
return cast(bool, self.settings.get("allow_credentials", False))
|
|
|
|
def set_default_headers(self) -> None:
|
|
"""Add CORS headers, if defined"""
|
|
super().set_default_headers()
|
|
|
|
def set_cors_headers(self) -> None:
|
|
"""Add CORS headers, if defined
|
|
|
|
Now that current_user is async (jupyter-server 2.0),
|
|
must be called at the end of prepare(), instead of in set_default_headers.
|
|
"""
|
|
if self.allow_origin:
|
|
self.set_header("Access-Control-Allow-Origin", self.allow_origin)
|
|
elif self.allow_origin_pat:
|
|
origin = self.get_origin()
|
|
if origin and re.match(self.allow_origin_pat, origin):
|
|
self.set_header("Access-Control-Allow-Origin", origin)
|
|
elif self.token_authenticated and "Access-Control-Allow-Origin" not in self.settings.get(
|
|
"headers", {}
|
|
):
|
|
# allow token-authenticated requests cross-origin by default.
|
|
# only apply this exception if allow-origin has not been specified.
|
|
self.set_header("Access-Control-Allow-Origin", self.request.headers.get("Origin", ""))
|
|
|
|
if self.allow_credentials:
|
|
self.set_header("Access-Control-Allow-Credentials", "true")
|
|
|
|
def set_attachment_header(self, filename: str) -> None:
|
|
"""Set Content-Disposition: attachment header
|
|
|
|
As a method to ensure handling of filename encoding
|
|
"""
|
|
escaped_filename = url_escape(filename)
|
|
self.set_header(
|
|
"Content-Disposition",
|
|
f"attachment; filename*=utf-8''{escaped_filename}",
|
|
)
|
|
|
|
def get_origin(self) -> str | None:
|
|
# Handle WebSocket Origin naming convention differences
|
|
# The difference between version 8 and 13 is that in 8 the
|
|
# client sends a "Sec-Websocket-Origin" header and in 13 it's
|
|
# simply "Origin".
|
|
if "Origin" in self.request.headers:
|
|
origin = self.request.headers.get("Origin")
|
|
else:
|
|
origin = self.request.headers.get("Sec-Websocket-Origin", None)
|
|
return origin
|
|
|
|
# origin_to_satisfy_tornado is present because tornado requires
|
|
# check_origin to take an origin argument, but we don't use it
|
|
def check_origin(self, origin_to_satisfy_tornado: str = "") -> bool:
|
|
"""Check Origin for cross-site API requests, including websockets
|
|
|
|
Copied from WebSocket with changes:
|
|
|
|
- allow unspecified host/origin (e.g. scripts)
|
|
- allow token-authenticated requests
|
|
"""
|
|
if self.allow_origin == "*" or self.skip_check_origin():
|
|
return True
|
|
|
|
host = self.request.headers.get("Host")
|
|
origin = self.request.headers.get("Origin")
|
|
|
|
# If no header is provided, let the request through.
|
|
# Origin can be None for:
|
|
# - same-origin (IE, Firefox)
|
|
# - Cross-site POST form (IE, Firefox)
|
|
# - Scripts
|
|
# The cross-site POST (XSRF) case is handled by tornado's xsrf_token
|
|
if origin is None or host is None:
|
|
return True
|
|
|
|
origin = origin.lower()
|
|
origin_host = urlparse(origin).netloc
|
|
|
|
# OK if origin matches host
|
|
if origin_host == host:
|
|
return True
|
|
|
|
# Check CORS headers
|
|
if self.allow_origin:
|
|
allow = bool(self.allow_origin == origin)
|
|
elif self.allow_origin_pat:
|
|
allow = bool(re.match(self.allow_origin_pat, origin))
|
|
else:
|
|
# No CORS headers deny the request
|
|
allow = False
|
|
if not allow:
|
|
self.log.warning(
|
|
"Blocking Cross Origin API request for %s. Origin: %s, Host: %s",
|
|
self.request.path,
|
|
origin,
|
|
host,
|
|
)
|
|
return allow
|
|
|
|
def check_referer(self) -> bool:
|
|
"""Check Referer for cross-site requests.
|
|
Disables requests to certain endpoints with
|
|
external or missing Referer.
|
|
If set, allow_origin settings are applied to the Referer
|
|
to whitelist specific cross-origin sites.
|
|
Used on GET for api endpoints and /files/
|
|
to block cross-site inclusion (XSSI).
|
|
"""
|
|
if self.allow_origin == "*" or self.skip_check_origin():
|
|
return True
|
|
|
|
host = self.request.headers.get("Host")
|
|
referer = self.request.headers.get("Referer")
|
|
|
|
if not host:
|
|
self.log.warning("Blocking request with no host")
|
|
return False
|
|
if not referer:
|
|
self.log.warning("Blocking request with no referer")
|
|
return False
|
|
|
|
referer_url = urlparse(referer)
|
|
referer_host = referer_url.netloc
|
|
if referer_host == host:
|
|
return True
|
|
|
|
# apply cross-origin checks to Referer:
|
|
origin = f"{referer_url.scheme}://{referer_url.netloc}"
|
|
if self.allow_origin:
|
|
allow = self.allow_origin == origin
|
|
elif self.allow_origin_pat:
|
|
allow = bool(re.match(self.allow_origin_pat, origin))
|
|
else:
|
|
# No CORS settings, deny the request
|
|
allow = False
|
|
|
|
if not allow:
|
|
self.log.warning(
|
|
"Blocking Cross Origin request for %s. Referer: %s, Host: %s",
|
|
self.request.path,
|
|
origin,
|
|
host,
|
|
)
|
|
return allow
|
|
|
|
def check_xsrf_cookie(self) -> None:
|
|
"""Bypass xsrf cookie checks when token-authenticated"""
|
|
if not hasattr(self, "_jupyter_current_user"):
|
|
# Called too early, will be checked later
|
|
return None
|
|
if self.token_authenticated or self.settings.get("disable_check_xsrf", False):
|
|
# Token-authenticated requests do not need additional XSRF-check
|
|
# Servers without authentication are vulnerable to XSRF
|
|
return None
|
|
try:
|
|
if not self.check_origin():
|
|
raise web.HTTPError(404)
|
|
return super().check_xsrf_cookie()
|
|
except web.HTTPError as e:
|
|
if self.request.method in {"GET", "HEAD"}:
|
|
# Consider Referer a sufficient cross-origin check for GET requests
|
|
if not self.check_referer():
|
|
referer = self.request.headers.get("Referer")
|
|
if referer:
|
|
msg = f"Blocking Cross Origin request from {referer}."
|
|
else:
|
|
msg = "Blocking request from unknown origin"
|
|
raise web.HTTPError(403, msg) from e
|
|
else:
|
|
raise
|
|
|
|
def check_host(self) -> bool:
|
|
"""Check the host header if remote access disallowed.
|
|
|
|
Returns True if the request should continue, False otherwise.
|
|
"""
|
|
if self.settings.get("allow_remote_access", False):
|
|
return True
|
|
|
|
# Remove port (e.g. ':8888') from host
|
|
match = re.match(r"^(.*?)(:\d+)?$", self.request.host)
|
|
assert match is not None
|
|
host = match.group(1)
|
|
|
|
# Browsers format IPv6 addresses like [::1]; we need to remove the []
|
|
if host.startswith("[") and host.endswith("]"):
|
|
host = host[1:-1]
|
|
|
|
# UNIX socket handling
|
|
check_host = urldecode_unix_socket_path(host)
|
|
if check_host.startswith("/") and os.path.exists(check_host):
|
|
allow = True
|
|
else:
|
|
try:
|
|
addr = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
# Not an IP address: check against hostnames
|
|
allow = host in self.settings.get("local_hostnames", ["localhost"])
|
|
else:
|
|
allow = addr.is_loopback
|
|
|
|
if not allow:
|
|
self.log.warning(
|
|
(
|
|
"Blocking request with non-local 'Host' %s (%s). "
|
|
"If the server should be accessible at that name, "
|
|
"set ServerApp.allow_remote_access to disable the check."
|
|
),
|
|
host,
|
|
self.request.host,
|
|
)
|
|
return allow
|
|
|
|
async def prepare(self, *, _redirect_to_login=True) -> Awaitable[None] | None: # type:ignore[override]
|
|
"""Prepare a response."""
|
|
# Set the current Jupyter Handler context variable.
|
|
CallContext.set(CallContext.JUPYTER_HANDLER, self)
|
|
|
|
if not self.check_host():
|
|
self.current_user = self._jupyter_current_user = None
|
|
raise web.HTTPError(403)
|
|
|
|
from jupyter_server.auth import IdentityProvider
|
|
|
|
mod_obj = inspect.getmodule(self.get_current_user)
|
|
assert mod_obj is not None
|
|
user: User | None = None
|
|
|
|
if type(self.identity_provider) is IdentityProvider and mod_obj.__name__ != __name__:
|
|
# check for overridden get_current_user + default IdentityProvider
|
|
# deprecated way to override auth (e.g. JupyterHub < 3.0)
|
|
# allow deprecated, overridden get_current_user
|
|
warnings.warn(
|
|
"Overriding JupyterHandler.get_current_user is deprecated in jupyter-server 2.0."
|
|
" Use an IdentityProvider class.",
|
|
DeprecationWarning,
|
|
stacklevel=1,
|
|
)
|
|
user = User(self.get_current_user())
|
|
else:
|
|
_user = self.identity_provider.get_user(self)
|
|
if isinstance(_user, Awaitable):
|
|
# IdentityProvider.get_user _may_ be async
|
|
_user = await _user
|
|
user = _user
|
|
|
|
# self.current_user for tornado's @web.authenticated
|
|
# self._jupyter_current_user for backward-compat in deprecated get_current_user calls
|
|
# and our own private checks for whether .current_user has been set
|
|
self.current_user = self._jupyter_current_user = user
|
|
# complete initial steps which require auth to resolve first:
|
|
self.set_cors_headers()
|
|
if self.request.method not in {"GET", "HEAD", "OPTIONS"}:
|
|
self.check_xsrf_cookie()
|
|
|
|
if not self.settings.get("allow_unauthenticated_access", False):
|
|
if not self.request.method:
|
|
raise HTTPError(403)
|
|
method = getattr(self, self.request.method.lower())
|
|
if not getattr(method, "__allow_unauthenticated", False):
|
|
if _redirect_to_login:
|
|
# reuse `web.authenticated` logic, which redirects to the login
|
|
# page on GET and HEAD and otherwise raises 403
|
|
return web.authenticated(lambda _: super().prepare())(self)
|
|
else:
|
|
# raise 403 if user is not known without redirecting to login page
|
|
user = self.current_user
|
|
if user is None:
|
|
self.log.warning(
|
|
f"Couldn't authenticate {self.__class__.__name__} connection"
|
|
)
|
|
raise web.HTTPError(403)
|
|
|
|
return super().prepare()
|
|
|
|
# ---------------------------------------------------------------
|
|
# template rendering
|
|
# ---------------------------------------------------------------
|
|
|
|
def get_template(self, name):
|
|
"""Return the jinja template object for a given name"""
|
|
return self.settings["jinja2_env"].get_template(name)
|
|
|
|
def render_template(self, name, **ns):
|
|
"""Render a template by name."""
|
|
ns.update(self.template_namespace)
|
|
template = self.get_template(name)
|
|
return template.render(**ns)
|
|
|
|
@property
|
|
def template_namespace(self) -> dict[str, Any]:
|
|
return dict(
|
|
base_url=self.base_url,
|
|
default_url=self.default_url,
|
|
ws_url=self.ws_url,
|
|
logged_in=self.logged_in,
|
|
allow_password_change=getattr(self.identity_provider, "allow_password_change", False),
|
|
auth_enabled=self.identity_provider.auth_enabled,
|
|
login_available=self.identity_provider.login_available,
|
|
token_available=bool(self.token),
|
|
static_url=self.static_url,
|
|
sys_info=json_sys_info(),
|
|
contents_js_source=self.contents_js_source,
|
|
version_hash=self.version_hash,
|
|
xsrf_form_html=self.xsrf_form_html,
|
|
token=self.token,
|
|
xsrf_token=self.xsrf_token.decode("utf8"),
|
|
nbjs_translations=json.dumps(
|
|
combine_translations(self.request.headers.get("Accept-Language", ""))
|
|
),
|
|
**self.jinja_template_vars,
|
|
)
|
|
|
|
def get_json_body(self) -> dict[str, Any] | None:
|
|
"""Return the body of the request as JSON data."""
|
|
if not self.request.body:
|
|
return None
|
|
# Do we need to call body.decode('utf-8') here?
|
|
body = self.request.body.strip().decode("utf-8")
|
|
try:
|
|
model = json.loads(body)
|
|
except Exception as e:
|
|
self.log.debug("Bad JSON: %r", body)
|
|
self.log.error("Couldn't parse JSON", exc_info=True)
|
|
raise web.HTTPError(400, "Invalid JSON in body of request") from e
|
|
return cast("dict[str, Any]", model)
|
|
|
|
def write_error(self, status_code: int, **kwargs: Any) -> None:
|
|
"""render custom error pages"""
|
|
exc_info = kwargs.get("exc_info")
|
|
message = ""
|
|
status_message = responses.get(status_code, "Unknown HTTP Error")
|
|
|
|
if exc_info:
|
|
exception = exc_info[1]
|
|
# get the custom message, if defined
|
|
try:
|
|
message = exception.log_message % exception.args
|
|
except Exception:
|
|
pass
|
|
|
|
# construct the custom reason, if defined
|
|
reason = getattr(exception, "reason", "")
|
|
if reason:
|
|
status_message = reason
|
|
else:
|
|
exception = "(unknown)"
|
|
|
|
# build template namespace
|
|
ns = {
|
|
"status_code": status_code,
|
|
"status_message": status_message,
|
|
"message": message,
|
|
"exception": exception,
|
|
}
|
|
|
|
self.set_header("Content-Type", "text/html")
|
|
# render the template
|
|
try:
|
|
html = self.render_template("%s.html" % status_code, **ns)
|
|
except TemplateNotFound:
|
|
html = self.render_template("error.html", **ns)
|
|
|
|
self.write(html)
|
|
|
|
|
|
class APIHandler(JupyterHandler):
|
|
"""Base class for API handlers"""
|
|
|
|
async def prepare(self) -> None: # type:ignore[override]
|
|
"""Prepare an API response."""
|
|
await super().prepare()
|
|
if not self.check_origin():
|
|
raise web.HTTPError(404)
|
|
|
|
def write_error(self, status_code: int, **kwargs: Any) -> None:
|
|
"""APIHandler errors are JSON, not human pages"""
|
|
self.set_header("Content-Type", "application/json")
|
|
message = responses.get(status_code, "Unknown HTTP Error")
|
|
reply: dict[str, Any] = {
|
|
"message": message,
|
|
}
|
|
exc_info = kwargs.get("exc_info")
|
|
if exc_info:
|
|
e = exc_info[1]
|
|
if isinstance(e, HTTPError):
|
|
reply["message"] = e.log_message or message
|
|
reply["reason"] = e.reason
|
|
else:
|
|
reply["message"] = "Unhandled error"
|
|
reply["reason"] = None
|
|
# backward-compatibility: traceback field is present,
|
|
# but always empty
|
|
reply["traceback"] = ""
|
|
self.log.warning("wrote error: %r", reply["message"], exc_info=True)
|
|
self.finish(json.dumps(reply))
|
|
|
|
def get_login_url(self) -> str:
|
|
"""Get the login url."""
|
|
# if get_login_url is invoked in an API handler,
|
|
# that means @web.authenticated is trying to trigger a redirect.
|
|
# instead of redirecting, raise 403 instead.
|
|
if not self.current_user:
|
|
raise web.HTTPError(403)
|
|
return super().get_login_url()
|
|
|
|
@property
|
|
def content_security_policy(self) -> str:
|
|
csp = "; ".join( # noqa: FLY002
|
|
[
|
|
super().content_security_policy,
|
|
"default-src 'none'",
|
|
]
|
|
)
|
|
return csp
|
|
|
|
# set _track_activity = False on API handlers that shouldn't track activity
|
|
_track_activity = True
|
|
|
|
def update_api_activity(self) -> None:
|
|
"""Update last_activity of API requests"""
|
|
# record activity of authenticated requests
|
|
if (
|
|
self._track_activity
|
|
and getattr(self, "_jupyter_current_user", None)
|
|
and self.get_argument("no_track_activity", None) is None
|
|
):
|
|
self.settings["api_last_activity"] = utcnow()
|
|
|
|
def finish(self, *args: Any, **kwargs: Any) -> Future[Any]:
|
|
"""Finish an API response."""
|
|
self.update_api_activity()
|
|
# Allow caller to indicate content-type...
|
|
set_content_type = kwargs.pop("set_content_type", "application/json")
|
|
self.set_header("Content-Type", set_content_type)
|
|
return super().finish(*args, **kwargs)
|
|
|
|
@allow_unauthenticated
|
|
def options(self, *args: Any, **kwargs: Any) -> None:
|
|
"""Get the options."""
|
|
if "Access-Control-Allow-Headers" in self.settings.get("headers", {}):
|
|
self.set_header(
|
|
"Access-Control-Allow-Headers",
|
|
self.settings["headers"]["Access-Control-Allow-Headers"],
|
|
)
|
|
else:
|
|
self.set_header(
|
|
"Access-Control-Allow-Headers",
|
|
"accept, content-type, authorization, x-xsrftoken",
|
|
)
|
|
self.set_header("Access-Control-Allow-Methods", "GET, PUT, POST, PATCH, DELETE, OPTIONS")
|
|
|
|
# if authorization header is requested,
|
|
# that means the request is token-authenticated.
|
|
# avoid browser-side rejection of the preflight request.
|
|
# only allow this exception if allow_origin has not been specified
|
|
# and Jupyter server authentication is enabled.
|
|
# If the token is not valid, the 'real' request will still be rejected.
|
|
requested_headers = self.request.headers.get("Access-Control-Request-Headers", "").split(
|
|
","
|
|
)
|
|
if (
|
|
requested_headers
|
|
and any(h.strip().lower() == "authorization" for h in requested_headers)
|
|
and (
|
|
# FIXME: it would be even better to check specifically for token-auth,
|
|
# but there is currently no API for this.
|
|
self.login_available
|
|
)
|
|
and (
|
|
self.allow_origin
|
|
or self.allow_origin_pat
|
|
or "Access-Control-Allow-Origin" in self.settings.get("headers", {})
|
|
)
|
|
):
|
|
self.set_header("Access-Control-Allow-Origin", self.request.headers.get("Origin", ""))
|
|
|
|
|
|
class Template404(JupyterHandler):
|
|
"""Render our 404 template"""
|
|
|
|
async def prepare(self) -> None: # type:ignore[override]
|
|
"""Prepare a 404 response."""
|
|
await super().prepare()
|
|
raise web.HTTPError(404)
|
|
|
|
|
|
class AuthenticatedFileHandler(JupyterHandler, web.StaticFileHandler):
|
|
"""static files should only be accessible when logged in"""
|
|
|
|
auth_resource = "contents"
|
|
|
|
@property
|
|
def content_security_policy(self) -> str:
|
|
# In case we're serving HTML/SVG, confine any Javascript to a unique
|
|
# origin so it can't interact with the Jupyter server.
|
|
return super().content_security_policy + "; sandbox allow-scripts"
|
|
|
|
@web.authenticated
|
|
@authorized
|
|
def head(self, path: str) -> Awaitable[None]: # type:ignore[override]
|
|
"""Get the head response for a path."""
|
|
self.check_xsrf_cookie()
|
|
return super().head(path)
|
|
|
|
@web.authenticated
|
|
@authorized
|
|
def get( # type:ignore[override]
|
|
self, path: str, **kwargs: Any
|
|
) -> Awaitable[None]:
|
|
"""Get a file by path."""
|
|
self.check_xsrf_cookie()
|
|
if os.path.splitext(path)[1] == ".ipynb" or self.get_argument("download", None):
|
|
name = path.rsplit("/", 1)[-1]
|
|
self.set_attachment_header(name)
|
|
|
|
return web.StaticFileHandler.get(self, path, **kwargs)
|
|
|
|
def get_content_type(self) -> str:
|
|
"""Get the content type."""
|
|
assert self.absolute_path is not None
|
|
path = self.absolute_path.strip("/")
|
|
if "/" in path:
|
|
_, name = path.rsplit("/", 1)
|
|
else:
|
|
name = path
|
|
if name.endswith(".ipynb"):
|
|
return "application/x-ipynb+json"
|
|
else:
|
|
cur_mime = mimetypes.guess_type(name)[0]
|
|
if cur_mime == "text/plain":
|
|
return "text/plain; charset=UTF-8"
|
|
else:
|
|
return super().get_content_type()
|
|
|
|
def set_headers(self) -> None:
|
|
"""Set the headers."""
|
|
super().set_headers()
|
|
# disable browser caching, rely on 304 replies for savings
|
|
if "v" not in self.request.arguments:
|
|
self.add_header("Cache-Control", "no-cache")
|
|
|
|
def compute_etag(self) -> str | None:
|
|
"""Compute the etag."""
|
|
return None
|
|
|
|
def validate_absolute_path(self, root: str, absolute_path: str) -> str:
|
|
"""Validate and return the absolute path.
|
|
|
|
Requires tornado 3.1
|
|
|
|
Adding to tornado's own handling, forbids the serving of hidden files.
|
|
"""
|
|
abs_path = super().validate_absolute_path(root, absolute_path)
|
|
abs_root = os.path.abspath(root)
|
|
assert abs_path is not None
|
|
if not self.contents_manager.allow_hidden and is_hidden(abs_path, abs_root):
|
|
self.log.info(
|
|
"Refusing to serve hidden file, via 404 Error, use flag 'ContentsManager.allow_hidden' to enable"
|
|
)
|
|
raise web.HTTPError(404)
|
|
return abs_path
|
|
|
|
|
|
def json_errors(method: Any) -> Any: # pragma: no cover
|
|
"""Decorate methods with this to return GitHub style JSON errors.
|
|
|
|
This should be used on any JSON API on any handler method that can raise HTTPErrors.
|
|
|
|
This will grab the latest HTTPError exception using sys.exc_info
|
|
and then:
|
|
|
|
1. Set the HTTP status code based on the HTTPError
|
|
2. Create and return a JSON body with a message field describing
|
|
the error in a human readable form.
|
|
"""
|
|
warnings.warn(
|
|
"@json_errors is deprecated in notebook 5.2.0. Subclass APIHandler instead.",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
|
|
@functools.wraps(method)
|
|
def wrapper(self, *args, **kwargs):
|
|
self.write_error = types.MethodType(APIHandler.write_error, self)
|
|
return method(self, *args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# File handler
|
|
# -----------------------------------------------------------------------------
|
|
|
|
# to minimize subclass changes:
|
|
HTTPError = web.HTTPError
|
|
|
|
|
|
class FileFindHandler(JupyterHandler, web.StaticFileHandler):
|
|
"""subclass of StaticFileHandler for serving files from a search path
|
|
|
|
The setting "static_immutable_cache" can be set up to serve some static
|
|
file as immutable (e.g. file name containing a hash). The setting is a
|
|
list of base URL, every static file URL starting with one of those will
|
|
be immutable.
|
|
"""
|
|
|
|
# cache search results, don't search for files more than once
|
|
_static_paths: dict[str, str] = {}
|
|
root: tuple[str] # type:ignore[assignment]
|
|
|
|
def set_headers(self) -> None:
|
|
"""Set the headers."""
|
|
super().set_headers()
|
|
|
|
immutable_paths = self.settings.get("static_immutable_cache", [])
|
|
|
|
# allow immutable cache for files
|
|
if any(self.request.path.startswith(path) for path in immutable_paths):
|
|
self.set_header("Cache-Control", "public, max-age=31536000, immutable")
|
|
|
|
# disable browser caching, rely on 304 replies for savings
|
|
elif "v" not in self.request.arguments or any(
|
|
self.request.path.startswith(path) for path in self.no_cache_paths
|
|
):
|
|
self.set_header("Cache-Control", "no-cache")
|
|
|
|
def initialize(
|
|
self,
|
|
path: str | list[str],
|
|
default_filename: str | None = None,
|
|
no_cache_paths: list[str] | None = None,
|
|
) -> None:
|
|
"""Initialize the file find handler."""
|
|
self.no_cache_paths = no_cache_paths or []
|
|
|
|
if isinstance(path, str):
|
|
path = [path]
|
|
|
|
self.root = tuple(os.path.abspath(os.path.expanduser(p)) + os.sep for p in path) # type:ignore[assignment]
|
|
self.default_filename = default_filename
|
|
|
|
def compute_etag(self) -> str | None:
|
|
"""Compute the etag."""
|
|
return None
|
|
|
|
# access is allowed as this class is used to serve static assets on login page
|
|
# TODO: create an allow-list of files used on login page and remove this decorator
|
|
@allow_unauthenticated
|
|
def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]:
|
|
return super().get(path, include_body)
|
|
|
|
# access is allowed as this class is used to serve static assets on login page
|
|
# TODO: create an allow-list of files used on login page and remove this decorator
|
|
@allow_unauthenticated
|
|
def head(self, path: str) -> Awaitable[None]:
|
|
return super().head(path)
|
|
|
|
@classmethod
|
|
def get_absolute_path(cls, roots: Sequence[str], path: str) -> str:
|
|
"""locate a file to serve on our static file search path"""
|
|
with cls._lock:
|
|
if path in cls._static_paths:
|
|
return cls._static_paths[path]
|
|
try:
|
|
abspath = os.path.abspath(filefind(path, roots))
|
|
except OSError:
|
|
# IOError means not found
|
|
return ""
|
|
|
|
cls._static_paths[path] = abspath
|
|
|
|
log().debug(f"Path {path} served from {abspath}")
|
|
return abspath
|
|
|
|
def validate_absolute_path(self, root: str, absolute_path: str) -> str | None:
|
|
"""check if the file should be served (raises 404, 403, etc.)"""
|
|
if not absolute_path:
|
|
raise web.HTTPError(404)
|
|
|
|
for root in self.root:
|
|
if (absolute_path + os.sep).startswith(root):
|
|
break
|
|
|
|
return super().validate_absolute_path(root, absolute_path)
|
|
|
|
|
|
class APIVersionHandler(APIHandler):
|
|
"""An API handler for the server version."""
|
|
|
|
_track_activity = False
|
|
|
|
@allow_unauthenticated
|
|
def get(self) -> None:
|
|
"""Get the server version info."""
|
|
# not authenticated, so give as few info as possible
|
|
self.finish(json.dumps({"version": jupyter_server.__version__}))
|
|
|
|
|
|
class TrailingSlashHandler(web.RequestHandler):
|
|
"""Simple redirect handler that strips trailing slashes
|
|
|
|
This should be the first, highest priority handler.
|
|
"""
|
|
|
|
@allow_unauthenticated
|
|
def get(self) -> None:
|
|
"""Handle trailing slashes in a get."""
|
|
assert self.request.uri is not None
|
|
path, *rest = self.request.uri.partition("?")
|
|
# trim trailing *and* leading /
|
|
# to avoid misinterpreting repeated '//'
|
|
path = "/" + path.strip("/")
|
|
new_uri = "".join([path, *rest])
|
|
self.redirect(new_uri)
|
|
|
|
post = put = get
|
|
|
|
|
|
class MainHandler(JupyterHandler):
|
|
"""Simple handler for base_url."""
|
|
|
|
@allow_unauthenticated
|
|
def get(self) -> None:
|
|
"""Get the main template."""
|
|
html = self.render_template("main.html")
|
|
self.write(html)
|
|
|
|
post = put = get
|
|
|
|
|
|
class FilesRedirectHandler(JupyterHandler):
|
|
"""Handler for redirecting relative URLs to the /files/ handler"""
|
|
|
|
@staticmethod
|
|
async def redirect_to_files(self: Any, path: str) -> None:
|
|
"""make redirect logic a reusable static method
|
|
|
|
so it can be called from other handlers.
|
|
"""
|
|
cm = self.contents_manager
|
|
if await ensure_async(cm.dir_exists(path)):
|
|
# it's a *directory*, redirect to /tree
|
|
url = url_path_join(self.base_url, "tree", url_escape(path))
|
|
else:
|
|
orig_path = path
|
|
# otherwise, redirect to /files
|
|
parts = path.split("/")
|
|
|
|
if not await ensure_async(cm.file_exists(path=path)) and "files" in parts:
|
|
# redirect without files/ iff it would 404
|
|
# this preserves pre-2.0-style 'files/' links
|
|
self.log.warning("Deprecated files/ URL: %s", orig_path)
|
|
parts.remove("files")
|
|
path = "/".join(parts)
|
|
|
|
if not await ensure_async(cm.file_exists(path=path)):
|
|
raise web.HTTPError(404)
|
|
|
|
url = url_path_join(self.base_url, "files", url_escape(path))
|
|
self.log.debug("Redirecting %s to %s", self.request.path, url)
|
|
self.redirect(url)
|
|
|
|
@allow_unauthenticated
|
|
async def get(self, path: str = "") -> None:
|
|
return await self.redirect_to_files(self, path)
|
|
|
|
|
|
class RedirectWithParams(web.RequestHandler):
|
|
"""Same as web.RedirectHandler, but preserves URL parameters"""
|
|
|
|
def initialize(self, url: str, permanent: bool = True) -> None:
|
|
"""Initialize a redirect handler."""
|
|
self._url = url
|
|
self._permanent = permanent
|
|
|
|
@allow_unauthenticated
|
|
def get(self) -> None:
|
|
"""Get a redirect."""
|
|
sep = "&" if "?" in self._url else "?"
|
|
url = sep.join([self._url, self.request.query])
|
|
self.redirect(url, permanent=self._permanent)
|
|
|
|
|
|
class PrometheusMetricsHandler(JupyterHandler):
|
|
"""
|
|
Return prometheus metrics for this server
|
|
"""
|
|
|
|
@allow_unauthenticated
|
|
def get(self) -> None:
|
|
"""Get prometheus metrics."""
|
|
if self.settings["authenticate_prometheus"] and not self.logged_in:
|
|
raise web.HTTPError(403)
|
|
|
|
self.set_header("Content-Type", prometheus_client.CONTENT_TYPE_LATEST)
|
|
self.write(prometheus_client.generate_latest(prometheus_client.REGISTRY))
|
|
|
|
|
|
class PublicStaticFileHandler(web.StaticFileHandler):
|
|
"""Same as web.StaticFileHandler, but decorated to acknowledge that auth is not required."""
|
|
|
|
@allow_unauthenticated
|
|
def head(self, path: str) -> Awaitable[None]:
|
|
return super().head(path)
|
|
|
|
@allow_unauthenticated
|
|
def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]:
|
|
return super().get(path, include_body)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# URL pattern fragments for reuse
|
|
# -----------------------------------------------------------------------------
|
|
|
|
# path matches any number of `/foo[/bar...]` or just `/` or ''
|
|
path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))"
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# URL to handler mappings
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
default_handlers = [
|
|
(r".*/", TrailingSlashHandler),
|
|
(r"api", APIVersionHandler),
|
|
(r"/(robots\.txt|favicon\.ico)", PublicStaticFileHandler),
|
|
(r"/metrics", PrometheusMetricsHandler),
|
|
]
|