From 49dfe2e331277a8e57e5e475234fd3252717e4c3 Mon Sep 17 00:00:00 2001 From: Zachary Sailer Date: Wed, 9 Feb 2022 08:25:03 -0800 Subject: [PATCH] Add authorization layer to server request handlers (#165) * add authorization layer to request handlers * update authorized wrapper with resource * Add tests * Add documentation * Add AuthorizationManager class * Update examples/authorization/README.md * authorization: address review - "contents" applies to /view - "terminals" is plural - "server" is scope for shutdown - failed authorization is 403, not 401 - calling it Authorizer instead of AuthorizationManager - 'user' term is more broadly understood than 'subject'. Plus, it always comes from `self.current_user`. - default authorizer that allows all users is AllowAllAuthorizer * allow `@authorized` to be used with no arguments - use auth_resource on handler - use http method name for action * Structure authorization resources as a table * Move Authorizer to existing jupyter_server.auth since it's a public API packages should import, let's not nest it deep in services.auth.authorizer Co-authored-by: David Brochart Co-authored-by: Steven Silvester Co-authored-by: Min RK --- docs/source/operators/security.rst | 156 ++++++++++ examples/authorization/README.md | 84 ++++++ .../jupyter_nbclassic_readonly_config.py | 14 + .../jupyter_nbclassic_rw_config.py | 14 + .../authorization/jupyter_temporary_config.py | 14 + jupyter_server/auth/__init__.py | 2 + jupyter_server/auth/authorizer.py | 69 +++++ jupyter_server/auth/decorator.py | 78 +++++ jupyter_server/auth/login.py | 3 +- jupyter_server/auth/utils.py | 66 +++++ jupyter_server/base/handlers.py | 10 +- jupyter_server/base/zmqhandlers.py | 7 +- jupyter_server/config_manager.py | 4 +- jupyter_server/extension/application.py | 6 +- jupyter_server/extension/handler.py | 5 +- jupyter_server/files/handlers.py | 8 + jupyter_server/gateway/gateway_client.py | 9 +- jupyter_server/gateway/managers.py | 6 +- jupyter_server/kernelspecs/handlers.py | 7 + jupyter_server/nbconvert/handlers.py | 8 + jupyter_server/serverapp.py | 116 ++++++-- jupyter_server/services/api/handlers.py | 9 + jupyter_server/services/config/handlers.py | 9 + jupyter_server/services/contents/handlers.py | 25 +- jupyter_server/services/contents/manager.py | 4 +- jupyter_server/services/kernels/handlers.py | 32 +- .../services/kernels/kernelmanager.py | 8 +- .../services/kernelspecs/handlers.py | 19 +- jupyter_server/services/nbconvert/handlers.py | 7 + jupyter_server/services/security/handlers.py | 9 +- jupyter_server/services/sessions/handlers.py | 23 +- .../services/sessions/sessionmanager.py | 3 +- jupyter_server/services/shutdown.py | 7 + jupyter_server/terminal/api_handlers.py | 16 +- jupyter_server/terminal/handlers.py | 13 +- jupyter_server/terminal/terminalmanager.py | 4 +- jupyter_server/tests/auth/test_authorizer.py | 277 ++++++++++++++++++ jupyter_server/tests/auth/test_utils.py | 37 +++ .../extension/mockextensions/__init__.py | 5 +- jupyter_server/tests/extension/test_app.py | 45 ++- .../tests/extension/test_serverextension.py | 16 +- .../tests/nbconvert/test_handlers.py | 28 +- .../tests/services/contents/test_api.py | 78 ++++- .../contents/test_largefilemanager.py | 8 +- .../tests/services/contents/test_manager.py | 5 +- .../tests/services/kernels/test_api.py | 14 +- .../tests/services/sessions/test_api.py | 20 +- jupyter_server/tests/test_files.py | 7 +- jupyter_server/tests/test_gateway.py | 14 +- jupyter_server/tests/test_paths.py | 7 +- jupyter_server/tests/test_traittypes.py | 6 +- jupyter_server/tests/test_utils.py | 5 +- jupyter_server/view/handlers.py | 7 + 53 files changed, 1355 insertions(+), 98 deletions(-) create mode 100644 examples/authorization/README.md create mode 100644 examples/authorization/jupyter_nbclassic_readonly_config.py create mode 100644 examples/authorization/jupyter_nbclassic_rw_config.py create mode 100644 examples/authorization/jupyter_temporary_config.py create mode 100644 jupyter_server/auth/authorizer.py create mode 100644 jupyter_server/auth/decorator.py create mode 100644 jupyter_server/auth/utils.py create mode 100644 jupyter_server/tests/auth/test_authorizer.py create mode 100644 jupyter_server/tests/auth/test_utils.py diff --git a/docs/source/operators/security.rst b/docs/source/operators/security.rst index 5f69c334b9..87148cbe41 100644 --- a/docs/source/operators/security.rst +++ b/docs/source/operators/security.rst @@ -77,6 +77,162 @@ but this is **NOT RECOMMENDED**, unless authentication or access restrictions ar c.ServerApp.token = '' c.ServerApp.password = '' +Authorization +------------- + +.. versionadded:: 2.0 + +Authorization in Jupyter Server serves to provide finer grained control of access to its +API resources. With authentication, requests are accepted if the current user is known by +the server. Thus it can restrain access to specific users, but there is no way to give allowed +users more or less permissions. Jupyter Server provides a thin and extensible authorization layer +which checks if the current user is authorized to make a specific request. + +This is done by calling a ``is_authorized(handler, user, action, resource)`` method before each +request handler. Each request is labeled as either a "read", "write", or "execute" ``action``: + +- "read" wraps all ``GET`` and ``HEAD`` requests. + In general, read permissions grants access to read but not modify anything about the given resource. +- "write" wraps all ``POST``, ``PUT``, ``PATCH``, and ``DELETE`` requests. + In general, write permissions grants access to modify the given resource. +- "execute" wraps all requests to ZMQ/Websocket channels (terminals and kernels). + Execute is a special permission that usually corresponds to arbitrary execution, + such as via a kernel or terminal. + These permissions should generally be considered sufficient to perform actions equivalent + to ~all other permissions via other means. + +The ``resource`` being accessed refers to the resource name in the Jupyter Server's API endpoints. +In most cases, this is matches the field after `/api/`. +For instance, values for ``resource`` in the endpoints provided by the base jupyter server package, +and the corresponding permissions: + +.. list-table:: + :header-rows: 1 + + * - resource + - read + - write + - execute + - endpoints + + * - *resource name* + - *what can you do with read permissions?* + - *what can you do with write permissions?* + - *what can you do with execute permissions, if anything?* + - ``/api/...`` *what endpoints are governed by this resource?* + + * - api + - read server status (last activity, number of kernels, etc.), OpenAPI specification + - + - + - ``/api/status``, ``/api/spec.yaml`` + * - csp + - + - report content-security-policy violations + - + - ``/api/security/csp-report`` + * - config + - read frontend configuration, such as for notebook extensions + - modify frontend configuration + - + - ``/api/config`` + * - contents + - read files + - modify files (create, modify, delete) + - + - ``/api/contents``, ``/view``, ``/files`` + * - kernels + - list kernels, get status of kernels + - start, stop, and restart kernels + - Connect to kernel websockets, send/recv kernel messages. + **This generally means arbitrary code execution, + and should usually be considered equivalent to having all other permissions.** + - ``/api/kernels`` + * - kernelspecs + - read, list information about available kernels + - + - + - ``/api/kernelspecs`` + * - nbconvert + - render notebooks to other formats via nbconvert. + **Note: depending on server-side configuration, + this *could* involve execution.** + - + - + - ``/api/nbconvert`` + * - server + - + - Shutdown the server + - + - ``/api/shutdown`` + * - sessions + - list current sessions (association of documents to kernels) + - create, modify, and delete existing sessions, + which includes starting, stopping, and deleting kernels. + - + - ``/api/sessions`` + * - terminals + - list running terminals and their last activity + - start new terminals, stop running terminals + - Connect to terminal websockets, execute code in a shell. + **This generally means arbitrary code execution, + and should usually be considered equivalent to having all other permissions.** + - ``/api/terminals`` + + +Extensions may define their own resources. +Extension resources should start with ``extension_name:`` to avoid namespace conflicts. + +If ``is_authorized(...)`` returns ``True``, the request is made; otherwise, a +``HTTPError(403)`` (403 means "Forbidden") error is raised, and the request is blocked. + +By default, authorization is turned off—i.e. ``is_authorized()`` always returns ``True`` and +all authenticated users are allowed to make all types of requests. To turn-on authorization, pass +a class that inherits from ``Authorizer`` to the ``ServerApp.authorizer_class`` +parameter, implementing a ``is_authorized()`` method with your desired authorization logic, as +follows: + +.. sourcecode:: python + + from jupyter_server.auth import Authorizer + + class MyAuthorizationManager(Authorizer): + """Class for authorizing access to resources in the Jupyter Server. + + All authorizers used in Jupyter Server should inherit from + AuthorizationManager and, at the very minimum, override and implement + an `is_authorized` method with the following signature. + + The `is_authorized` method is called by the `@authorized` decorator in + JupyterHandler. If it returns True, the incoming request to the server + is accepted; if it returns False, the server returns a 403 (Forbidden) error code. + """ + + def is_authorized(self, handler: JupyterHandler, user: Any, action: str, resource: str) -> bool: + """A method to determine if `user` is authorized to perform `action` + (read, write, or execute) on the `resource` type. + + Parameters + ------------ + user : usually a dict or string + A truthy model representing the authenticated user. + A username string by default, + but usually a dict when integrating with an auth provider. + + action : str + the category of action for the current request: read, write, or execute. + + resource : str + the type of resource (i.e. contents, kernels, files, etc.) the user is requesting. + + Returns True if user authorized to make request; otherwise, returns False. + """ + return True # implement your authorization logic here + +The ``is_authorized()`` method will automatically be called whenever a handler is decorated with +``@authorized`` (from ``jupyter_server.auth``), similarly to the +``@authenticated`` decorator for authorization (from ``tornado.web``). + Security in notebook documents ============================== diff --git a/examples/authorization/README.md b/examples/authorization/README.md new file mode 100644 index 0000000000..28fe0df83f --- /dev/null +++ b/examples/authorization/README.md @@ -0,0 +1,84 @@ +# Authorization in a simple Jupyter Notebook Server + +This folder contains the following examples: + +1. a "read-only" Jupyter Notebook Server +2. a read/write Server without the ability to execute code on kernels. +3. a "temporary notebook server", i.e. read and execute notebooks but cannot save/write files. + +## How does it work? + +To add a custom authorization system to the Jupyter Server, you will need to write your own `Authorizer` subclass and pass it to Jupyter's configuration system (i.e. by file or CLI). + +The examples below demonstrate some basic implementations of an `Authorizer`. + +```python +from jupyter_server.auth import Authorizer + + +class MyCustomAuthorizer(Authorizer): + """Custom authorization manager.""" + + # Define my own method here for handling authorization. + # The argument signature must have `self`, `handler`, `user`, `action`, and `resource`. + def is_authorized(self, handler, user, action, resource): + """My override for handling authorization in Jupyter services.""" + + # Add logic here to check if user is allowed. + # For example, here is an example of a read-only server + if action != "read": + return False + + return True + +# Pass this custom class to Jupyter Server +c.ServerApp.authorizer_class = MyCustomAuthorizer +``` + +In the `jupyter_nbclassic_readonly_config.py` + +## Try it out! + +### Read-only example + +1. Install nbclassic using `pip`. + + pip install nbclassic + +2. Navigate to the jupyter_authorized_server `examples/` folder. + +3. Launch nbclassic and load `jupyter_nbclassic_readonly_config.py`: + + jupyter nbclassic --config=jupyter_nbclassic_readonly_config.py + +4. Try creating a notebook, running a notebook in a cell, etc. You should see a `403: Forbidden` error. + +### Read+Write example + +1. Install nbclassic using `pip`. + + pip install nbclassic + +2. Navigate to the jupyter_authorized_server `examples/` folder. + +3. Launch nbclassic and load `jupyter_nbclassic_rw_config.py`: + + jupyter nbclassic --config=jupyter_nbclassic_rw_config.py + +4. Try running a cell in a notebook. You should see a `403: Forbidden` error. + +### Temporary notebook server example + +This configuration allows everything except saving files. + +1. Install nbclassic using `pip`. + + pip install nbclassic + +2. Navigate to the jupyter_authorized_server `examples/` folder. + +3. Launch nbclassic and load `jupyter_temporary_config.py`: + + jupyter nbclassic --config=jupyter_temporary_config.py + +4. Edit a notebook, run a cell, etc. Everything works fine. Then try to save your changes... you should see a `403: Forbidden` error. diff --git a/examples/authorization/jupyter_nbclassic_readonly_config.py b/examples/authorization/jupyter_nbclassic_readonly_config.py new file mode 100644 index 0000000000..292644c284 --- /dev/null +++ b/examples/authorization/jupyter_nbclassic_readonly_config.py @@ -0,0 +1,14 @@ +from jupyter_server.auth import Authorizer + + +class ReadOnly(Authorizer): + """Authorizer that makes Jupyter Server a read-only server.""" + + def is_authorized(self, handler, user, action, resource): + """Only allows `read` operations.""" + if action != "read": + return False + return True + + +c.ServerApp.authorizer_class = ReadOnly diff --git a/examples/authorization/jupyter_nbclassic_rw_config.py b/examples/authorization/jupyter_nbclassic_rw_config.py new file mode 100644 index 0000000000..261efcf984 --- /dev/null +++ b/examples/authorization/jupyter_nbclassic_rw_config.py @@ -0,0 +1,14 @@ +from jupyter_server.auth import Authorizer + + +class ReadWriteOnly(Authorizer): + """Authorizer class that makes Jupyter Server a read/write-only server.""" + + def is_authorized(self, handler, user, action, resource): + """Only allows `read` and `write` operations.""" + if action not in {"read", "write"}: + return False + return True + + +c.ServerApp.authorizer_class = ReadWriteOnly diff --git a/examples/authorization/jupyter_temporary_config.py b/examples/authorization/jupyter_temporary_config.py new file mode 100644 index 0000000000..e1bd2fb507 --- /dev/null +++ b/examples/authorization/jupyter_temporary_config.py @@ -0,0 +1,14 @@ +from jupyter_server.auth import Authorizer + + +class TemporaryServerPersonality(Authorizer): + """Authorizer that prevents modifying files via the contents service""" + + def is_authorized(self, handler, user, action, resource): + """Allow everything but write on contents""" + if action == "write" and resource == "contents": + return False + return True + + +c.ServerApp.authorizer_class = TemporaryServerPersonality diff --git a/jupyter_server/auth/__init__.py b/jupyter_server/auth/__init__.py index 23b6dc8b2a..54477ffd1b 100644 --- a/jupyter_server/auth/__init__.py +++ b/jupyter_server/auth/__init__.py @@ -1 +1,3 @@ +from .authorizer import * # noqa +from .decorator import authorized # noqa from .security import passwd # noqa diff --git a/jupyter_server/auth/authorizer.py b/jupyter_server/auth/authorizer.py new file mode 100644 index 0000000000..952cb0278d --- /dev/null +++ b/jupyter_server/auth/authorizer.py @@ -0,0 +1,69 @@ +"""An Authorizer for use in the Jupyter server. + +The default authorizer (AllowAllAuthorizer) +allows all authenticated requests + +.. versionadded:: 2.0 +""" +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +from traitlets.config import LoggingConfigurable + +from jupyter_server.base.handlers import JupyterHandler + + +class Authorizer(LoggingConfigurable): + """Base class for authorizing access to resources + in the Jupyter Server. + + All authorizers used in Jupyter Server + should inherit from this base class and, at the very minimum, + implement an `is_authorized` method with the + same signature as in this base class. + + The `is_authorized` method is called by the `@authorized` decorator + in JupyterHandler. If it returns True, the incoming request + to the server is accepted; if it returns False, the server + returns a 403 (Forbidden) error code. + + The authorization check will only be applied to requests + that have already been authenticated. + + .. versionadded:: 2.0 + """ + + def is_authorized(self, handler: JupyterHandler, user: str, action: str, resource: str) -> bool: + """A method to determine if `user` is authorized to perform `action` + (read, write, or execute) on the `resource` type. + + Parameters + ---------- + user : usually a dict or string + A truthy model representing the authenticated user. + A username string by default, + but usually a dict when integrating with an auth provider. + action : str + the category of action for the current request: read, write, or execute. + + resource : str + the type of resource (i.e. contents, kernels, files, etc.) the user is requesting. + + Returns True if user authorized to make request; otherwise, returns False. + """ + raise NotImplementedError() + + +class AllowAllAuthorizer(Authorizer): + """A no-op implementation of the Authorizer + + This authorizer allows all authenticated requests. + + .. versionadded:: 2.0 + """ + + def is_authorized(self, handler: JupyterHandler, user: str, action: str, resource: str) -> bool: + """This method always returns True. + + All authenticated users are allowed to do anything in the Jupyter Server. + """ + return True diff --git a/jupyter_server/auth/decorator.py b/jupyter_server/auth/decorator.py new file mode 100644 index 0000000000..926808fd85 --- /dev/null +++ b/jupyter_server/auth/decorator.py @@ -0,0 +1,78 @@ +"""Decorator for layering authorization into JupyterHandlers. +""" +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +from functools import wraps +from typing import Callable +from typing import Optional +from typing import Union + +from tornado.log import app_log +from tornado.web import HTTPError + +from .utils import HTTP_METHOD_TO_AUTH_ACTION + + +def authorized( + action: Optional[Union[str, Callable]] = None, + resource: Optional[str] = None, + message: Optional[str] = None, +) -> Callable: + """A decorator for tornado.web.RequestHandler methods + that verifies whether the current user is authorized + to make the following request. + + Helpful for adding an 'authorization' layer to + a REST API. + + .. versionadded:: 2.0 + + Parameters + ---------- + action : str + the type of permission or action to check. + + resource: str or None + the name of the resource the action is being authorized + to access. + + message : str or none + a message for the unauthorized action. + """ + + def wrapper(method): + @wraps(method) + def inner(self, *args, **kwargs): + # default values for action, resource + nonlocal action + nonlocal resource + nonlocal message + if action is None: + http_method = self.request.method.upper() + action = HTTP_METHOD_TO_AUTH_ACTION[http_method] + if resource is None: + resource = self.auth_resource + if message is None: + message = f"User is not authorized to {action} on resource: {resource}." + + user = self.current_user + if not user: + app_log.warning("Attempting to authorize request without authentication!") + raise HTTPError(status_code=403, log_message=message) + # If the user is allowed to do this action, + # call the method. + if self.authorizer.is_authorized(self, user, action, resource): + return method(self, *args, **kwargs) + # else raise an exception. + else: + raise HTTPError(status_code=403, log_message=message) + + return inner + + if callable(action): + method = action + action = None + # no-arguments `@authorized` decorator called + return wrapper(method) + + return wrapper diff --git a/jupyter_server/auth/login.py b/jupyter_server/auth/login.py index 19cdb47d75..84bd22a86b 100644 --- a/jupyter_server/auth/login.py +++ b/jupyter_server/auth/login.py @@ -210,7 +210,8 @@ def get_user_token(cls, handler): if user_token == token: # token-authenticated, set the login cookie handler.log.debug( - "Accepting token-authenticated connection from %s", handler.request.remote_ip + "Accepting token-authenticated connection from %s", + handler.request.remote_ip, ) authenticated = True diff --git a/jupyter_server/auth/utils.py b/jupyter_server/auth/utils.py new file mode 100644 index 0000000000..5336235084 --- /dev/null +++ b/jupyter_server/auth/utils.py @@ -0,0 +1,66 @@ +"""A module with various utility methods for authorization in Jupyter Server. +""" +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import importlib +import re + + +HTTP_METHOD_TO_AUTH_ACTION = { + "GET": "read", + "HEAD": "read", + "OPTIONS": "read", + "POST": "write", + "PUT": "write", + "PATCH": "write", + "DELETE": "write", + "WEBSOCKET": "execute", +} + + +def get_regex_to_resource_map(): + """Returns a dictionary with all of Jupyter Server's + request handler URL regex patterns mapped to + their resource name. + + e.g. + { "/api/contents/": "contents", ...} + """ + from jupyter_server.serverapp import JUPYTER_SERVICE_HANDLERS + + modules = [] + for mod in JUPYTER_SERVICE_HANDLERS.values(): + if mod: + modules.extend(mod) + resource_map = {} + for handler_module in modules: + mod = importlib.import_module(handler_module) + name = mod.AUTH_RESOURCE + for handler in mod.default_handlers: + url_regex = handler[0] + resource_map[url_regex] = name + # terminal plugin doesn't have importable url patterns + # get these from terminal/__init__.py + for url_regex in [ + r"/terminals/websocket/(\w+)", + "/api/terminals", + r"/api/terminals/(\w+)", + ]: + resource_map[url_regex] = "terminals" + return resource_map + + +def match_url_to_resource(url, regex_mapping=None): + """Finds the JupyterHandler regex pattern that would + match the given URL and returns the resource name (str) + of that handler. + + e.g. + /api/contents/... returns "contents" + """ + if not regex_mapping: + regex_mapping = get_regex_to_resource_map() + for regex, auth_resource in regex_mapping.items(): + pattern = re.compile(regex) + if pattern.fullmatch(url): + return auth_resource diff --git a/jupyter_server/base/handlers.py b/jupyter_server/base/handlers.py index db5f2296cf..7de52f4e04 100644 --- a/jupyter_server/base/handlers.py +++ b/jupyter_server/base/handlers.py @@ -191,6 +191,10 @@ def login_available(self): return False return bool(self.login_handler.get_login_available(self.settings)) + @property + def authorizer(self): + return self.settings["authorizer"] + class JupyterHandler(AuthenticatedHandler): """Jupyter-specific extensions to authenticated handling @@ -251,7 +255,8 @@ def ws_url(self): @property def contents_js_source(self): self.log.debug( - "Using contents: %s", self.settings.get("contents_js_source", "services/contents") + "Using contents: %s", + self.settings.get("contents_js_source", "services/contents"), ) return self.settings.get("contents_js_source", "services/contents") @@ -674,7 +679,8 @@ def options(self, *args, **kwargs): ) else: self.set_header( - "Access-Control-Allow-Headers", "accept, content-type, authorization, x-xsrftoken" + "Access-Control-Allow-Headers", + "accept, content-type, authorization, x-xsrftoken", ) self.set_header("Access-Control-Allow-Methods", "GET, PUT, POST, PATCH, DELETE, OPTIONS") diff --git a/jupyter_server/base/zmqhandlers.py b/jupyter_server/base/zmqhandlers.py index 6109db5d1d..ff8a5dd602 100644 --- a/jupyter_server/base/zmqhandlers.py +++ b/jupyter_server/base/zmqhandlers.py @@ -316,10 +316,15 @@ def pre_get(self): the websocket finishes completing. """ # authenticate the request before opening the websocket - if self.get_current_user() is None: + user = self.get_current_user() + if user is None: self.log.warning("Couldn't authenticate WebSocket connection") raise web.HTTPError(403) + # authorize the user. + if not self.authorizer.is_authorized(self, user, "execute", "kernels"): + raise web.HTTPError(403) + if self.get_argument("session_id", False): self.session.session = cast_unicode(self.get_argument("session_id")) else: diff --git a/jupyter_server/config_manager.py b/jupyter_server/config_manager.py index 94d613fedb..933529ae53 100644 --- a/jupyter_server/config_manager.py +++ b/jupyter_server/config_manager.py @@ -95,7 +95,9 @@ def get(self, section_name, include_root=True): # .json file is probably a user configuration. paths = sorted(glob.glob(pattern)) + paths self.log.debug( - "Paths used for configuration of %s: \n\t%s", section_name, "\n\t".join(paths) + "Paths used for configuration of %s: \n\t%s", + section_name, + "\n\t".join(paths), ) data = {} for path in paths: diff --git a/jupyter_server/extension/application.py b/jupyter_server/extension/application.py index a0cd5c3551..ac990b8d22 100644 --- a/jupyter_server/extension/application.py +++ b/jupyter_server/extension/application.py @@ -495,7 +495,8 @@ def load_classic_server_extension(cls, serverapp): RedirectHandler, { "url": url_path_join( - serverapp.base_url, "static/base/images/favicon-notebook.ico" + serverapp.base_url, + "static/base/images/favicon-notebook.ico", ) }, ), @@ -504,7 +505,8 @@ def load_classic_server_extension(cls, serverapp): RedirectHandler, { "url": url_path_join( - serverapp.base_url, "static/base/images/favicon-terminal.ico" + serverapp.base_url, + "static/base/images/favicon-terminal.ico", ) }, ), diff --git a/jupyter_server/extension/handler.py b/jupyter_server/extension/handler.py index 4b7444001b..be257a46bc 100644 --- a/jupyter_server/extension/handler.py +++ b/jupyter_server/extension/handler.py @@ -111,6 +111,9 @@ def static_url(self, path, include_host=None, **kwargs): # Hijack settings dict to send extension templates to extension # static directory. - settings = {"static_path": self.static_path, "static_url_prefix": self.static_url_prefix} + settings = { + "static_path": self.static_path, + "static_url_prefix": self.static_url_prefix, + } return base + get_url(settings, path, **kwargs) diff --git a/jupyter_server/files/handlers.py b/jupyter_server/files/handlers.py index 4190638817..2eab425aeb 100644 --- a/jupyter_server/files/handlers.py +++ b/jupyter_server/files/handlers.py @@ -7,10 +7,14 @@ from tornado import web +from jupyter_server.auth import authorized from jupyter_server.base.handlers import JupyterHandler from jupyter_server.utils import ensure_async +AUTH_RESOURCE = "contents" + + class FilesHandler(JupyterHandler): """serve files via ContentsManager @@ -20,6 +24,8 @@ class FilesHandler(JupyterHandler): a subclass of StaticFileHandler. """ + auth_resource = AUTH_RESOURCE + @property def content_security_policy(self): # In case we're serving HTML/SVG, confine any Javascript to a unique @@ -27,12 +33,14 @@ def content_security_policy(self): return super(FilesHandler, self).content_security_policy + "; sandbox allow-scripts" @web.authenticated + @authorized def head(self, path): self.get(path, include_body=False) self.check_xsrf_cookie() return self.get(path, include_body=False) @web.authenticated + @authorized async def get(self, path, include_body=True): # /files/ requests must originate from the same site self.check_xsrf_cookie() diff --git a/jupyter_server/gateway/gateway_client.py b/jupyter_server/gateway/gateway_client.py index abe0c63864..ec7b52f8d8 100644 --- a/jupyter_server/gateway/gateway_client.py +++ b/jupyter_server/gateway/gateway_client.py @@ -116,7 +116,8 @@ def _kernelspecs_endpoint_default(self): @default("kernelspecs_resource_endpoint") def _kernelspecs_resource_endpoint_default(self): return os.environ.get( - self.kernelspecs_resource_endpoint_env, self.kernelspecs_resource_endpoint_default_value + self.kernelspecs_resource_endpoint_env, + self.kernelspecs_resource_endpoint_default_value, ) connect_timeout_default_value = 40.0 @@ -309,7 +310,8 @@ def _env_whitelist_default(self): def gateway_retry_interval_default(self): return float( os.environ.get( - "JUPYTER_GATEWAY_RETRY_INTERVAL", self.gateway_retry_interval_default_value + "JUPYTER_GATEWAY_RETRY_INTERVAL", + self.gateway_retry_interval_default_value, ) ) @@ -326,7 +328,8 @@ def gateway_retry_interval_default(self): def gateway_retry_interval_max_default(self): return float( os.environ.get( - "JUPYTER_GATEWAY_RETRY_INTERVAL_MAX", self.gateway_retry_interval_max_default_value + "JUPYTER_GATEWAY_RETRY_INTERVAL_MAX", + self.gateway_retry_interval_max_default_value, ) ) diff --git a/jupyter_server/gateway/managers.py b/jupyter_server/gateway/managers.py index b688c59b69..6b50ecdb46 100644 --- a/jupyter_server/gateway/managers.py +++ b/jupyter_server/gateway/managers.py @@ -192,7 +192,8 @@ def __init__(self, **kwargs): self.base_endpoint = GatewayKernelSpecManager._get_endpoint_for_user_filter(base_endpoint) self.base_resource_endpoint = url_path_join( - GatewayClient.instance().url, GatewayClient.instance().kernelspecs_resource_endpoint + GatewayClient.instance().url, + GatewayClient.instance().kernelspecs_resource_endpoint, ) @staticmethod @@ -259,7 +260,8 @@ async def get_kernel_spec(self, kernel_name, **kwargs): # message is not used, but might as well make it useful for troubleshooting raise KeyError( "kernelspec {kernel_name} not found on Gateway server at: {gateway_url}".format( - kernel_name=kernel_name, gateway_url=GatewayClient.instance().url + kernel_name=kernel_name, + gateway_url=GatewayClient.instance().url, ) ) from error else: diff --git a/jupyter_server/kernelspecs/handlers.py b/jupyter_server/kernelspecs/handlers.py index b940015934..f78a57181c 100644 --- a/jupyter_server/kernelspecs/handlers.py +++ b/jupyter_server/kernelspecs/handlers.py @@ -2,15 +2,21 @@ from ..base.handlers import JupyterHandler from ..services.kernelspecs.handlers import kernel_name_regex +from jupyter_server.auth import authorized + + +AUTH_RESOURCE = "kernelspecs" class KernelSpecResourceHandler(web.StaticFileHandler, JupyterHandler): SUPPORTED_METHODS = ("GET", "HEAD") + auth_resource = AUTH_RESOURCE def initialize(self): web.StaticFileHandler.initialize(self, path="") @web.authenticated + @authorized def get(self, kernel_name, path, include_body=True): ksm = self.kernel_spec_manager try: @@ -21,6 +27,7 @@ def get(self, kernel_name, path, include_body=True): return web.StaticFileHandler.get(self, path, include_body=include_body) @web.authenticated + @authorized def head(self, kernel_name, path): return self.get(kernel_name, path, include_body=False) diff --git a/jupyter_server/nbconvert/handlers.py b/jupyter_server/nbconvert/handlers.py index 7cad017cc7..e4ba4bb851 100644 --- a/jupyter_server/nbconvert/handlers.py +++ b/jupyter_server/nbconvert/handlers.py @@ -15,9 +15,13 @@ from ..base.handlers import FilesRedirectHandler from ..base.handlers import JupyterHandler from ..base.handlers import path_regex +from jupyter_server.auth import authorized from jupyter_server.utils import ensure_async +AUTH_RESOURCE = "nbconvert" + + def find_resource_files(output_files_dir): files = [] for dirpath, dirnames, filenames in os.walk(output_files_dir): @@ -78,9 +82,11 @@ def get_exporter(format, **kwargs): class NbconvertFileHandler(JupyterHandler): + auth_resource = AUTH_RESOURCE SUPPORTED_METHODS = ("GET",) @web.authenticated + @authorized async def get(self, format, path): self.check_xsrf_cookie() exporter = get_exporter(format, config=self.config, log=self.log) @@ -144,8 +150,10 @@ async def get(self, format, path): class NbconvertPostHandler(JupyterHandler): SUPPORTED_METHODS = ("POST",) + auth_resource = AUTH_RESOURCE @web.authenticated + @authorized async def post(self, format): exporter = get_exporter(format, config=self.config) diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 428345f750..0630037dbe 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -26,6 +26,7 @@ import threading import time import urllib +import warnings import webbrowser from base64 import encodebytes @@ -74,7 +75,10 @@ AsyncMappingKernelManager, ) from jupyter_server.services.config import ConfigManager -from jupyter_server.services.contents.manager import AsyncContentsManager, ContentsManager +from jupyter_server.services.contents.manager import ( + AsyncContentsManager, + ContentsManager, +) from jupyter_server.services.contents.filemanager import ( AsyncFileContentsManager, FileContentsManager, @@ -87,6 +91,7 @@ GatewaySessionManager, GatewayClient, ) +from jupyter_server.auth.authorizer import Authorizer, AllowAllAuthorizer from jupyter_server.auth.login import LoginHandler from jupyter_server.auth.logout import LogoutHandler @@ -168,7 +173,10 @@ "jupyter_server.kernelspecs.handlers", "jupyter_server.services.kernelspecs.handlers", ], - nbconvert=["jupyter_server.nbconvert.handlers", "jupyter_server.services.nbconvert.handlers"], + nbconvert=[ + "jupyter_server.nbconvert.handlers", + "jupyter_server.services.nbconvert.handlers", + ], security=["jupyter_server.services.security.handlers"], sessions=["jupyter_server.services.sessions.handlers"], shutdown=["jupyter_server.services.shutdown"], @@ -222,7 +230,16 @@ def __init__( default_url, settings_overrides, jinja_env_options, + authorizer=None, ): + if authorizer is None: + warnings.warn( + "authorizer unspecified. Using permissive AllowAllAuthorizer." + " Specify an authorizer to avoid this message.", + RuntimeWarning, + stacklevel=2, + ) + authorizer = AllowAllAuthorizer(jupyter_app) settings = self.init_settings( jupyter_app, @@ -237,6 +254,7 @@ def __init__( default_url, settings_overrides, jinja_env_options, + authorizer=authorizer, ) handlers = self.init_handlers(default_services, settings) @@ -256,6 +274,7 @@ def init_settings( default_url, settings_overrides, jinja_env_options=None, + authorizer=None, ): _template_path = settings_overrides.get( @@ -280,7 +299,9 @@ def init_settings( dev_mode = os.path.exists(os.path.join(base_dir, ".git")) nbui = gettext.translation( - "nbui", localedir=os.path.join(base_dir, "jupyter_server/i18n"), fallback=True + "nbui", + localedir=os.path.join(base_dir, "jupyter_server/i18n"), + fallback=True, ) env.install_gettext_translations(nbui, newstyle=False) @@ -338,6 +359,7 @@ def init_settings( session_manager=session_manager, kernel_spec_manager=kernel_spec_manager, config_manager=config_manager, + authorizer=authorizer, # handlers extra_services=extra_services, # Jupyter stuff @@ -582,7 +604,8 @@ def start(self): return current_endpoint = self.sock or self.port print( - "There is currently no server running on {}".format(current_endpoint), file=sys.stderr + "There is currently no server running on {}".format(current_endpoint), + file=sys.stderr, ) print("Ports/sockets currently in use:", file=sys.stderr) for server in servers: @@ -671,7 +694,10 @@ def start(self): # Add notebook manager flags flags.update( boolean_flag( - "script", "FileContentsManager.save_script", "DEPRECATED, IGNORED", "DEPRECATED, IGNORED" + "script", + "FileContentsManager.save_script", + "DEPRECATED, IGNORED", + "DEPRECATED, IGNORED", ) ) @@ -730,6 +756,7 @@ class ServerApp(JupyterApp): GatewayKernelSpecManager, GatewaySessionManager, GatewayClient, + Authorizer, ] if terminado_available: # Only necessary when terminado is available classes.append(TerminalManager) @@ -807,11 +834,15 @@ def _default_log_format(self): ) allow_credentials = Bool( - False, config=True, help=_i18n("Set the Access-Control-Allow-Credentials: true header") + False, + config=True, + help=_i18n("Set the Access-Control-Allow-Credentials: true header"), ) allow_root = Bool( - False, config=True, help=_i18n("Whether to allow the user to run the server as root.") + False, + config=True, + help=_i18n("Whether to allow the user to run the server as root."), ) autoreload = Bool( @@ -823,7 +854,9 @@ def _default_log_format(self): default_url = Unicode("/", config=True, help=_i18n("The default URL to redirect to from `/`")) ip = Unicode( - "localhost", config=True, help=_i18n("The IP address the Jupyter server will listen on.") + "localhost", + config=True, + help=_i18n("The IP address the Jupyter server will listen on."), ) @default("ip") @@ -872,7 +905,8 @@ def _validate_ip(self, proposal): port_default_value = DEFAULT_JUPYTER_SERVER_PORT port = Integer( - config=True, help=_i18n("The port the server will listen on (env: JUPYTER_PORT).") + config=True, + help=_i18n("The port the server will listen on (env: JUPYTER_PORT)."), ) @default("port") @@ -897,7 +931,9 @@ def port_retries_default(self): sock = Unicode(u"", config=True, help="The UNIX socket the Jupyter server will listen on.") sock_mode = Unicode( - "0600", config=True, help="The permissions mode for UNIX socket creation (default: 0600)." + "0600", + config=True, + help="The permissions mode for UNIX socket creation (default: 0600).", ) @validate("sock_mode") @@ -923,7 +959,9 @@ def _validate_sock_mode(self, proposal): return value certfile = Unicode( - u"", config=True, help=_i18n("""The full path to an SSL/TLS certificate file.""") + u"", + config=True, + help=_i18n("""The full path to an SSL/TLS certificate file."""), ) keyfile = Unicode( @@ -980,7 +1018,9 @@ def _write_cookie_secret_file(self, secret): f.write(secret) except OSError as e: self.log.error( - _i18n("Failed to write cookie secret to %s: %s"), self.cookie_secret_file, e + _i18n("Failed to write cookie secret to %s: %s"), + self.cookie_secret_file, + e, ) token = Unicode( @@ -1042,7 +1082,9 @@ def _default_min_open_files_limit(self): return DEFAULT_SOFT self.log.debug( - "Default value for min_open_files_limit is ignored (hard=%r, soft=%r)", hard, soft + "Default value for min_open_files_limit is ignored (hard=%r, soft=%r)", + hard, + soft, ) return soft @@ -1298,7 +1340,8 @@ def _default_allow_remote(self): ) jinja_environment_options = Dict( - config=True, help=_i18n("Supply extra arguments that will be passed to Jinja environment.") + config=True, + help=_i18n("Supply extra arguments that will be passed to Jinja environment."), ) jinja_template_vars = Dict( @@ -1424,11 +1467,15 @@ def _observe_contents_manager_class(self, change): ) session_manager_class = Type( - default_value=SessionManager, config=True, help=_i18n("The session manager class to use.") + default_value=SessionManager, + config=True, + help=_i18n("The session manager class to use."), ) config_manager_class = Type( - default_value=ConfigManager, config=True, help=_i18n("The config manager class to use") + default_value=ConfigManager, + config=True, + help=_i18n("The config manager class to use"), ) kernel_spec_manager = Instance(KernelSpecManager, allow_none=True) @@ -1459,6 +1506,13 @@ def _observe_contents_manager_class(self, change): help=_i18n("The logout handler class to use."), ) + authorizer_class = Type( + default_value=AllowAllAuthorizer, + klass=Authorizer, + config=True, + help=_i18n("The authorizer class to use."), + ) + trust_xheaders = Bool( False, config=True, @@ -1783,6 +1837,7 @@ def init_configurables(self): parent=self, log=self.log, ) + self.authorizer = self.authorizer_class(parent=self, log=self.log) def init_logging(self): # This prevents double log messages because tornado use a root logger that @@ -1869,6 +1924,7 @@ def init_webapp(self): self.default_url, self.tornado_settings, self.jinja_environment_options, + authorizer=self.authorizer, ) if self.certfile: self.ssl_options["certfile"] = self.certfile @@ -1944,7 +2000,12 @@ def _get_urlparts(self, path=None, include_token=False): query = urllib.parse.urlencode({"token": token}) # Build the URL Parts to dump. urlparts = urllib.parse.ParseResult( - scheme=scheme, netloc=netloc, path=path, params=None, query=query, fragment=None + scheme=scheme, + netloc=netloc, + path=path, + params=None, + query=query, + fragment=None, ) return urlparts @@ -1989,7 +2050,12 @@ def init_terminals(self): try: from jupyter_server.terminal import initialize - initialize(self.web_app, self.root_dir, self.connection_url, self.terminado_settings) + initialize( + self.web_app, + self.root_dir, + self.connection_url, + self.terminado_settings, + ) self.terminals_available = True except ImportError as e: self.log.warning(_i18n("Terminals not available (error was %s)"), e) @@ -2152,7 +2218,8 @@ def shutdown_no_activity(self): self.log.debug("No activity for %d seconds.", seconds_since_active) if seconds_since_active > self.shutdown_no_activity_timeout: self.log.info( - "No kernels or terminals for %d seconds; shutting down.", seconds_since_active + "No kernels or terminals for %d seconds; shutting down.", + seconds_since_active, ) self.stop() @@ -2244,7 +2311,10 @@ def _bind_http_server_tcp(self): else: self.log.info(_i18n("The port %i is already in use.") % port) continue - elif e.errno in (errno.EACCES, getattr(errno, "WSAEACCES", errno.EACCES)): + elif e.errno in ( + errno.EACCES, + getattr(errno, "WSAEACCES", errno.EACCES), + ): self.log.warning(_i18n("Permission to listen on port %i denied.") % port) continue else: @@ -2302,7 +2372,11 @@ def _init_asyncio_patch(): @catch_config_error def initialize( - self, argv=None, find_extensions=True, new_httpserver=True, starter_extension=None + self, + argv=None, + find_extensions=True, + new_httpserver=True, + starter_extension=None, ): """Initialize the Server application class, configurables, web application, and http server. diff --git a/jupyter_server/services/api/handlers.py b/jupyter_server/services/api/handlers.py index dd47bfd4c7..8974215eb1 100644 --- a/jupyter_server/services/api/handlers.py +++ b/jupyter_server/services/api/handlers.py @@ -10,14 +10,21 @@ from ...base.handlers import JupyterHandler from jupyter_server._tz import isoformat from jupyter_server._tz import utcfromtimestamp +from jupyter_server.auth import authorized from jupyter_server.utils import ensure_async +AUTH_RESOURCE = "api" + + class APISpecHandler(web.StaticFileHandler, JupyterHandler): + auth_resource = AUTH_RESOURCE + def initialize(self): web.StaticFileHandler.initialize(self, path=os.path.dirname(__file__)) @web.authenticated + @authorized def get(self): self.log.warning("Serving api spec (experimental, incomplete)") return web.StaticFileHandler.get(self, "api.yaml") @@ -28,9 +35,11 @@ def get_content_type(self): class APIStatusHandler(APIHandler): + auth_resource = AUTH_RESOURCE _track_activity = False @web.authenticated + @authorized async def get(self): # if started was missing, use unix epoch started = self.settings.get("started", utcfromtimestamp(0)) diff --git a/jupyter_server/services/config/handlers.py b/jupyter_server/services/config/handlers.py index 783cf49321..09bb88f1aa 100644 --- a/jupyter_server/services/config/handlers.py +++ b/jupyter_server/services/config/handlers.py @@ -6,21 +6,30 @@ from tornado import web from ...base.handlers import APIHandler +from jupyter_server.auth import authorized + + +AUTH_RESOURCE = "config" class ConfigHandler(APIHandler): + auth_resource = AUTH_RESOURCE + @web.authenticated + @authorized def get(self, section_name): self.set_header("Content-Type", "application/json") self.finish(json.dumps(self.config_manager.get(section_name))) @web.authenticated + @authorized def put(self, section_name): data = self.get_json_body() # Will raise 400 if content is not valid JSON self.config_manager.set(section_name, data) self.set_status(204) @web.authenticated + @authorized def patch(self, section_name): new_data = self.get_json_body() section = self.config_manager.update(section_name, new_data) diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index 6fa7b0f2b2..e4e97bb59e 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -18,6 +18,10 @@ from jupyter_server.utils import ensure_async from jupyter_server.utils import url_escape from jupyter_server.utils import url_path_join +from jupyter_server.auth import authorized + + +AUTH_RESOURCE = "contents" def validate_model(model, expect_content): @@ -62,7 +66,11 @@ def validate_model(model, expect_content): ) -class ContentsHandler(APIHandler): +class ContentsAPIHandler(APIHandler): + auth_resource = AUTH_RESOURCE + + +class ContentsHandler(ContentsAPIHandler): def location_url(self, path): """Return the full URL location of a file. @@ -83,6 +91,7 @@ def _finish_model(self, model, location=True): self.finish(json.dumps(model, default=json_default)) @web.authenticated + @authorized async def get(self, path=""): """Return a model for a file or directory. @@ -114,6 +123,7 @@ async def get(self, path=""): self._finish_model(model, location=False) @web.authenticated + @authorized async def patch(self, path=""): """PATCH renames a file or directory without re-uploading content.""" cm = self.contents_manager @@ -165,6 +175,7 @@ async def _save(self, model, path): self._finish_model(model) @web.authenticated + @authorized async def post(self, path=""): """Create a new file in the specified path. @@ -201,6 +212,7 @@ async def post(self, path=""): await self._new_untitled(path) @web.authenticated + @authorized async def put(self, path=""): """Saves the file in the location specified by name and path. @@ -225,6 +237,7 @@ async def put(self, path=""): await self._new_untitled(path) @web.authenticated + @authorized async def delete(self, path=""): """delete a file in the given path""" cm = self.contents_manager @@ -234,8 +247,9 @@ async def delete(self, path=""): self.finish() -class CheckpointsHandler(APIHandler): +class CheckpointsHandler(ContentsAPIHandler): @web.authenticated + @authorized async def get(self, path=""): """get lists checkpoints for a file""" cm = self.contents_manager @@ -244,6 +258,7 @@ async def get(self, path=""): self.finish(data) @web.authenticated + @authorized async def post(self, path=""): """post creates a new checkpoint""" cm = self.contents_manager @@ -261,8 +276,9 @@ async def post(self, path=""): self.finish(data) -class ModifyCheckpointsHandler(APIHandler): +class ModifyCheckpointsHandler(ContentsAPIHandler): @web.authenticated + @authorized async def post(self, path, checkpoint_id): """post restores a file from a checkpoint""" cm = self.contents_manager @@ -271,6 +287,7 @@ async def post(self, path, checkpoint_id): self.finish() @web.authenticated + @authorized async def delete(self, path, checkpoint_id): """delete clears a checkpoint for a given file""" cm = self.contents_manager @@ -295,6 +312,7 @@ class TrustNotebooksHandler(JupyterHandler): """ Handles trust/signing of notebooks """ @web.authenticated + @authorized(resource=AUTH_RESOURCE) async def post(self, path=""): cm = self.contents_manager await ensure_async(cm.trust_notebook(path)) @@ -309,6 +327,7 @@ async def post(self, path=""): _checkpoint_id_regex = r"(?P[\w-]+)" + default_handlers = [ (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler), ( diff --git a/jupyter_server/services/contents/manager.py b/jupyter_server/services/contents/manager.py index f522b4bd26..d5c728cb17 100644 --- a/jupyter_server/services/contents/manager.py +++ b/jupyter_server/services/contents/manager.py @@ -81,7 +81,9 @@ def _notary_default(self): ) untitled_notebook = Unicode( - _i18n("Untitled"), config=True, help="The base name used when creating untitled notebooks." + _i18n("Untitled"), + config=True, + help="The base name used when creating untitled notebooks.", ) untitled_file = Unicode( diff --git a/jupyter_server/services/kernels/handlers.py b/jupyter_server/services/kernels/handlers.py index 6ed315f6e1..84d14fd26e 100644 --- a/jupyter_server/services/kernels/handlers.py +++ b/jupyter_server/services/kernels/handlers.py @@ -30,16 +30,26 @@ from jupyter_server.utils import ensure_async from jupyter_server.utils import url_escape from jupyter_server.utils import url_path_join +from jupyter_server.auth import authorized -class MainKernelHandler(APIHandler): +AUTH_RESOURCE = "kernels" + + +class KernelsAPIHandler(APIHandler): + auth_resource = AUTH_RESOURCE + + +class MainKernelHandler(KernelsAPIHandler): @web.authenticated + @authorized async def get(self): km = self.kernel_manager kernels = await ensure_async(km.list_kernels()) self.finish(json.dumps(kernels, default=json_default)) @web.authenticated + @authorized async def post(self): km = self.kernel_manager model = self.get_json_body() @@ -56,14 +66,16 @@ async def post(self): self.finish(json.dumps(model, default=json_default)) -class KernelHandler(APIHandler): +class KernelHandler(KernelsAPIHandler): @web.authenticated + @authorized async def get(self, kernel_id): km = self.kernel_manager model = await ensure_async(km.kernel_model(kernel_id)) self.finish(json.dumps(model, default=json_default)) @web.authenticated + @authorized async def delete(self, kernel_id): km = self.kernel_manager await ensure_async(km.shutdown_kernel(kernel_id)) @@ -71,8 +83,9 @@ async def delete(self, kernel_id): self.finish() -class KernelActionHandler(APIHandler): +class KernelActionHandler(KernelsAPIHandler): @web.authenticated + @authorized async def post(self, kernel_id, action): km = self.kernel_manager if action == "interrupt": @@ -99,6 +112,8 @@ class ZMQChannelsHandler(AuthenticatedZMQStreamHandler): the sessions. """ + auth_resource = AUTH_RESOURCE + # class-level registry of open sessions # allows checking for conflict on session-id, # which is used as a zmq identity and must be unique. @@ -126,7 +141,10 @@ def rate_limit_window(self): return self.settings.get("rate_limit_window", 1.0) def __repr__(self): - return "%s(%s)" % (self.__class__.__name__, getattr(self, "kernel_id", "uninitialized")) + return "%s(%s)" % ( + self.__class__.__name__, + getattr(self, "kernel_id", "uninitialized"), + ) def create_stream(self): km = self.kernel_manager @@ -566,7 +584,6 @@ def _limit_rate(self, channel, msg, msg_list): self._iopub_data_exceeded = False if msg_type not in {"status", "comm_open", "execute_input"}: - # Remove the counts queued for removal. now = IOLoop.current().time() while len(self._iopub_window_byte_queue) > 0: @@ -760,6 +777,9 @@ def _on_error(self, channel, msg, msg_list): default_handlers = [ (r"/api/kernels", MainKernelHandler), (r"/api/kernels/%s" % _kernel_id_regex, KernelHandler), - (r"/api/kernels/%s/%s" % (_kernel_id_regex, _kernel_action_regex), KernelActionHandler), + ( + r"/api/kernels/%s/%s" % (_kernel_id_regex, _kernel_action_regex), + KernelActionHandler, + ), (r"/api/kernels/%s/channels" % _kernel_id_regex, ZMQChannelsHandler), ] diff --git a/jupyter_server/services/kernels/kernelmanager.py b/jupyter_server/services/kernels/kernelmanager.py index e455483d52..8f8fff7667 100644 --- a/jupyter_server/services/kernels/kernelmanager.py +++ b/jupyter_server/services/kernels/kernelmanager.py @@ -143,7 +143,8 @@ def _default_kernel_buffers(self): return defaultdict(lambda: {"buffer": [], "session_key": "", "channels": {}}) last_kernel_activity = Instance( - datetime, help="The last activity on any kernel, including shutting down a kernel" + datetime, + help="The last activity on any kernel, including shutting down a kernel", ) def __init__(self, **kwargs): @@ -519,7 +520,10 @@ def record_activity(msg_list): if msg_type == "status": kernel.execution_state = msg["content"]["execution_state"] self.log.debug( - "activity on %s: %s (%s)", kernel_id, msg_type, kernel.execution_state + "activity on %s: %s (%s)", + kernel_id, + msg_type, + kernel.execution_state, ) else: self.log.debug("activity on %s: %s", kernel_id, msg_type) diff --git a/jupyter_server/services/kernelspecs/handlers.py b/jupyter_server/services/kernelspecs/handlers.py index 1ac353ba62..f00cfcc999 100644 --- a/jupyter_server/services/kernelspecs/handlers.py +++ b/jupyter_server/services/kernelspecs/handlers.py @@ -14,6 +14,10 @@ from ...base.handlers import APIHandler from ...utils import ensure_async, url_path_join, url_unescape +from jupyter_server.auth import authorized + + +AUTH_RESOURCE = "kernelspecs" def kernelspec_model(handler, name, spec_dict, resource_dir): @@ -44,8 +48,13 @@ def is_kernelspec_model(spec_dict): ) -class MainKernelSpecHandler(APIHandler): +class KernelSpecsAPIHandler(APIHandler): + auth_resource = AUTH_RESOURCE + + +class MainKernelSpecHandler(KernelSpecsAPIHandler): @web.authenticated + @authorized async def get(self): ksm = self.kernel_spec_manager km = self.kernel_manager @@ -59,7 +68,10 @@ async def get(self): d = kernel_info else: d = kernelspec_model( - self, kernel_name, kernel_info["spec"], kernel_info["resource_dir"] + self, + kernel_name, + kernel_info["spec"], + kernel_info["resource_dir"], ) except Exception: self.log.error("Failed to load kernel spec: '%s'", kernel_name, exc_info=True) @@ -69,8 +81,9 @@ async def get(self): self.finish(json.dumps(model)) -class KernelSpecHandler(APIHandler): +class KernelSpecHandler(KernelSpecsAPIHandler): @web.authenticated + @authorized async def get(self, kernel_name): ksm = self.kernel_spec_manager kernel_name = url_unescape(kernel_name) diff --git a/jupyter_server/services/nbconvert/handlers.py b/jupyter_server/services/nbconvert/handlers.py index 67f1c4afb9..d64c0566ea 100644 --- a/jupyter_server/services/nbconvert/handlers.py +++ b/jupyter_server/services/nbconvert/handlers.py @@ -5,13 +5,20 @@ from tornado import web from ...base.handlers import APIHandler +from jupyter_server.auth import authorized + + +AUTH_RESOURCE = "nbconvert" LOCK = asyncio.Lock() class NbconvertRootHandler(APIHandler): + auth_resource = AUTH_RESOURCE + @web.authenticated + @authorized async def get(self): try: from nbconvert.exporters import base diff --git a/jupyter_server/services/security/handlers.py b/jupyter_server/services/security/handlers.py index 91cd9a4834..5bf540fa72 100644 --- a/jupyter_server/services/security/handlers.py +++ b/jupyter_server/services/security/handlers.py @@ -5,11 +5,16 @@ from . import csp_report_uri from ...base.handlers import APIHandler +from jupyter_server.auth import authorized + + +AUTH_RESOURCE = "csp" class CSPReportHandler(APIHandler): """Accepts a content security policy violation report""" + auth_resource = AUTH_RESOURCE _track_activity = False def skip_check_origin(self): @@ -21,10 +26,12 @@ def check_xsrf_cookie(self): return @web.authenticated + @authorized def post(self): """Log a content security policy violation report""" self.log.warning( - "Content security violation: %s", self.request.body.decode("utf8", "replace") + "Content security violation: %s", + self.request.body.decode("utf8", "replace"), ) diff --git a/jupyter_server/services/sessions/handlers.py b/jupyter_server/services/sessions/handlers.py index 336934235f..09e3ca367b 100644 --- a/jupyter_server/services/sessions/handlers.py +++ b/jupyter_server/services/sessions/handlers.py @@ -17,10 +17,19 @@ from ...base.handlers import APIHandler from jupyter_server.utils import ensure_async from jupyter_server.utils import url_path_join +from jupyter_server.auth import authorized -class SessionRootHandler(APIHandler): +AUTH_RESOURCE = "sessions" + + +class SessionsAPIHandler(APIHandler): + auth_resource = AUTH_RESOURCE + + +class SessionRootHandler(SessionsAPIHandler): @web.authenticated + @authorized async def get(self): # Return a list of running sessions sm = self.session_manager @@ -28,6 +37,7 @@ async def get(self): self.finish(json.dumps(sessions, default=json_default)) @web.authenticated + @authorized async def post(self): # Creates a new session # (unless a session already exists for the named session) @@ -67,7 +77,11 @@ async def post(self): else: try: model = await sm.create_session( - path=path, kernel_name=kernel_name, kernel_id=kernel_id, name=name, type=mtype + path=path, + kernel_name=kernel_name, + kernel_id=kernel_id, + name=name, + type=mtype, ) except NoSuchKernel: msg = ( @@ -88,8 +102,9 @@ async def post(self): self.finish(json.dumps(model, default=json_default)) -class SessionHandler(APIHandler): +class SessionHandler(SessionsAPIHandler): @web.authenticated + @authorized async def get(self, session_id): # Returns the JSON model for a single session sm = self.session_manager @@ -97,6 +112,7 @@ async def get(self, session_id): self.finish(json.dumps(model, default=json_default)) @web.authenticated + @authorized async def patch(self, session_id): """Patch updates sessions: @@ -154,6 +170,7 @@ async def patch(self, session_id): self.finish(json.dumps(model, default=json_default)) @web.authenticated + @authorized async def delete(self, session_id): # Deletes the session with given session_id sm = self.session_manager diff --git a/jupyter_server/services/sessions/sessionmanager.py b/jupyter_server/services/sessions/sessionmanager.py index 02e9dc4625..968f3f34e0 100644 --- a/jupyter_server/services/sessions/sessionmanager.py +++ b/jupyter_server/services/sessions/sessionmanager.py @@ -168,7 +168,8 @@ async def save_session(self, session_id, path=None, name=None, type=None, kernel a dictionary of the session model """ self.cursor.execute( - "INSERT INTO session VALUES (?,?,?,?,?)", (session_id, path, name, type, kernel_id) + "INSERT INTO session VALUES (?,?,?,?,?)", + (session_id, path, name, type, kernel_id), ) result = await self.get_session(session_id=session_id) return result diff --git a/jupyter_server/services/shutdown.py b/jupyter_server/services/shutdown.py index 959fc1addf..a77e90091b 100644 --- a/jupyter_server/services/shutdown.py +++ b/jupyter_server/services/shutdown.py @@ -3,11 +3,18 @@ from tornado import ioloop from tornado import web +from jupyter_server.auth import authorized from jupyter_server.base.handlers import JupyterHandler +AUTH_RESOURCE = "server" + + class ShutdownHandler(JupyterHandler): + auth_resource = AUTH_RESOURCE + @web.authenticated + @authorized async def post(self): self.log.info("Shutting down on /api/shutdown request.") diff --git a/jupyter_server/terminal/api_handlers.py b/jupyter_server/terminal/api_handlers.py index 76bfeee7c0..99f7e91d2a 100644 --- a/jupyter_server/terminal/api_handlers.py +++ b/jupyter_server/terminal/api_handlers.py @@ -3,15 +3,25 @@ from tornado import web from ..base.handlers import APIHandler +from jupyter_server.auth import authorized -class TerminalRootHandler(APIHandler): +AUTH_RESOURCE = "terminals" + + +class TerminalAPIHandler(APIHandler): + auth_resource = AUTH_RESOURCE + + +class TerminalRootHandler(TerminalAPIHandler): @web.authenticated + @authorized def get(self): models = self.terminal_manager.list() self.finish(json.dumps(models)) @web.authenticated + @authorized def post(self): """POST /terminals creates a new terminal and redirects to it""" data = self.get_json_body() or {} @@ -20,15 +30,17 @@ def post(self): self.finish(json.dumps(model)) -class TerminalHandler(APIHandler): +class TerminalHandler(TerminalAPIHandler): SUPPORTED_METHODS = ("GET", "DELETE") @web.authenticated + @authorized def get(self, name): model = self.terminal_manager.get(name) self.finish(json.dumps(model)) @web.authenticated + @authorized async def delete(self, name): await self.terminal_manager.terminate(name, force=True) self.set_status(204) diff --git a/jupyter_server/terminal/handlers.py b/jupyter_server/terminal/handlers.py index e56c780dcb..7c11170196 100644 --- a/jupyter_server/terminal/handlers.py +++ b/jupyter_server/terminal/handlers.py @@ -9,8 +9,13 @@ from ..base.zmqhandlers import WebSocketMixin from jupyter_server._tz import utcnow +AUTH_RESOURCE = "terminals" + class TermSocket(WebSocketMixin, JupyterHandler, terminado.TermSocket): + + auth_resource = AUTH_RESOURCE + def origin_check(self): """Terminado adds redundant origin_check Tornado already calls check_origin, so don't do anything here. @@ -18,8 +23,14 @@ def origin_check(self): return True def get(self, *args, **kwargs): - if not self.get_current_user(): + user = self.current_user + + if not user: raise web.HTTPError(403) + + if not self.authorizer.is_authorized(self, user, "execute", self.auth_resource): + raise web.HTTPError(403) + if not args[0] in self.term_manager.terminals: raise web.HTTPError(404) return super(TermSocket, self).get(*args, **kwargs) diff --git a/jupyter_server/terminal/terminalmanager.py b/jupyter_server/terminal/terminalmanager.py index cfbfea8e4c..4e8ddcaca1 100644 --- a/jupyter_server/terminal/terminalmanager.py +++ b/jupyter_server/terminal/terminalmanager.py @@ -162,6 +162,8 @@ async def _cull_inactive_terminal(self, name): if is_time: inactivity = int(dt_inactive.total_seconds()) self.log.warning( - "Culling terminal '%s' due to %s seconds of inactivity.", name, inactivity + "Culling terminal '%s' due to %s seconds of inactivity.", + name, + inactivity, ) await self.terminate(name, force=True) diff --git a/jupyter_server/tests/auth/test_authorizer.py b/jupyter_server/tests/auth/test_authorizer.py new file mode 100644 index 0000000000..a0453200e0 --- /dev/null +++ b/jupyter_server/tests/auth/test_authorizer.py @@ -0,0 +1,277 @@ +"""Tests for authorization""" +import json + +import pytest +from jupyter_client.kernelspec import NATIVE_KERNEL_NAME +from nbformat import writes +from nbformat.v4 import new_notebook +from tornado.httpclient import HTTPClientError +from tornado.websocket import WebSocketHandler + +from jupyter_server.auth.authorizer import Authorizer +from jupyter_server.auth.utils import HTTP_METHOD_TO_AUTH_ACTION +from jupyter_server.auth.utils import match_url_to_resource +from jupyter_server.services.security import csp_report_uri + + +class AuthorizerforTesting(Authorizer): + + # Set these class attributes from within a test + # to verify that they match the arguments passed + # by the REST API. + permissions = {} + + def normalize_url(self, path): + """Drop the base URL and make sure path leads with a /""" + base_url = self.parent.base_url + # Remove base_url + if path.startswith(base_url): + path = path[len(base_url) :] + # Make sure path starts with / + if not path.startswith("/"): + path = "/" + path + return path + + def is_authorized(self, handler, user, action, resource): + # Parse Request + if isinstance(handler, WebSocketHandler): + method = "WEBSOCKET" + else: + method = handler.request.method + url = self.normalize_url(handler.request.path) + + # Map request parts to expected action and resource. + expected_action = HTTP_METHOD_TO_AUTH_ACTION[method] + expected_resource = match_url_to_resource(url) + + # Assert that authorization layer returns the + # correct action + resource. + assert action == expected_action + assert resource == expected_resource + + # Now, actually apply the authorization layer. + return all( + [ + action in self.permissions.get("actions", []), + resource in self.permissions.get("resources", []), + ] + ) + + +@pytest.fixture +def jp_server_config(): + return {"ServerApp": {"authorizer_class": AuthorizerforTesting}} + + +@pytest.fixture +def send_request(jp_fetch, jp_ws_fetch): + """Send to Jupyter Server and return response code.""" + + async def _(url, **fetch_kwargs): + if url.endswith("channels") or "/websocket/" in url: + fetch = jp_ws_fetch + else: + fetch = jp_fetch + + try: + r = await fetch(url, **fetch_kwargs, allow_nonstandard_methods=True) + code = r.code + except HTTPClientError as err: + code = err.code + else: + if fetch is jp_ws_fetch: + r.close() + + print(code, url, fetch_kwargs) + return code + + return _ + + +HTTP_REQUESTS = [ + { + "method": "GET", + "url": "/view/{nbpath}", + }, + { + "method": "GET", + "url": "/api/contents", + }, + { + "method": "POST", + "url": "/api/contents", + "body": json.dumps({"type": "directory"}), + }, + { + "method": "PUT", + "url": "/api/contents/foo", + "body": json.dumps({"type": "directory"}), + }, + { + "method": "PATCH", + "url": "/api/contents/{nbpath}", + "body": json.dumps({"path": "/newpath"}), + }, + { + "method": "DELETE", + "url": "/api/contents/{nbpath}", + }, + { + "method": "GET", + "url": "/api/kernels", + }, + { + "method": "GET", + "url": "/api/kernels/{kernel_id}", + }, + { + "method": "GET", + "url": "/api/kernels/{kernel_id}/channels", + }, + { + "method": "POST", + "url": "/api/kernels/{kernel_id}/interrupt", + }, + { + "method": "POST", + "url": "/api/kernels/{kernel_id}/restart", + }, + { + "method": "DELETE", + "url": "/api/kernels/{kernel_id}", + }, + { + "method": "POST", + "url": "/api/kernels", + }, + {"method": "GET", "url": "/api/kernelspecs"}, + {"method": "GET", "url": "/api/kernelspecs/{kernelspec}"}, + {"method": "GET", "url": "/api/nbconvert"}, + {"method": "GET", "url": "/api/spec.yaml"}, + {"method": "GET", "url": "/api/status"}, + {"method": "GET", "url": "/api/config/foo"}, + {"method": "PUT", "url": "/api/config/foo", "body": "{}"}, + {"method": "PATCH", "url": "/api/config/foo", "body": "{}"}, + { + "method": "POST", + "url": "/".join(tuple(csp_report_uri.split("/")[1:])), + }, + { + "method": "GET", + "url": "/api/sessions", + }, + { + "method": "GET", + "url": "/api/sessions/{session_id}", + }, + {"method": "PATCH", "url": "/api/sessions/{session_id}", "body": "{}"}, + { + "method": "DELETE", + "url": "/api/sessions/{session_id}", + }, + { + "method": "POST", + "url": "/api/sessions", + "body": json.dumps({"path": "foo", "type": "bar"}), + }, + { + "method": "POST", + "url": "/api/terminals", + "body": "", + }, + { + "method": "GET", + "url": "/api/terminals", + }, + { + "method": "GET", + "url": "/terminals/websocket/{term_name}", + }, + { + "method": "DELETE", + "url": "/api/terminals/{term_name}", + }, +] + +HTTP_REQUESTS_PARAMETRIZED = [(req["method"], req["url"], req.get("body")) for req in HTTP_REQUESTS] + +# -------- Test scenarios ----------- + + +@pytest.mark.parametrize("method, url, body", HTTP_REQUESTS_PARAMETRIZED) +@pytest.mark.parametrize("allowed", (True, False)) +async def test_authorized_requests( + request, + io_loop, + send_request, + tmp_path, + jp_serverapp, + method, + url, + body, + allowed, +): + ### Setup stuff for the Contents API + # Add a notebook on disk + contents_dir = tmp_path / jp_serverapp.root_dir + p = contents_dir / "dir_for_testing" + p.mkdir(parents=True, exist_ok=True) + + # Create a notebook + nb = writes(new_notebook(), version=4) + nbname = p.joinpath("nb_for_testing.ipynb") + nbname.write_text(nb, encoding="utf-8") + + ### Setup + nbpath = "dir_for_testing/nb_for_testing.ipynb" + kernelspec = NATIVE_KERNEL_NAME + km = jp_serverapp.kernel_manager + + if "session" in url: + request.addfinalizer(lambda: io_loop.run_sync(km.shutdown_all)) + session_model = await jp_serverapp.session_manager.create_session(path="foo") + session_id = session_model["id"] + + if "kernel" in url: + request.addfinalizer(lambda: io_loop.run_sync(km.shutdown_all)) + kernel_id = await km.start_kernel() + kernel = km.get_kernel(kernel_id) + # kernels take a moment to be ready + # wait for it to respond + kc = kernel.client() + kc.start_channels() + await kc.wait_for_ready() + kc.stop_channels() + + if "terminal" in url: + term_manager = jp_serverapp.web_app.settings["terminal_manager"] + request.addfinalizer(lambda: io_loop.run_sync(term_manager.terminate_all)) + term_model = term_manager.create() + term_name = term_model["name"] + + url = url.format(**locals()) + if allowed: + # Create a server with full permissions + permissions = { + "actions": ["read", "write", "execute"], + "resources": [ + "contents", + "kernels", + "kernelspecs", + "nbconvert", + "sessions", + "api", + "config", + "csp", + "server", + "terminals", + ], + } + expected_codes = {200, 201, 204, None} # Websockets don't return a code + else: + permissions = {"actions": [], "resources": []} + expected_codes = {403} + jp_serverapp.authorizer.permissions = permissions + + code = await send_request(url, body=body, method=method) + assert code in expected_codes diff --git a/jupyter_server/tests/auth/test_utils.py b/jupyter_server/tests/auth/test_utils.py new file mode 100644 index 0000000000..4927c2243a --- /dev/null +++ b/jupyter_server/tests/auth/test_utils.py @@ -0,0 +1,37 @@ +import pytest + +from jupyter_server.auth.utils import match_url_to_resource + + +@pytest.mark.parametrize( + "url,expected_resource", + [ + ("/api/kernels", "kernels"), + ("/api/kernelspecs", "kernelspecs"), + ("/api/contents", "contents"), + ("/api/sessions", "sessions"), + ("/api/terminals", "terminals"), + ("/api/nbconvert", "nbconvert"), + ("/api/config/x", "config"), + ("/api/shutdown", "server"), + ("/nbconvert/py", "nbconvert"), + ], +) +def test_match_url_to_resource(url, expected_resource): + resource = match_url_to_resource(url) + assert resource == expected_resource + + +@pytest.mark.parametrize( + "url", + [ + "/made/up/url", + # Misspell. + "/api/kernel", + # Not a resource + "/tree", + ], +) +def test_bad_match_url_to_resource(url): + resource = match_url_to_resource(url) + assert resource is None diff --git a/jupyter_server/tests/extension/mockextensions/__init__.py b/jupyter_server/tests/extension/mockextensions/__init__.py index d821824771..7b60ae58ba 100644 --- a/jupyter_server/tests/extension/mockextensions/__init__.py +++ b/jupyter_server/tests/extension/mockextensions/__init__.py @@ -8,7 +8,10 @@ # by the test functions. def _jupyter_server_extension_points(): return [ - {"module": "jupyter_server.tests.extension.mockextensions.app", "app": MockExtensionApp}, + { + "module": "jupyter_server.tests.extension.mockextensions.app", + "app": MockExtensionApp, + }, {"module": "jupyter_server.tests.extension.mockextensions.mock1"}, {"module": "jupyter_server.tests.extension.mockextensions.mock2"}, {"module": "jupyter_server.tests.extension.mockextensions.mock3"}, diff --git a/jupyter_server/tests/extension/test_app.py b/jupyter_server/tests/extension/test_app.py index 58fa38cda2..5078a5c5bc 100644 --- a/jupyter_server/tests/extension/test_app.py +++ b/jupyter_server/tests/extension/test_app.py @@ -12,7 +12,10 @@ def jp_server_config(jp_template_dir): "ServerApp": { "jpserver_extensions": {"jupyter_server.tests.extension.mockextensions": True}, }, - "MockExtensionApp": {"template_paths": [str(jp_template_dir)], "log_level": "DEBUG"}, + "MockExtensionApp": { + "template_paths": [str(jp_template_dir)], + "log_level": "DEBUG", + }, } return config @@ -36,7 +39,13 @@ def test_initialize(jp_serverapp, jp_template_dir, mock_extension): @pytest.mark.parametrize( "trait_name, trait_value, jp_argv", - (["mock_trait", "test mock trait", ["--MockExtensionApp.mock_trait=test mock trait"]],), + ( + [ + "mock_trait", + "test mock trait", + ["--MockExtensionApp.mock_trait=test mock trait"], + ], + ), ) def test_instance_creation_with_argv( trait_name, @@ -66,10 +75,34 @@ def test_extensionapp_load_config_file( (False, {"ServerApp": {"open_browser": False}}), (True, {"MockExtensionApp": {"open_browser": True}}), (False, {"MockExtensionApp": {"open_browser": False}}), - (True, {"ServerApp": {"open_browser": True}, "MockExtensionApp": {"open_browser": True}}), - (False, {"ServerApp": {"open_browser": True}, "MockExtensionApp": {"open_browser": False}}), - (True, {"ServerApp": {"open_browser": False}, "MockExtensionApp": {"open_browser": True}}), - (False, {"ServerApp": {"open_browser": False}, "MockExtensionApp": {"open_browser": False}}), + ( + True, + { + "ServerApp": {"open_browser": True}, + "MockExtensionApp": {"open_browser": True}, + }, + ), + ( + False, + { + "ServerApp": {"open_browser": True}, + "MockExtensionApp": {"open_browser": False}, + }, + ), + ( + True, + { + "ServerApp": {"open_browser": False}, + "MockExtensionApp": {"open_browser": True}, + }, + ), + ( + False, + { + "ServerApp": {"open_browser": False}, + "MockExtensionApp": {"open_browser": False}, + }, + ), ) diff --git a/jupyter_server/tests/extension/test_serverextension.py b/jupyter_server/tests/extension/test_serverextension.py index 0e7ed45893..5140cdf49a 100644 --- a/jupyter_server/tests/extension/test_serverextension.py +++ b/jupyter_server/tests/extension/test_serverextension.py @@ -46,20 +46,28 @@ def test_merge_config(jp_env_config_path, jp_configurable_serverapp, jp_extensio # Toggle each extension module with a JSON config file # at the sys-prefix config dir. toggle_server_extension_python( - "jupyter_server.tests.extension.mockextensions.mockext_sys", enabled=True, sys_prefix=True + "jupyter_server.tests.extension.mockextensions.mockext_sys", + enabled=True, + sys_prefix=True, ) toggle_server_extension_python( - "jupyter_server.tests.extension.mockextensions.mockext_user", enabled=True, user=True + "jupyter_server.tests.extension.mockextensions.mockext_user", + enabled=True, + user=True, ) # Write this configuration in two places, sys-prefix and user. # sys-prefix supercedes users, so the extension should be disabled # when these two configs merge. toggle_server_extension_python( - "jupyter_server.tests.extension.mockextensions.mockext_both", enabled=True, sys_prefix=True + "jupyter_server.tests.extension.mockextensions.mockext_both", + enabled=True, + sys_prefix=True, ) toggle_server_extension_python( - "jupyter_server.tests.extension.mockextensions.mockext_both", enabled=False, user=True + "jupyter_server.tests.extension.mockextensions.mockext_both", + enabled=False, + user=True, ) arg = "--ServerApp.jpserver_extensions={{'{mockext_py}': True}}".format( diff --git a/jupyter_server/tests/nbconvert/test_handlers.py b/jupyter_server/tests/nbconvert/test_handlers.py index 1343b397e5..d3769a5dae 100644 --- a/jupyter_server/tests/nbconvert/test_handlers.py +++ b/jupyter_server/tests/nbconvert/test_handlers.py @@ -52,7 +52,12 @@ def notebook(jp_root_dir): async def test_from_file(jp_fetch, notebook): r = await jp_fetch( - "nbconvert", "html", "foo", "testnb.ipynb", method="GET", params={"download": False} + "nbconvert", + "html", + "foo", + "testnb.ipynb", + method="GET", + params={"download": False}, ) assert r.code == 200 @@ -61,7 +66,12 @@ async def test_from_file(jp_fetch, notebook): assert "print" in r.body.decode() r = await jp_fetch( - "nbconvert", "python", "foo", "testnb.ipynb", method="GET", params={"download": False} + "nbconvert", + "python", + "foo", + "testnb.ipynb", + method="GET", + params={"download": False}, ) assert r.code == 200 @@ -84,7 +94,12 @@ async def test_from_file_404(jp_fetch, notebook): async def test_from_file_download(jp_fetch, notebook): r = await jp_fetch( - "nbconvert", "python", "foo", "testnb.ipynb", method="GET", params={"download": True} + "nbconvert", + "python", + "foo", + "testnb.ipynb", + method="GET", + params={"download": True}, ) content_disposition = r.headers["Content-Disposition"] assert "attachment" in content_disposition @@ -93,7 +108,12 @@ async def test_from_file_download(jp_fetch, notebook): async def test_from_file_zip(jp_fetch, notebook): r = await jp_fetch( - "nbconvert", "latex", "foo", "testnb.ipynb", method="GET", params={"download": True} + "nbconvert", + "latex", + "foo", + "testnb.ipynb", + method="GET", + params={"download": True}, ) assert "application/zip" in r.headers["Content-Type"] assert ".zip" in r.headers["Content-Disposition"] diff --git a/jupyter_server/tests/services/contents/test_api.py b/jupyter_server/tests/services/contents/test_api.py index 2774e9ace3..668a815248 100644 --- a/jupyter_server/tests/services/contents/test_api.py +++ b/jupyter_server/tests/services/contents/test_api.py @@ -368,7 +368,12 @@ async def test_mkdir(jp_fetch, contents, contents_dir, _check_created): name = "New ∂ir" path = "å b" r = await jp_fetch( - "api", "contents", path, name, method="PUT", body=json.dumps({"type": "directory"}) + "api", + "contents", + path, + name, + method="PUT", + body=json.dumps({"type": "directory"}), ) _check_created(r, str(contents_dir), path, name, type="directory") @@ -376,7 +381,11 @@ async def test_mkdir(jp_fetch, contents, contents_dir, _check_created): async def test_mkdir_hidden_400(jp_fetch): with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( - "api", "contents", "å b/.hidden", method="PUT", body=json.dumps({"type": "directory"}) + "api", + "contents", + "å b/.hidden", + method="PUT", + body=json.dumps({"type": "directory"}), ) assert expected_http_error(e, 400) @@ -427,21 +436,33 @@ async def test_copy(jp_fetch, contents, contents_dir, _check_created): name = "ç d.ipynb" copy = "ç d-Copy1.ipynb" r = await jp_fetch( - "api", "contents", path, method="POST", body=json.dumps({"copy_from": path + "/" + name}) + "api", + "contents", + path, + method="POST", + body=json.dumps({"copy_from": path + "/" + name}), ) _check_created(r, str(contents_dir), path, copy, type="notebook") # Copy the same file name copy2 = "ç d-Copy2.ipynb" r = await jp_fetch( - "api", "contents", path, method="POST", body=json.dumps({"copy_from": path + "/" + name}) + "api", + "contents", + path, + method="POST", + body=json.dumps({"copy_from": path + "/" + name}), ) _check_created(r, str(contents_dir), path, copy2, type="notebook") # copy a copy. copy3 = "ç d-Copy3.ipynb" r = await jp_fetch( - "api", "contents", path, method="POST", body=json.dumps({"copy_from": path + "/" + copy2}) + "api", + "contents", + path, + method="POST", + body=json.dumps({"copy_from": path + "/" + copy2}), ) _check_created(r, str(contents_dir), path, copy3, type="notebook") @@ -452,12 +473,20 @@ async def test_copy_path(jp_fetch, contents, contents_dir, _check_created): name = "a.ipynb" copy = "a-Copy1.ipynb" r = await jp_fetch( - "api", "contents", path2, method="POST", body=json.dumps({"copy_from": path1 + "/" + name}) + "api", + "contents", + path2, + method="POST", + body=json.dumps({"copy_from": path1 + "/" + name}), ) _check_created(r, str(contents_dir), path2, name, type="notebook") r = await jp_fetch( - "api", "contents", path2, method="POST", body=json.dumps({"copy_from": path1 + "/" + name}) + "api", + "contents", + path2, + method="POST", + body=json.dumps({"copy_from": path1 + "/" + name}), ) _check_created(r, str(contents_dir), path2, copy, type="notebook") @@ -477,7 +506,11 @@ async def test_copy_put_400(jp_fetch, contents, contents_dir, _check_created): async def test_copy_dir_400(jp_fetch, contents, contents_dir, _check_created): with pytest.raises(tornado.httpclient.HTTPClientError) as e: await jp_fetch( - "api", "contents", "foo", method="POST", body=json.dumps({"copy_from": "å b"}) + "api", + "contents", + "foo", + method="POST", + body=json.dumps({"copy_from": "å b"}), ) assert expected_http_error(e, 400) @@ -561,7 +594,13 @@ async def test_checkpoints_follow_file(jp_fetch, contents): # Create a checkpoint of initial state r = await jp_fetch( - "api", "contents", path, name, "checkpoints", method="POST", allow_nonstandard_methods=True + "api", + "contents", + path, + name, + "checkpoints", + method="POST", + allow_nonstandard_methods=True, ) cp1 = json.loads(r.body.decode()) @@ -629,7 +668,12 @@ async def test_checkpoints(jp_fetch, contents): resp = await jp_fetch("api", "contents", path, method="GET") model = json.loads(resp.body.decode()) r = await jp_fetch( - "api", "contents", path, "checkpoints", method="POST", allow_nonstandard_methods=True + "api", + "contents", + path, + "checkpoints", + method="POST", + allow_nonstandard_methods=True, ) assert r.code == 201 cp1 = json.loads(r.body.decode()) @@ -687,7 +731,12 @@ async def test_file_checkpoints(jp_fetch, contents): resp = await jp_fetch("api", "contents", path, method="GET") orig_content = json.loads(resp.body.decode())["content"] r = await jp_fetch( - "api", "contents", path, "checkpoints", method="POST", allow_nonstandard_methods=True + "api", + "contents", + path, + "checkpoints", + method="POST", + allow_nonstandard_methods=True, ) assert r.code == 201 cp1 = json.loads(r.body.decode()) @@ -743,6 +792,11 @@ async def test_trust(jp_fetch, contents): # It should be able to trust a notebook that exists for path in contents["notebooks"]: r = await jp_fetch( - "api", "contents", str(path), "trust", method="POST", allow_nonstandard_methods=True + "api", + "contents", + str(path), + "trust", + method="POST", + allow_nonstandard_methods=True, ) assert r.code == 201 diff --git a/jupyter_server/tests/services/contents/test_largefilemanager.py b/jupyter_server/tests/services/contents/test_largefilemanager.py index 89103e232f..46b91edb48 100644 --- a/jupyter_server/tests/services/contents/test_largefilemanager.py +++ b/jupyter_server/tests/services/contents/test_largefilemanager.py @@ -67,7 +67,13 @@ async def test_bad_save(jp_large_contents_manager, model, err_message): async def test_saving_different_chunks(jp_large_contents_manager): cm = jp_large_contents_manager - model = {"name": "test", "path": "test", "type": "file", "content": u"test==", "format": "text"} + model = { + "name": "test", + "path": "test", + "type": "file", + "content": u"test==", + "format": "text", + } name = model["name"] path = model["path"] await ensure_async(cm.save(model, path)) diff --git a/jupyter_server/tests/services/contents/test_manager.py b/jupyter_server/tests/services/contents/test_manager.py index 841c7fba24..3475b5f3d1 100644 --- a/jupyter_server/tests/services/contents/test_manager.py +++ b/jupyter_server/tests/services/contents/test_manager.py @@ -209,7 +209,10 @@ async def test_good_symlink(jp_file_contents_manager_class, tmp_path): symlink(cm, file_model["path"], path) symlink_model = await ensure_async(cm.get(path, content=False)) dir_model = await ensure_async(cm.get(parent)) - assert sorted(dir_model["content"], key=lambda x: x["name"]) == [symlink_model, file_model] + assert sorted(dir_model["content"], key=lambda x: x["name"]) == [ + symlink_model, + file_model, + ] @pytest.mark.skipif(sys.platform.startswith("win"), reason="Can't test permissions on Windows") diff --git a/jupyter_server/tests/services/kernels/test_api.py b/jupyter_server/tests/services/kernels/test_api.py index 37943a24ad..50be5fb200 100644 --- a/jupyter_server/tests/services/kernels/test_api.py +++ b/jupyter_server/tests/services/kernels/test_api.py @@ -126,14 +126,24 @@ async def test_main_kernel_handler( # Interrupt a kernel await pending_kernel_is_ready(kernel2["id"]) r = await jp_fetch( - "api", "kernels", kernel2["id"], "interrupt", method="POST", allow_nonstandard_methods=True + "api", + "kernels", + kernel2["id"], + "interrupt", + method="POST", + allow_nonstandard_methods=True, ) assert r.code == 204 # Restart a kernel await pending_kernel_is_ready(kernel2["id"]) r = await jp_fetch( - "api", "kernels", kernel2["id"], "restart", method="POST", allow_nonstandard_methods=True + "api", + "kernels", + kernel2["id"], + "restart", + method="POST", + allow_nonstandard_methods=True, ) restarted_kernel = json.loads(r.body.decode()) assert restarted_kernel["id"] == kernel2["id"] diff --git a/jupyter_server/tests/services/sessions/test_api.py b/jupyter_server/tests/services/sessions/test_api.py index d039037667..3bbc5d0cd9 100644 --- a/jupyter_server/tests/services/sessions/test_api.py +++ b/jupyter_server/tests/services/sessions/test_api.py @@ -87,7 +87,12 @@ async def _req(self, *args, method, body=None): body = json.dumps(body) r = await self.jp_fetch( - "api", "sessions", *args, method=method, body=body, allow_nonstandard_methods=True + "api", + "sessions", + *args, + method=method, + body=body, + allow_nonstandard_methods=True, ) return r @@ -98,7 +103,11 @@ async def get(self, id): return await self._req(id, method="GET") async def create(self, path, type="notebook", kernel_name=None, kernel_id=None): - body = {"path": path, "type": type, "kernel": {"name": kernel_name, "id": kernel_id}} + body = { + "path": path, + "type": type, + "kernel": {"name": kernel_name, "id": kernel_id}, + } return await self._req(method="POST", body=body) def create_deprecated(self, path): @@ -268,7 +277,12 @@ async def test_create_bad( @pytest.mark.timeout(TEST_TIMEOUT) async def test_create_bad_pending( - session_client, jp_base_url, jp_ws_fetch, jp_cleanup_subprocesses, jp_serverapp, jp_kernelspecs + session_client, + jp_base_url, + jp_ws_fetch, + jp_cleanup_subprocesses, + jp_serverapp, + jp_kernelspecs, ): if not getattr(jp_serverapp.kernel_manager, "use_pending_kernels", False): return diff --git a/jupyter_server/tests/test_files.py b/jupyter_server/tests/test_files.py index d8e65f4e8a..ccbbaf4fdd 100644 --- a/jupyter_server/tests/test_files.py +++ b/jupyter_server/tests/test_files.py @@ -13,7 +13,12 @@ @pytest.fixture( - params=[[False, ["å b"]], [False, ["å b", "ç. d"]], [True, [".å b"]], [True, ["å b", ".ç d"]]] + params=[ + [False, ["å b"]], + [False, ["å b", "ç. d"]], + [True, [".å b"]], + [True, ["å b", ".ç d"]], + ] ) def maybe_hidden(request): return request.param diff --git a/jupyter_server/tests/test_gateway.py b/jupyter_server/tests/test_gateway.py index 1949dc9b6b..ba66568e72 100644 --- a/jupyter_server/tests/test_gateway.py +++ b/jupyter_server/tests/test_gateway.py @@ -371,7 +371,12 @@ async def interrupt_kernel(jp_fetch, kernel_id): """Issues request to interrupt the given kernel""" with mocked_gateway: r = await jp_fetch( - "api", "kernels", kernel_id, "interrupt", method="POST", allow_nonstandard_methods=True + "api", + "kernels", + kernel_id, + "interrupt", + method="POST", + allow_nonstandard_methods=True, ) assert r.code == 204 assert r.reason == "No Content" @@ -381,7 +386,12 @@ async def restart_kernel(jp_fetch, kernel_id): """Issues request to retart the given kernel""" with mocked_gateway: r = await jp_fetch( - "api", "kernels", kernel_id, "restart", method="POST", allow_nonstandard_methods=True + "api", + "kernels", + kernel_id, + "restart", + method="POST", + allow_nonstandard_methods=True, ) assert r.code == 200 model = json.loads(r.body.decode("utf-8")) diff --git a/jupyter_server/tests/test_paths.py b/jupyter_server/tests/test_paths.py index d185234389..0789be4ded 100644 --- a/jupyter_server/tests/test_paths.py +++ b/jupyter_server/tests/test_paths.py @@ -46,7 +46,12 @@ def test_path_regex_bad(): ], ) async def test_trailing_slash( - jp_ensure_app_fixture, uri, expected, http_server_client, jp_auth_header, jp_base_url + jp_ensure_app_fixture, + uri, + expected, + http_server_client, + jp_auth_header, + jp_base_url, ): # http_server_client raises an exception when follow_redirects=False with pytest.raises(tornado.httpclient.HTTPClientError) as err: diff --git a/jupyter_server/tests/test_traittypes.py b/jupyter_server/tests/test_traittypes.py index 7a7be84406..0b1849f686 100644 --- a/jupyter_server/tests/test_traittypes.py +++ b/jupyter_server/tests/test_traittypes.py @@ -30,7 +30,11 @@ class Thing(HasTraits): b = TypeFromClasses( default_value=None, allow_none=True, - klasses=[DummyClass, int, "jupyter_server.services.contents.manager.ContentsManager"], + klasses=[ + DummyClass, + int, + "jupyter_server.services.contents.manager.ContentsManager", + ], ) diff --git a/jupyter_server/tests/test_utils.py b/jupyter_server/tests/test_utils.py index dea714f8cc..c49be09ade 100644 --- a/jupyter_server/tests/test_utils.py +++ b/jupyter_server/tests/test_utils.py @@ -18,7 +18,10 @@ def test_help_output(): [ ("/this is a test/for spaces/", "/this%20is%20a%20test/for%20spaces/"), ("notebook with space.ipynb", "notebook%20with%20space.ipynb"), - ("/path with a/notebook and space.ipynb", "/path%20with%20a/notebook%20and%20space.ipynb"), + ( + "/path with a/notebook and space.ipynb", + "/path%20with%20a/notebook%20and%20space.ipynb", + ), ( "/ !@$#%^&* / test %^ notebook @#$ name.ipynb", "/%20%21%40%24%23%25%5E%26%2A%20/%20test%20%25%5E%20notebook%20%40%23%24%20name.ipynb", diff --git a/jupyter_server/view/handlers.py b/jupyter_server/view/handlers.py index 6ad73a17f1..6bd2f32258 100644 --- a/jupyter_server/view/handlers.py +++ b/jupyter_server/view/handlers.py @@ -9,12 +9,19 @@ from ..utils import ensure_async from ..utils import url_escape from ..utils import url_path_join +from jupyter_server.auth import authorized + + +AUTH_RESOURCE = "contents" class ViewHandler(JupyterHandler): """Render HTML files within an iframe.""" + auth_resource = AUTH_RESOURCE + @web.authenticated + @authorized async def get(self, path): path = path.strip("/") if not await ensure_async(self.contents_manager.file_exists(path)):