diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index eb83d33b..97bdfa60 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -64,7 +64,7 @@ jobs: - name: Install requirements run: | - pip install -r docs/requirements.txt pytest + pip install -e . -r docs/requirements.txt pytest - name: pytest docs/ run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd551181..98b0fe4b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -103,6 +103,9 @@ jobs: subset: singleuser - python: "3.11" browser: browser + - python: "3.11" + subdomain: subdomain + browser: browser - python: "3.12" main_dependencies: main_dependencies diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7bd24ba7..3d5e2a81 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ ci: repos: # autoformat and lint Python code - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.2 + rev: v0.3.5 hooks: - id: ruff types_or: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 1b783e0a..432093fd 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -15,6 +15,7 @@ build: python: install: + - path: . - requirements: docs/requirements.txt formats: diff --git a/README.md b/README.md index e500f931..290ab2c9 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ for administration of the Hub and its users. ### Check prerequisites - A Linux/Unix based system -- [Python](https://www.python.org/downloads/) 3.6 or greater +- [Python](https://www.python.org/downloads/) 3.8 or greater - [nodejs/npm](https://www.npmjs.com/) - If you are using **`conda`**, the nodejs and npm dependencies will be installed for diff --git a/docs/requirements.txt b/docs/requirements.txt index 54683842..5f174d5a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,13 +1,6 @@ -# We install the jupyterhub package to help autodoc-traits inspect it and -# generate documentation. -# -# FIXME: If there is a way for this requirements.txt file to pass a flag that -# the build system can intercept to not build the javascript artifacts, -# then do so so. That would mean that installing the documentation can -# avoid needing node/npm installed. -# ---editable . - +# docs also require jupyterhub itself to be installed +# don't depend on it here, as that often results in a duplicate +# installation of jupyterhub that's already installed autodoc-traits jupyterhub-sphinx-theme myst-parser>=0.19 diff --git a/docs/source/conf.py b/docs/source/conf.py index 7f80b823..4e357d87 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -70,6 +70,8 @@ myst_enable_extensions = [ myst_substitutions = { # date example: Dev 07, 2022 "date": datetime.date.today().strftime("%b %d, %Y").title(), + "node_min": "12", + "python_min": "3.8", "version": jupyterhub.__version__, } @@ -289,6 +291,8 @@ linkcheck_ignore = [ "https://github.com/jupyterhub/jupyterhub/compare/", # too many comparisons in changelog r"https?://(localhost|127.0.0.1).*", # ignore localhost references in auto-links r"https://linux.die.net/.*", # linux.die.net seems to block requests from CI with 403 sometimes + # don't check links to unpublished advisories + r"https://github.com/jupyterhub/jupyterhub/security/advisories/.*", ] linkcheck_anchors_ignore = [ "/#!", diff --git a/docs/source/contributing/setup.md b/docs/source/contributing/setup.md index a00cc131..92da6428 100644 --- a/docs/source/contributing/setup.md +++ b/docs/source/contributing/setup.md @@ -12,18 +12,18 @@ development. ### Install Python JupyterHub is written in the [Python](https://python.org) programming language and -requires you have at least version 3.6 installed locally. If you haven’t +requires you have at least version {{python_min}} installed locally. If you haven’t installed Python before, the recommended way to install it is to use [Miniforge](https://github.com/conda-forge/miniforge#download). ### Install nodejs -[NodeJS 12+](https://nodejs.org/en/) is required for building some JavaScript components. +[NodeJS {{node_min}}+](https://nodejs.org/en/) is required for building some JavaScript components. `configurable-http-proxy`, the default proxy implementation for JupyterHub, is written in Javascript. If you have not installed NodeJS before, we recommend installing it in the `miniconda` environment you set up for Python. You can do so with `conda install nodejs`. -Many in the Jupyter community use \[`nvm`\]() to +Many in the Jupyter community use [`nvm`](https://github.com/nvm-sh/nvm) to managing node dependencies. ### Install git @@ -59,7 +59,7 @@ a more detailed discussion. python -V ``` - This should return a version number greater than or equal to 3.6. + This should return a version number greater than or equal to {{python_min}}. ```bash npm -v diff --git a/docs/source/explanation/websecurity.md b/docs/source/explanation/websecurity.md index a78486f0..854ff1f1 100644 --- a/docs/source/explanation/websecurity.md +++ b/docs/source/explanation/websecurity.md @@ -16,7 +16,8 @@ works. JupyterHub is designed to be a _simple multi-user server for modestly sized groups_ of **semi-trusted** users. While the design reflects serving -semi-trusted users, JupyterHub can also be suitable for serving **untrusted** users. +semi-trusted users, JupyterHub can also be suitable for serving **untrusted** users, +but **is not suitable for untrusted users** in its default configuration. As a result, using JupyterHub with **untrusted** users means more work by the administrator, since much care is required to secure a Hub, with extra caution on @@ -56,30 +57,63 @@ ensure that: If any additional services are run on the same domain as the Hub, the services **must never** display user-authored HTML that is neither _sanitized_ nor _sandboxed_ -(e.g. IFramed) to any user that lacks authentication as the author of a file. +to any user that lacks authentication as the author of a file. + +### Sharing access to servers + +Because sharing access to servers (via `access:servers` scopes or the sharing feature in JupyterHub 5) by definition means users can serve each other files, enabling sharing is not suitable for untrusted users without also enabling per-user domains. + +JupyterHub does not enable any sharing by default. ## Mitigate security issues The several approaches to mitigating security issues with configuration options provided by JupyterHub include: -### Enable subdomains +### Enable user subdomains JupyterHub provides the ability to run single-user servers on their own -subdomains. This means the cross-origin protections between servers has the -desired effect, and user servers and the Hub are protected from each other. A -user's single-user server will be at `username.jupyter.mydomain.com`. This also -requires all user subdomains to point to the same address, which is most easily -accomplished with wildcard DNS. Since this spreads the service across multiple -domains, you will need wildcard SSL as well. Unfortunately, for many -institutional domains, wildcard DNS and SSL are not available. **If you do plan -to serve untrusted users, enabling subdomains is highly encouraged**, as it -resolves the cross-site issues. +domains. This means the cross-origin protections between servers has the +desired effect, and user servers and the Hub are protected from each other. + +**Subdomains are the only way to reliably isolate user servers from each other.** + +To enable subdomains, set: + +```python +c.JupyterHub.subdomain_host = "https://jupyter.example.org" +``` + +When subdomains are enabled, each user's single-user server will be at e.g. `https://username.jupyter.example.org`. +This also requires all user subdomains to point to the same address, +which is most easily accomplished with wildcard DNS, where a single A record points to your server and a wildcard CNAME record points to your A record: + +``` +A jupyter.example.org 192.168.1.123 +CNAME *.jupyter.example.org jupyter.example.org +``` + +Since this spreads the service across multiple domains, you will likely need wildcard SSL as well, +matching `*.jupyter.example.org`. + +Unfortunately, for many institutional domains, wildcard DNS and SSL may not be available. + +We also **strongly encourage** serving JupyterHub and user content on a domain that is _not_ a subdomain of any sensitive content. +For reasoning, see [GitHub's discussion of moving user content to github.io from \*.github.com](https://github.blog/2013-04-09-yummy-cookies-across-domains/). + +**If you do plan to serve untrusted users, enabling subdomains is highly encouraged**, +as it resolves many security issues, which are difficult to unavoidable when JupyterHub is on a single-domain. + +:::{important} +JupyterHub makes no guarantees about protecting users from each other unless subdomains are enabled. + +If you want to protect users from each other, you **_must_** enable per-user domains. +::: ### Disable user config If subdomains are unavailable or undesirable, JupyterHub provides a -configuration option `Spawner.disable_user_config`, which can be set to prevent +configuration option `Spawner.disable_user_config = True`, which can be set to prevent the user-owned configuration files from being loaded. After implementing this option, `PATH`s and package installation are the other things that the admin must enforce. @@ -89,23 +123,24 @@ admin must enforce. For most Spawners, `PATH` is not something users can influence, but it's important that the Spawner should _not_ evaluate shell configuration files prior to launching the server. -### Isolate packages using virtualenv +### Isolate packages in a read-only environment -Package isolation is most easily handled by running the single-user server in -a virtualenv with disabled system-site-packages. The user should not have -permission to install packages into this environment. +The user must not have permission to install packages into the environment where the singleuser-server runs. +On a shared system, package isolation is most easily handled by running the single-user server in +a root-owned virtualenv with disabled system-site-packages. +The user must not have permission to install packages into this environment. The same principle extends to the images used by container-based deployments. -If users can select the images in which their servers run, they can disable all security. +If users can select the images in which their servers run, they can disable all security for their own servers. -It is important to note that the control over the environment only affects the -single-user server, and not the environment(s) in which the user's kernel(s) +It is important to note that the control over the environment is only required for the +single-user server, and not the environment(s) in which the users' kernel(s) may run. Installing additional packages in the kernel environment does not pose additional risk to the web application's security. ### Encrypt internal connections with SSL/TLS -By default, all communications on the server, between the proxy, hub, and single --user notebooks are performed unencrypted. Setting the `internal_ssl` flag in +By default, all communications within JupyterHub—between the proxy, hub, and single +-user notebooks—are performed unencrypted. Setting the `internal_ssl` flag in `jupyterhub_config.py` secures the aforementioned routes. Turning this feature on does require that the enabled `Spawner` can use the certificates generated by the `Hub` (the default `LocalProcessSpawner` can, for instance). @@ -119,6 +154,104 @@ Unix permissions to the communication sockets thereby restricting communication to the socket owner. The `internal_ssl` option will eventually extend to securing the `tcp` sockets as well. +### Mitigating same-origin deployments + +While per-user domains are **required** for robust protection of users from each other, +you can mitigate many (but not all) cross-user issues. +First, it is critical that users cannot modify their server environments, as described above. +Second, it is important that users do not have `access:servers` permission to any server other than their own. + +If users can access each others' servers, additional security measures must be enabled, some of which come with distinct user-experience costs. + +Without the [Same-Origin Policy] (SOP) protecting user servers from each other, +each user server is considered a trusted origin for requests to each other user server (and the Hub itself). +Servers _cannot_ meaningfully distinguish requests originating from other user servers, +because SOP implies a great deal of trust, losing many restrictions applied to cross-origin requests. + +That means pages served from each user server can: + +1. arbitrarily modify the path in the Referer +2. make fully authorized requests with cookies +3. access full page contents served from the hub or other servers via popups + +JupyterHub uses distinct xsrf tokens stored in cookies on each server path to attempt to limit requests across. +This has limitations because not all requests are protected by these XSRF tokens, +and unless additional measures are taken, the XSRF tokens from other user prefixes may be retrieved. + +[Same-Origin Policy]: https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy + +For example: + +- `Content-Security-Policy` header must prohibit popups and iframes from the same origin. + The following Content-Security-Policy rules are _insecure_ and readily enable users to access each others' servers: + + - `frame-ancestors: 'self'` + - `frame-ancestors: '*'` + - `sandbox allow-popups` + +- Ideally, pages should use the strictest `Content-Security-Policy: sandbox` available, + but this is not feasible in general for JupyterLab pages, which need at least `sandbox allow-same-origin allow-scripts` to work. + +The default Content-Security-Policy for single-user servers is + +``` +frame-ancestors: 'none' +``` + +which prohibits iframe embedding, but not pop-ups. + +A more secure Content-Security-Policy that has some costs to user experience is: + +``` +frame-ancestors: 'none'; sandbox allow-same-origin allow-scripts +``` + +`allow-popups` is not disabled by default because disabling it breaks legitimate functionality, like "Open this in a new tab", and the "JupyterHub Control Panel" menu item. +To reiterate, the right way to avoid these issues is to enable per-user domains, where none of these concerns come up. + +Note: even this level of protection requires administrators maintaining full control over the user server environment. +If users can modify their server environment, these methods are ineffective, as users can readily disable them. + +### Cookie tossing + +Cookie tossing is a technique where another server on a subdomain or peer subdomain can set a cookie +which will be read on another domain. +This is not relevant unless there are other user-controlled servers on a peer domain. + +"Domain-locked" cookies avoid this issue, but have their own restrictions: + +- JupyterHub must be served over HTTPS +- All secure cookies must be set on `/`, not on sub-paths, which means they are shared by all JupyterHub components in a single-domain deployment. + +As a result, this option is only recommended when per-user subdomains are enabled, +to prevent sending all jupyterhub cookies to all user servers. + +To enable domain-locked cookies, set: + +```python +c.JupyterHub.cookie_host_prefix_enabled = True +``` + +```{versionadded} 4.1 + +``` + +### Forced-login + +Jupyter servers can share links with `?token=...`. +JupyterHub prior to 5.0 will accept this request and persist the token for future requests. +This is useful for enabling admins to create 'fully authenticated' links bypassing login. +However, it also means users can share their own links that will log other users into their own servers, +enabling them to serve each other notebooks and other arbitrary HTML, depending on server configuration. + +```{versionadded} 4.1 +Setting environment variable `JUPYTERHUB_ALLOW_TOKEN_IN_URL=0` in the single-user environment can opt out of accepting token auth in URL parameters. +``` + +```{versionadded} 5.0 +Accepting tokens in URLs is disabled by default, and `JUPYTERHUB_ALLOW_TOKEN_IN_URL=1` environment variable must be set to _allow_ token auth in URL parameters. +``` + ## Security audits We recommend that you do periodic reviews of your deployment's security. It's diff --git a/docs/source/reference/authenticators.md b/docs/source/reference/authenticators.md index 76656b14..46b09f59 100644 --- a/docs/source/reference/authenticators.md +++ b/docs/source/reference/authenticators.md @@ -37,14 +37,19 @@ A [generic implementation](https://github.com/jupyterhub/oauthenticator/blob/mas ## The Dummy Authenticator When testing, it may be helpful to use the -{class}`jupyterhub.auth.DummyAuthenticator`. This allows for any username and -password unless if a global password has been set. Once set, any username will +{class}`~.jupyterhub.auth.DummyAuthenticator`. This allows for any username and +password unless a global password has been set. Once set, any username will still be accepted but the correct password will need to be provided. +:::{versionadded} 5.0 +The DummyAuthenticator's default `allow_all` is True, +unlike most other Authenticators. +::: + ## Additional Authenticators -A partial list of other authenticators is available on the -[JupyterHub wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators). +Additional authenticators can be found on GitHub +by searching for [topic:jupyterhub topic:authenticator](https://github.com/search?q=topic%3Ajupyterhub%20topic%3Aauthenticator&type=repositories). ## Technical Overview of Authentication @@ -54,9 +59,9 @@ The base authenticator uses simple username and password authentication. The base Authenticator has one central method: -#### Authenticator.authenticate method +#### Authenticator.authenticate - Authenticator.authenticate(handler, data) +{meth}`.Authenticator.authenticate` This method is passed the Tornado `RequestHandler` and the `POST data` from JupyterHub's login form. Unless the login form has been customized, @@ -81,7 +86,8 @@ Writing an Authenticator that looks up passwords in a dictionary requires only overriding this one method: ```python -from IPython.utils.traitlets import Dict +from secrets import compare_digest +from traitlets import Dict from jupyterhub.auth import Authenticator class DictionaryAuthenticator(Authenticator): @@ -91,8 +97,14 @@ class DictionaryAuthenticator(Authenticator): ) async def authenticate(self, handler, data): - if self.passwords.get(data['username']) == data['password']: - return data['username'] + username = data["username"] + password = data["password"] + check_password = self.passwords.get(username, "") + # always call compare_digest, for timing attacks + if compare_digest(check_password, password) and username in self.passwords: + return username + else: + return None ``` #### Normalize usernames @@ -136,7 +148,7 @@ To only allow usernames that start with 'w': c.Authenticator.username_pattern = r'w.*' ``` -### How to write a custom authenticator +## How to write a custom authenticator You can use custom Authenticator subclasses to enable authentication via other mechanisms. One such example is using [GitHub OAuth][]. @@ -148,11 +160,6 @@ and {meth}`.Authenticator.post_spawn_stop`, are hooks that can be used to do auth-related startup (e.g. opening PAM sessions) and cleanup (e.g. closing PAM sessions). -See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/jupyterhub/wiki/Authenticators). - -If you are interested in writing a custom authenticator, you can read -[this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/authenticators.html). - ### Registering custom Authenticators via entry points As of JupyterHub 1.0, custom authenticators can register themselves via @@ -188,6 +195,166 @@ Additionally, configurable attributes for your authenticator will appear in jupyterhub help output and auto-generated configuration files via `jupyterhub --generate-config`. +(authenticator-allow)= + +### Allowing access + +When dealing with logging in, there are generally two _separate_ steps: + +authentication +: identifying who is trying to log in, and + +authorization +: deciding whether an authenticated user is allowed to access your JupyterHub + +{meth}`Authenticator.authenticate` is responsible for authenticating users. +It is perfectly fine in the simplest cases for `Authenticator.authenticate` to be responsible for authentication _and_ authorization, +in which case `authenticate` may return `None` if the user is not authorized. + +However, Authenticators also have two methods, {meth}`~.Authenticator.check_allowed` and {meth}`~.Authenticator.check_blocked_users`, which are called after successful authentication to further check if the user is allowed. + +If `check_blocked_users()` returns False, authorization stops and the user is not allowed. + +If `Authenticator.allow_all` is True OR `check_allowed()` returns True, authorization proceeds. + +:::{versionadded} 5.0 +{attr}`.Authenticator.allow_all` and {attr}`.Authenticator.allow_existing_users` are new in JupyterHub 5.0. + +By default, `allow_all` is False, +which is a change from pre-5.0, where `allow_all` was implicitly True if `allowed_users` was empty. +::: + +### Overriding `check_allowed` + +:::{versionchanged} 5.0 +`check_allowed()` is **not called** if `allow_all` is True. +::: + +:::{versionchanged} 5.0 +Starting with 5.0, `check_allowed()` should **NOT** return True if no allow config +is specified (`allow_all` should be used instead). + +::: + +The base implementation of {meth}`~.Authenticator.check_allowed` checks: + +- if username is in the `allowed_users` set, return True +- else return False + +:::{versionchanged} 5.0 +Prior to 5.0, this would also return True if `allowed_users` was empty. + +For clarity, this is no longer the case. A new `allow_all` property (default False) has been added which is checked _before_ calling `check_allowed`. +If `allow_all` is True, this takes priority over `check_allowed`, which will be ignored. + +If your Authenticator subclass similarly returns True when no allow config is defined, +this is fully backward compatible for your users, but means `allow_all = False` has no real effect. + +You can make your Authenticator forward-compatible with JupyterHub 5 by defining `allow_all` as a boolean config trait on your class: + +```python +class MyAuthenticator(Authenticator): + + # backport allow_all from JupyterHub 5 + allow_all = Bool(False, config=True) + + def check_allowed(self, username, authentication): + if self.allow_all: + # replaces previous "if no auth config" + return True + ... +``` + +::: + +If an Authenticator defines additional sources of `allow` configuration, +such as membership in a group or other information, +it should override `check_allowed` to account for this. + +:::{note} +`allow_` configuration should generally be _additive_, +i.e. if access is granted by _any_ allow configuration, +a user should be authorized. + +JupyterHub recommends that Authenticators applying _restrictive_ configuration should use names like `block_` or `require_`, +and check this during `check_blocked_users` or `authenticate`, not `check_allowed`. +::: + +In general, an Authenticator's skeleton should look like: + +```python +class MyAuthenticator(Authenticator): + # backport allow_all for compatibility with JupyterHub < 5 + allow_all = Bool(False, config=True) + require_something = List(config=True) + allowed_something = Set() + + def authenticate(self, data, handler): + ... + if success: + return {"username": username, "auth_state": {...}} + else: + return None + + def check_blocked_users(self, username, authentication=None): + """Apply _restrictive_ configuration""" + + if self.require_something and not has_something(username, self.request_): + return False + # repeat for each restriction + if restriction_defined and restriction_not_met: + return False + return super().check_blocked_users(self, username, authentication) + + def check_allowed(self, username, authentication=None): + """Apply _permissive_ configuration + + Only called if check_blocked_users returns True + AND allow_all is False + """ + if self.allow_all: + # check here to backport allow_all behavior + # from JupyterHub 5 + # this branch will never be taken with jupyterhub >=5 + return True + if self.allowed_something and user_has_something(username): + return True + # repeat for each allow + if allow_config and allow_met: + return True + # should always have this at the end + if self.allowed_users and username in self.allowed_users: + return True + # do not call super! + # super().check_allowed is not safe with JupyterHub < 5.0, + # as it will return True if allowed_users is empty + return False +``` + +Key points: + +- `allow_all` is backported from JupyterHub 5, for consistent behavior in all versions of JupyterHub (optional) +- restrictive configuration is checked in `check_blocked_users` +- if any restriction is not met, `check_blocked_users` returns False +- permissive configuration is checked in `check_allowed` +- if any `allow` condition is met, `check_allowed` returns True + +So the logical expression for a user being authorized should look like: + +> if ALL restrictions are met AND ANY admissions are met: user is authorized + +#### Custom error messages + +Any of these authentication and authorization methods may raise a `web.HTTPError` Exception + +```python +from tornado import web + +raise web.HTTPError(403, "informative message") +``` + +if you want to show a more informative login failure message rather than the generic one. + (authenticator-auth-state)= ### Authentication state diff --git a/docs/source/reference/changelog.md b/docs/source/reference/changelog.md index 809efed1..9ad3a112 100644 --- a/docs/source/reference/changelog.md +++ b/docs/source/reference/changelog.md @@ -6,8 +6,161 @@ For detailed changes from the prior release, click on the version number, and its link will bring up a GitHub listing of changes. Use `git log` on the command line for details. +## Versioning + +JupyterHub follows Intended Effort Versioning ([EffVer](https://jacobtomlinson.dev/effver/)) for versioning, +where the version number is meant to indicate the amount of effort required to upgrade to the new version. + +Contributors to major version bumps in JupyterHub include: + +- Database schema changes that require migrations and are hard to roll back +- Increasing the minimum required Python version +- Large new features +- Breaking changes likely to affect users + ## [Unreleased] +## 4.1 + +### 4.1.5 - 2024-04-04 + +([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.1.4...4.1.5)) + +#### Bugs fixed + +- singleuser mixin: include check_xsrf_cookie in overrides [#4771](https://github.com/jupyterhub/jupyterhub/pull/4771) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-03-30&to=2024-04-04&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-03-30..2024-04-04&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2024-03-30..2024-04-04&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-03-30..2024-04-04&type=Issues)) + +### 4.1.4 - 2024-03-30 + +([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.1.3...4.1.4)) + +#### Bugs fixed + +- avoid xsrf check on navigate GET requests [#4759](https://github.com/jupyterhub/jupyterhub/pull/4759) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-03-26&to=2024-03-30&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-03-26..2024-03-30&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-03-26..2024-03-30&type=Issues)) + +### 4.1.3 - 2024-03-26 + +([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.1.2...4.1.3)) + +#### Bugs fixed + +- respect jupyter-server disable_check_xsrf setting [#4753](https://github.com/jupyterhub/jupyterhub/pull/4753) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-03-25&to=2024-03-26&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-03-25..2024-03-26&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-03-25..2024-03-26&type=Issues)) + +### 4.1.2 - 2024-03-25 + +4.1.2 fixes a regression in 4.1.0 affecting named servers. + +([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.1.1...4.1.2)) + +#### Bugs fixed + +- rework handling of multiple xsrf tokens [#4750](https://github.com/jupyterhub/jupyterhub/pull/4750) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-03-23&to=2024-03-25&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-03-23..2024-03-25&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-03-23..2024-03-25&type=Issues)) + +### 4.1.1 - 2024-03-23 + +4.1.1 fixes a compatibility regression in 4.1.0 for some extensions, +particularly jupyter-server-proxy. + +([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.1.0...4.1.1)) + +#### Bugs fixed + +- allow subclasses to override xsrf check [#4745](https://github.com/jupyterhub/jupyterhub/pull/4745) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-03-20&to=2024-03-23&type=c)) + +@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-03-20..2024-03-23&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-03-20..2024-03-23&type=Issues)) + +### 4.1.0 - 2024-03-20 + +JupyterHub 4.1 is a security release, fixing [CVE-2024-28233]. +All JupyterHub deployments are encouraged to upgrade, +especially those with other user content on peer domains to JupyterHub. + +As always, JupyterHub deployments are especially encouraged to enable per-user domains if protecting users from each other is a concern. + +For more information on securely deploying JupyterHub, see the [web security documentation](web-security). + +[CVE-2024-28233]: https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-7r3h-4ph8-w38g + +([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.0.2...4.1.0)) + +#### Enhancements made + +- Backport PR #4628 on branch 4.x (Include LDAP groups in local spawner gids) [#4735](https://github.com/jupyterhub/jupyterhub/pull/4735) ([@minrk](https://github.com/minrk)) +- Backport PR #4561 on branch 4.x (Improve debugging when waiting for servers) [#4714](https://github.com/jupyterhub/jupyterhub/pull/4714) ([@minrk](https://github.com/minrk)) +- Backport PR #4563 on branch 4.x (only set 'domain' field on session-id cookie) [#4707](https://github.com/jupyterhub/jupyterhub/pull/4707) ([@minrk](https://github.com/minrk)) + +#### Bugs fixed + +- Backport PR #4733 on branch 4.x (Catch ValueError while waiting for server to be reachable) [#4734](https://github.com/jupyterhub/jupyterhub/pull/4734) ([@minrk](https://github.com/minrk)) +- Backport PR #4679 on branch 4.x (Unescape jinja username) [#4705](https://github.com/jupyterhub/jupyterhub/pull/4705) ([@minrk](https://github.com/minrk)) +- Backport PR #4630: avoid setting unused oauth state cookies on API requests [#4697](https://github.com/jupyterhub/jupyterhub/pull/4697) ([@minrk](https://github.com/minrk)) +- Backport PR #4632: simplify, avoid errors in parsing accept headers [#4696](https://github.com/jupyterhub/jupyterhub/pull/4696) ([@minrk](https://github.com/minrk)) +- Backport PR #4677 on branch 4.x (Improve validation, docs for token.expires_in) [#4692](https://github.com/jupyterhub/jupyterhub/pull/4692) ([@minrk](https://github.com/minrk)) +- Backport PR #4570 on branch 4.x (fix mutation of frozenset in scope intersection) [#4691](https://github.com/jupyterhub/jupyterhub/pull/4691) ([@minrk](https://github.com/minrk)) +- Backport PR #4562 on branch 4.x (Use `user.stop` to cleanup spawners that stopped while Hub was down) [#4690](https://github.com/jupyterhub/jupyterhub/pull/4690) ([@minrk](https://github.com/minrk)) +- Backport PR #4542 on branch 4.x (Fix include_stopped_servers in paginated next_url) [#4689](https://github.com/jupyterhub/jupyterhub/pull/4689) ([@minrk](https://github.com/minrk)) +- Backport PR #4651 on branch 4.x (avoid attempting to patch removed IPythonHandler with notebook v7) [#4688](https://github.com/jupyterhub/jupyterhub/pull/4688) ([@minrk](https://github.com/minrk)) +- Backport PR #4560 on branch 4.x (singleuser extension: persist token from ?token=... url in cookie) [#4687](https://github.com/jupyterhub/jupyterhub/pull/4687) ([@minrk](https://github.com/minrk)) + +#### Maintenance and upkeep improvements + +- Backport quay.io publishing [#4698](https://github.com/jupyterhub/jupyterhub/pull/4698) ([@minrk](https://github.com/minrk)) +- Backport PR #4617: try to improve reliability of test_external_proxy [#4695](https://github.com/jupyterhub/jupyterhub/pull/4695) ([@minrk](https://github.com/minrk)) +- Backport PR #4618 on branch 4.x (browser test: wait for token request to finish before reloading) [#4694](https://github.com/jupyterhub/jupyterhub/pull/4694) ([@minrk](https://github.com/minrk)) +- preparing 4.x branch [#4685](https://github.com/jupyterhub/jupyterhub/pull/4685) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +The following people contributed discussions, new ideas, code and documentation contributions, and review. +See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports). + +([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2023-08-10&to=2024-03-19&type=c)) + +@Achele ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AAchele+updated%3A2023-08-10..2024-03-19&type=Issues)) | @akashthedeveloper ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aakashthedeveloper+updated%3A2023-08-10..2024-03-19&type=Issues)) | @balajialg ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abalajialg+updated%3A2023-08-10..2024-03-19&type=Issues)) | @BhavyaT-135 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ABhavyaT-135+updated%3A2023-08-10..2024-03-19&type=Issues)) | @blink1073 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ablink1073+updated%3A2023-08-10..2024-03-19&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2023-08-10..2024-03-19&type=Issues)) | @fcollonval ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Afcollonval+updated%3A2023-08-10..2024-03-19&type=Issues)) | @I-Am-D-B ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AI-Am-D-B+updated%3A2023-08-10..2024-03-19&type=Issues)) | @jakirkham ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajakirkham+updated%3A2023-08-10..2024-03-19&type=Issues)) | @ktaletsk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aktaletsk+updated%3A2023-08-10..2024-03-19&type=Issues)) | @kzgrzendek ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akzgrzendek+updated%3A2023-08-10..2024-03-19&type=Issues)) | @lumberbot-app ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Alumberbot-app+updated%3A2023-08-10..2024-03-19&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2023-08-10..2024-03-19&type=Issues)) | @mbiette ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ambiette+updated%3A2023-08-10..2024-03-19&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2023-08-10..2024-03-19&type=Issues)) | @rcthomas ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arcthomas+updated%3A2023-08-10..2024-03-19&type=Issues)) | @ryanlovett ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aryanlovett+updated%3A2023-08-10..2024-03-19&type=Issues)) | @sgaist ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asgaist+updated%3A2023-08-10..2024-03-19&type=Issues)) | @shubham0473 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ashubham0473+updated%3A2023-08-10..2024-03-19&type=Issues)) | @Temidayo32 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ATemidayo32+updated%3A2023-08-10..2024-03-19&type=Issues)) | @willingc ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awillingc+updated%3A2023-08-10..2024-03-19&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ayuvipanda+updated%3A2023-08-10..2024-03-19&type=Issues)) + ## 4.0 ### 4.0.2 - 2023-08-10 diff --git a/docs/source/tutorial/getting-started/authenticators-users-basics.md b/docs/source/tutorial/getting-started/authenticators-users-basics.md index f2f2d2ae..9c0c09eb 100644 --- a/docs/source/tutorial/getting-started/authenticators-users-basics.md +++ b/docs/source/tutorial/getting-started/authenticators-users-basics.md @@ -6,21 +6,58 @@ The default Authenticator uses [PAM][] (Pluggable Authentication Module) to auth their usernames and passwords. With the default Authenticator, any user with an account and password on the system will be allowed to login. -## Create a set of allowed users (`allowed_users`) +## Deciding who is allowed + +In the base Authenticator, there are 3 configuration options for granting users access to your Hub: + +1. `allow_all` grants any user who can successfully authenticate access to the Hub +2. `allowed_users` defines a set of users who can access the Hub +3. `allow_existing_users` enables managing users via the JupyterHub API or admin page + +These options should apply to all Authenticators. +Your chosen Authenticator may add additional configuration options to admit users, such as team membership, course enrollment, etc. + +:::{important} +You should always specify at least one allow configuration if you want people to be able to access your Hub! +In most cases, this looks like: + +```python +c.Authenticator.allow_all = True +# or +c.Authenticator.allowed_users = {"name", ...} +``` + +::: + +:::{versionchanged} 5.0 +If no allow config is specified, then by default **nobody will have access to your Hub**. +Prior to 5.0, the opposite was true; effectively `allow_all = True` if no other allow config was specified. +::: You can restrict which users are allowed to login with a set, `Authenticator.allowed_users`: ```python c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'} +# c.Authenticator.allow_all = False +c.Authenticator.allow_existing_users = False ``` -Users in the `allowed_users` set are added to the Hub database when the Hub is -started. +Users in the `allowed_users` set are added to the Hub database when the Hub is started. -```{warning} -If this configuration value is not set, then **all authenticated users will be allowed into your hub**. -``` +:::{versionchanged} 5.0 +{attr}`.Authenticator.allow_all` and {attr}`.Authenticator.allow_existing_users` are new in JupyterHub 5.0 +to enable explicit configuration of previously implicit behavior. + +Prior to 5.0, `allow_all` was implicitly True if `allowed_users` was empty. +Starting with 5.0, to allow all authenticated users by default, +`allow_all` must be explicitly set to True. + +By default, `allow_existing_users` is True when `allowed_users` is not empty, +to ensure backward-compatibility. +To make the `allowed_users` set _restrictive_, +set `allow_existing_users = False`. +::: ## One Time Passwords ( request_otp ) @@ -42,7 +79,7 @@ c.Authenticator.otp_prompt = 'Google Authenticator:' ```{note} As of JupyterHub 2.0, the full permissions of `admin_users` should not be required. -Instead, you can assign [roles](define-role-target) to users or groups +Instead, it is best to assign [roles](define-role-target) to users or groups with only the scopes they require. ``` @@ -68,26 +105,55 @@ group. For example, we can let any user in the `wheel` group be an admin: c.PAMAuthenticator.admin_groups = {'wheel'} ``` -## Give admin access to other users' notebook servers (`admin_access`) +## Give some users access to other users' notebook servers -Since the default `JupyterHub.admin_access` setting is `False`, the admins -do not have permission to log in to the single user notebook servers -owned by _other users_. If `JupyterHub.admin_access` is set to `True`, -then admins have permission to log in _as other users_ on their -respective machines for debugging. **As a courtesy, you should make -sure your users know if admin_access is enabled.** +The `access:servers` scope can be granted to users to give them permission to visit other users' servers. +For example, to give members of the `teachers` group access to the servers of members of the `students` group: + +```python +c.JupyterHub.load_roles = [ + { + "name": "teachers", + "scopes": [ + "admin-ui", + "list:users", + "access:servers!group=students", + ], + "groups": ["teachers"], + } +] +``` + +By default, only the deprecated `admin` role has global `access` permissions. +**As a courtesy, you should make sure your users know if admin access is enabled.** ## Add or remove users from the Hub +:::{versionadded} 5.0 +`c.Authenticator.allow_existing_users` is added in 5.0 and True by default _if_ any `allowed_users` are specified. + +Prior to 5.0, this behavior was not optional. +::: + Users can be added to and removed from the Hub via the admin -panel or the REST API. When a user is **added**, the user will be -automatically added to the `allowed_users` set and database. Restarting the Hub -will not require manually updating the `allowed_users` set in your config file, +panel or the REST API. + +To enable this behavior, set: + +```python +c.Authenticator.allow_existing_users = True +``` + +When a user is **added**, the user will be +automatically added to the `allowed_users` set and database. +If `allow_existing_users` is True, restarting the Hub will not require manually updating the `allowed_users` set in your config file, as the users will be loaded from the database. +If `allow_existing_users` is False, users not granted access by configuration such as `allowed_users` will not be permitted to login, +even if they are present in the database. After starting the Hub once, it is not sufficient to **remove** a user from the allowed users set in your config file. You must also remove the user -from the Hub's database, either by deleting the user from JupyterHub's +from the Hub's database, either by deleting the user via JupyterHub's admin page, or you can clear the `jupyterhub.sqlite` database and start fresh. diff --git a/docs/source/tutorial/quickstart.md b/docs/source/tutorial/quickstart.md index 13a8e916..eca85a9e 100644 --- a/docs/source/tutorial/quickstart.md +++ b/docs/source/tutorial/quickstart.md @@ -5,11 +5,11 @@ Before installing JupyterHub, you will need: - a Linux/Unix-based system -- [Python](https://www.python.org/downloads/) 3.6 or greater. An understanding +- [Python {{python_min}}](https://www.python.org/downloads/) or greater. An understanding of using [`pip`](https://pip.pypa.io) or [`conda`](https://docs.conda.io/projects/conda/en/latest/user-guide/getting-started.html) for installing Python packages is helpful. -- [nodejs/npm](https://www.npmjs.com/). [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node), +- [Node.js {{node_min}}](https://www.npmjs.com/) or greater, along with npm. [Install Node.js/npm](https://docs.npmjs.com/getting-started/installing-node), using your operating system's package manager. - If you are using **`conda`**, the nodejs and npm dependencies will be installed for @@ -24,7 +24,7 @@ Before installing JupyterHub, you will need: ``` [nodesource][] is a great resource to get more recent versions of the nodejs runtime, - if your system package manager only has an old version of Node.js (e.g. 10 or older). + if your system package manager only has an old version of Node.js. - A [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module) to use the [default Authenticator](authenticators). diff --git a/jsx/package-lock.json b/jsx/package-lock.json index ef1e0f4b..7c43c26a 100644 --- a/jsx/package-lock.json +++ b/jsx/package-lock.json @@ -3599,12 +3599,13 @@ } }, "node_modules/body-parser": { - "version": "1.20.1", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dev": true, - "license": "MIT", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -3612,7 +3613,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -3623,16 +3624,18 @@ }, "node_modules/body-parser/node_modules/debug": { "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, - "license": "MIT", "dependencies": { "ms": "2.0.0" } }, "node_modules/body-parser/node_modules/iconv-lite": { "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, - "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -3642,8 +3645,9 @@ }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true }, "node_modules/bonjour-service": { "version": "1.1.1", @@ -3739,8 +3743,9 @@ }, "node_modules/bytes": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.8" } @@ -4051,8 +4056,9 @@ }, "node_modules/content-type": { "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -4063,9 +4069,10 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.5.0", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -5071,16 +5078,17 @@ } }, "node_modules/express": { - "version": "4.18.2", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dev": true, - "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -7319,8 +7327,9 @@ }, "node_modules/media-typer": { "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.6" } @@ -8120,8 +8129,9 @@ }, "node_modules/qs": { "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.0.4" }, @@ -8173,9 +8183,10 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dev": true, - "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -8188,8 +8199,9 @@ }, "node_modules/raw-body/node_modules/iconv-lite": { "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, - "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -9468,8 +9480,9 @@ }, "node_modules/type-is": { "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "dev": true, - "license": "MIT", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -9797,9 +9810,10 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "5.3.3", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "dev": true, - "license": "MIT", "dependencies": { "colorette": "^2.0.10", "memfs": "^3.4.3", diff --git a/jupyterhub/_xsrf_utils.py b/jupyterhub/_xsrf_utils.py new file mode 100644 index 00000000..b93e2818 --- /dev/null +++ b/jupyterhub/_xsrf_utils.py @@ -0,0 +1,247 @@ +"""utilities for XSRF + +Extends tornado's xsrf token checks with the following: + +- only set xsrf cookie on navigation requests (cannot be fetched) + +This utility file enables the consistent reuse of these functions +in both Hub and single-user code +""" + +import base64 +import hashlib +from http.cookies import SimpleCookie + +from tornado import web +from tornado.log import app_log + + +def _get_signed_value_urlsafe(handler, name, b64_value): + """Like get_signed_value (used in get_secure_cookie), but for urlsafe values + + Decodes urlsafe_base64-encoded signed values + + Returns None if any decoding failed + """ + if b64_value is None: + return None + + if isinstance(b64_value, str): + try: + b64_value = b64_value.encode("ascii") + except UnicodeEncodeError: + app_log.warning("Invalid value %r", b64_value) + return None + # re-pad, since we stripped padding in _create_signed_value + remainder = len(b64_value) % 4 + if remainder: + b64_value = b64_value + (b'=' * (4 - remainder)) + try: + value = base64.urlsafe_b64decode(b64_value) + except ValueError: + app_log.warning("Invalid base64 value %r", b64_value) + return None + + return web.decode_signed_value( + handler.application.settings["cookie_secret"], + name, + value, + max_age_days=31, + min_version=2, + ) + + +def _create_signed_value_urlsafe(handler, name, value): + """Like tornado's create_signed_value (used in set_secure_cookie), but returns urlsafe bytes""" + + signed_value = handler.create_signed_value(name, value) + return base64.urlsafe_b64encode(signed_value).rstrip(b"=") + + +def _get_xsrf_token_cookie(handler): + """ + Get the _valid_ XSRF token and id from Cookie + + Returns (xsrf_token, xsrf_id) found in Cookies header. + + multiple xsrf cookies may be set on multiple paths; + + RFC 6265 states that they should be in order of more specific path to less, + but ALSO states that servers should never rely on order. + + Tornado (6.4) and stdlib (3.12) SimpleCookie explicitly use the _last_ value, + which means the cookie with the _least_ specific prefix will be used if more than one is present. + + Because we sign values, we can get the first valid cookie and not worry about order too much. + + This is simplified from tornado's HTTPRequest.cookies property + only looking for a single cookie. + """ + + if "Cookie" not in handler.request.headers: + return (None, None) + + for chunk in handler.request.headers["Cookie"].split(";"): + key = chunk.partition("=")[0].strip() + if key != "_xsrf": + # we are only looking for the _xsrf cookie + # ignore everything else + continue + + # use stdlib parsing to handle quotes, validation, etc. + try: + xsrf_token = SimpleCookie(chunk)[key].value.encode("ascii") + except (ValueError, KeyError): + continue + + xsrf_token_id = _get_signed_value_urlsafe(handler, "_xsrf", xsrf_token) + + if xsrf_token_id: + # only return if we found a _valid_ xsrf cookie + # otherwise, keep looking + return (xsrf_token, xsrf_token_id) + # no valid token found found + return (None, None) + + +def _set_xsrf_cookie(handler, xsrf_id, *, cookie_path="", authenticated=None): + """Set xsrf token cookie""" + xsrf_token = _create_signed_value_urlsafe(handler, "_xsrf", xsrf_id) + xsrf_cookie_kwargs = {} + xsrf_cookie_kwargs.update(handler.settings.get('xsrf_cookie_kwargs', {})) + xsrf_cookie_kwargs.setdefault("path", cookie_path) + if authenticated is None: + try: + current_user = handler.current_user + except Exception: + authenticated = False + else: + authenticated = bool(current_user) + if not authenticated: + # limit anonymous xsrf cookies to one hour + xsrf_cookie_kwargs.pop("expires", None) + xsrf_cookie_kwargs.pop("expires_days", None) + xsrf_cookie_kwargs["max_age"] = 3600 + app_log.info( + "Setting new xsrf cookie for %r %r", + xsrf_id, + xsrf_cookie_kwargs, + ) + handler.set_cookie("_xsrf", xsrf_token, **xsrf_cookie_kwargs) + + +def get_xsrf_token(handler, cookie_path=""): + """Override tornado's xsrf token to add further restrictions + + - only set cookie for regular pages (not API requests) + - include login info in xsrf token + - verify signature + """ + # original: https://github.com/tornadoweb/tornado/blob/v6.4.0/tornado/web.py#L1455 + if hasattr(handler, "_xsrf_token"): + return handler._xsrf_token + + _set_cookie = False + # the raw cookie is the token + xsrf_token, xsrf_id_cookie = _get_xsrf_token_cookie(handler) + cookie_token = xsrf_token + + # check the decoded, signed value for validity + xsrf_id = handler._xsrf_token_id + if xsrf_id_cookie != xsrf_id: + # this will usually happen on the first page request after login, + # which changes the inputs to the token id + if xsrf_id_cookie: + app_log.debug("xsrf id mismatch %r != %r", xsrf_id_cookie, xsrf_id) + # generate new value + xsrf_token = _create_signed_value_urlsafe(handler, "_xsrf", xsrf_id) + # only set cookie on regular navigation pages + # i.e. not API requests, etc. + # insecure URLs (public hostname/ip, no https) + # don't set Sec-Fetch-Mode. + # consequence of assuming 'navigate': setting a cookie unnecessarily + # consequence of assuming not 'navigate': xsrf never set, nothing works + _set_cookie = ( + handler.request.headers.get("Sec-Fetch-Mode", "navigate") == "navigate" + ) + if xsrf_id_cookie and not _set_cookie: + # if we aren't setting a cookie here but we got one, + # this means things probably aren't going to work + app_log.warning( + "Not accepting incorrect xsrf token id in cookie on %s", + handler.request.path, + ) + + if _set_cookie: + _set_xsrf_cookie(handler, xsrf_id, cookie_path=cookie_path) + handler._xsrf_token = xsrf_token + return xsrf_token + + +def _needs_check_xsrf(handler): + """Does the given cookie-authenticated request need to check xsrf?""" + + if getattr(handler, "_token_authenticated", False): + return False + + fetch_mode = handler.request.headers.get("Sec-Fetch-Mode", "unspecified") + if fetch_mode in {"websocket", "no-cors"} or ( + fetch_mode in {"navigate", "unspecified"} + and handler.request.method.lower() in {"get", "head", "options"} + ): + # no xsrf check needed for regular page views or no-cors + # or websockets after allow_websocket_cookie_auth passes + if fetch_mode == "unspecified": + app_log.warning( + f"Skipping XSRF check for insecure request {handler.request.method} {handler.request.path}" + ) + return False + else: + return True + + +def check_xsrf_cookie(handler): + """Check that xsrf cookie matches xsrf token in request""" + # overrides tornado's implementation + # because we changed what a correct value should be in xsrf_token + if not _needs_check_xsrf(handler): + # don't require XSRF for regular page views + return + + token = ( + handler.get_argument("_xsrf", None) + or handler.request.headers.get("X-Xsrftoken") + or handler.request.headers.get("X-Csrftoken") + ) + + if not token: + raise web.HTTPError( + 403, f"'_xsrf' argument missing from {handler.request.method}" + ) + + try: + token = token.encode("utf8") + except UnicodeEncodeError: + raise web.HTTPError(403, "'_xsrf' argument invalid") + + if token != handler.xsrf_token: + raise web.HTTPError( + 403, f"XSRF cookie does not match {handler.request.method.upper()} argument" + ) + + +def _anonymous_xsrf_id(handler): + """Generate an appropriate xsrf token id for an anonymous request + + Currently uses hash of request ip and user-agent + + These are typically used only for the initial login page, + so only need to be valid for a few seconds to a few minutes + (enough to submit a login form with MFA). + """ + hasher = hashlib.sha256() + hasher.update(handler.request.remote_ip.encode("ascii")) + hasher.update( + handler.request.headers.get("User-Agent", "").encode("utf8", "replace") + ) + return base64.urlsafe_b64encode(hasher.digest()).decode("ascii") diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index d0e20576..4eb784b5 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -76,15 +76,8 @@ class APIHandler(BaseHandler): return True - async def prepare(self): - await super().prepare() - # tornado only checks xsrf on non-GET - # we also check xsrf on GETs to API endpoints - # make sure this runs after auth, which happens in super().prepare() - if self.request.method not in {"HEAD", "OPTIONS"} and self.settings.get( - "xsrf_cookies" - ): - self.check_xsrf_cookie() + # we also check xsrf on GETs to API endpoints + _xsrf_safe_methods = {"HEAD", "OPTIONS"} def check_xsrf_cookie(self): if not hasattr(self, '_jupyterhub_user'): diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 2463cd00..10a7d5e2 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -402,6 +402,25 @@ class JupyterHub(Application): Useful for daemonizing JupyterHub. """, ).tag(config=True) + + cookie_host_prefix_enabled = Bool( + False, + help="""Enable `__Host-` prefix on authentication cookies. + + The `__Host-` prefix on JupyterHub cookies provides further + protection against cookie tossing when untrusted servers + may control subdomains of your jupyterhub deployment. + + _However_, it also requires that cookies be set on the path `/`, + which means they are shared by all JupyterHub components, + so a compromised server component will have access to _all_ JupyterHub-related + cookies of the visiting browser. + It is recommended to only combine `__Host-` cookies with per-user domains. + + .. versionadded:: 4.1 + """, + ).tag(config=True) + cookie_max_age_days = Float( 14, help="""Number of days for a login cookie to be valid. @@ -2034,6 +2053,8 @@ class JupyterHub(Application): hub_args['port'] = self.hub_port self.hub = Hub(**hub_args) + if self.cookie_host_prefix_enabled: + self.hub.cookie_name = "__Host-" + self.hub.cookie_name if not self.subdomain_host: api_prefix = url_path_join(self.hub.base_url, "api/") @@ -2077,6 +2098,9 @@ class JupyterHub(Application): "auth_state is enabled, but encryption is not available: %s" % e ) + # give the authenticator a chance to check its own config + self.authenticator.check_allow_config() + if self.admin_users and not self.authenticator.admin_users: self.log.warning( "\nJupyterHub.admin_users is deprecated since version 0.7.2." @@ -2104,9 +2128,9 @@ class JupyterHub(Application): new_users.append(user) else: user.admin = True + # the admin_users config variable will never be used after this point. # only the database values will be referenced. - allowed_users = [ self.authenticator.normalize_username(name) for name in self.authenticator.allowed_users @@ -2116,10 +2140,10 @@ class JupyterHub(Application): if not self.authenticator.validate_username(username): raise ValueError("username %r is not valid" % username) - if not allowed_users: - self.log.info( - "Not using allowed_users. Any authenticated user will be allowed." - ) + if self.authenticator.allowed_users and self.authenticator.admin_users: + # make sure admin users are in the allowed_users set, if defined, + # otherwise they won't be able to login + self.authenticator.allowed_users |= self.authenticator.admin_users # add allowed users to the db for name in allowed_users: @@ -3161,6 +3185,7 @@ class JupyterHub(Application): default_url=self.default_url, public_url=urlparse(self.public_url) if self.public_url else "", cookie_secret=self.cookie_secret, + cookie_host_prefix_enabled=self.cookie_host_prefix_enabled, cookie_max_age_days=self.cookie_max_age_days, redirect_to_server=self.redirect_to_server, login_url=login_url, diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index cb7fbda4..6ea3d757 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -121,6 +121,55 @@ class Authenticator(LoggingConfigurable): """ ).tag(config=True) + any_allow_config = Bool( + False, + help="""Is there any allow config? + + Used to show a warning if it looks like nobody can access the Hub, + which can happen when upgrading to JupyterHub 5, + now that `allow_all` defaults to False. + + Deployments can set this explicitly to True to suppress + the "No allow config found" warning. + + Will be True if any config tagged with `.tag(allow_config=True)` + or starts with `allow` is truthy. + + .. versionadded:: 5.0 + """, + ).tag(config=True) + + @default("any_allow_config") + def _default_any_allowed(self): + for trait_name, trait in self.traits(config=True).items(): + if trait.metadata.get("allow_config", False) or trait_name.startswith( + "allow" + ): + # this is only used for a helpful warning, so not the biggest deal if it's imperfect + if getattr(self, trait_name): + return True + return False + + def check_allow_config(self): + """Log a warning if no allow config can be found. + + Could get a false positive if _only_ unrecognized allow config is used. + Authenticators can apply `.tag(allow_config=True)` to label this config + to make sure it is found. + + Subclasses can override to perform additonal checks and warn about likely + authenticator configuration problems. + + .. versionadded:: 5.0 + """ + if not self.any_allow_config: + self.log.warning( + "No allow config found, it's possible that nobody can login to your Hub!\n" + "You can set `c.Authenticator.allow_all = True` to allow any user who can login to access the Hub,\n" + "or e.g. `allowed_users` to a set of users who should have access.\n" + "You may suppress this warning by setting c.Authenticator.any_allow_config = True." + ) + whitelist = Set( help="Deprecated, use `Authenticator.allowed_users`", config=True, @@ -132,7 +181,7 @@ class Authenticator(LoggingConfigurable): Use this to limit which authenticated users may login. Default behavior: only users in this set are allowed. - + If empty, does not perform any restriction, in which case any authenticated user is allowed. @@ -144,6 +193,83 @@ class Authenticator(LoggingConfigurable): """ ).tag(config=True) + allow_all = Bool( + False, + config=True, + help=""" + Allow every user who can successfully authenticate to access JupyterHub. + + False by default, which means for most Authenticators, + _some_ allow-related configuration is required to allow users to log in. + + Authenticator subclasses may override the default with e.g.:: + + @default("allow_all") + def _default_allow_all(self): + # if _any_ auth config (depends on the Authenticator) + if self.allowed_users or self.allowed_groups or self.allow_existing_users: + return False + else: + return True + + .. versionadded:: 5.0 + + .. versionchanged:: 5.0 + Prior to 5.0, `allow_all` wasn't defined on its own, + and was instead implicitly True when no allow config was provided, + i.e. `allowed_users` unspecified or empty on the base Authenticator class. + + To preserve pre-5.0 behavior, + set `allow_all = True` if you have no other allow configuration. + """, + ).tag(allow_config=True) + + allow_existing_users = Bool( + # dynamic default computed from allowed_users + config=True, + help=""" + Allow existing users to login. + + Defaults to True if `allowed_users` is set for historical reasons, and + False otherwise. + + With this enabled, all users present in the JupyterHub database are allowed to login. + This has the effect of any user who has _previously_ been allowed to login + via any means will continue to be allowed until the user is deleted via the /hub/admin page + or REST API. + + .. warning:: + + Before enabling this you should review the existing users in the + JupyterHub admin panel at `/hub/admin`. You may find users existing + there because they have previously been declared in config such as + `allowed_users` or allowed to sign in. + + .. warning:: + + When this is enabled and you wish to remove access for one or more + users previously allowed, you must make sure that they + are removed from the jupyterhub database. This can be tricky to do + if you stop allowing an externally managed group of users for example. + + With this enabled, JupyterHub admin users can visit `/hub/admin` or use + JupyterHub's REST API to add and remove users to manage who can login. + + .. versionadded:: 5.0 + """, + ).tag(allow_config=True) + + @default("allow_existing_users") + def _allow_existing_users_default(self): + """ + Computes the default value of allow_existing_users based on if + allowed_users to align with original behavior not introduce a breaking + change. + """ + if self.allowed_users: + return True + return False + blocked_users = Set( help=""" Set of usernames that are not allowed to log in. @@ -472,13 +598,12 @@ class Authenticator(LoggingConfigurable): web.HTTPError(403): Raising HTTPErrors directly allows customizing the message shown to the user. """ - if not self.allowed_users: - # No allowed set means any name is allowed + if self.allow_all: return True return username in self.allowed_users def check_blocked_users(self, username, authentication=None): - """Check if a username is blocked to authenticate based on Authenticator.blocked configuration + """Check if a username is blocked to authenticate based on Authenticator.blocked_users configuration Return True if username is allowed, False otherwise. No block list means any username is allowed. @@ -525,8 +650,9 @@ class Authenticator(LoggingConfigurable): The various stages can be overridden separately: - `authenticate` turns formdata into a username - `normalize_username` normalizes the username - - `check_allowed` checks against the allowed usernames - `check_blocked_users` check against the blocked usernames + - `allow_all` is checked + - `check_allowed` checks against the allowed usernames - `is_admin` check if a user is an admin .. versionchanged:: 0.8 @@ -560,7 +686,11 @@ class Authenticator(LoggingConfigurable): self.log.warning("User %r blocked. Stop authentication", username) return - allowed_pass = await maybe_future(self.check_allowed(username, authenticated)) + allowed_pass = self.allow_all + if not allowed_pass: + allowed_pass = await maybe_future( + self.check_allowed(username, authenticated) + ) if allowed_pass: if authenticated['admin'] is None: @@ -697,25 +827,31 @@ class Authenticator(LoggingConfigurable): """Hook called when a user is added to JupyterHub This is called: - - When a user first authenticates - - When the hub restarts, for all users. + - When a user first authenticates, _after_ all allow and block checks have passed + - When the hub restarts, for all users in the database (i.e. users previously allowed) + - When a user is added to the database, either via configuration or REST API This method may be a coroutine. - By default, this just adds the user to the allowed_users set. + By default, this adds the user to the allowed_users set if + allow_existing_users is true. - Subclasses may do more extensive things, such as adding actual unix users, + Subclasses may do more extensive things, such as creating actual system users, but they should call super to ensure the allowed_users set is updated. Note that this should be idempotent, since it is called whenever the hub restarts for all users. + .. versionchanged:: 5.0 + Now adds users to the allowed_users set if allow_all is False and allow_existing_users is True, + instead of if allowed_users is not empty. + Args: user (User): The User wrapper object """ if not self.validate_username(user.name): raise ValueError("Invalid username: %s" % user.name) - if self.allowed_users: + if self.allow_existing_users and not self.allow_all: self.allowed_users.add(user.name) def delete_user(self, user): @@ -962,23 +1098,16 @@ class LocalAuthenticator(Authenticator): help=""" Allow login from all users in these UNIX groups. - If set, allowed username set is ignored. + .. versionchanged:: 5.0 + `allowed_groups` may be specified together with allowed_users, + to grant access by group OR name. """ - ).tag(config=True) - - @observe('allowed_groups') - def _allowed_groups_changed(self, change): - """Log a warning if mutually exclusive user and group allowed sets are specified.""" - if self.allowed_users: - self.log.warning( - "Ignoring Authenticator.allowed_users set because Authenticator.allowed_groups supplied!" - ) + ).tag(config=True, allow_config=True) def check_allowed(self, username, authentication=None): - if self.allowed_groups: - return self.check_allowed_groups(username, authentication) - else: - return super().check_allowed(username, authentication) + if self.check_allowed_groups(username, authentication): + return True + return super().check_allowed(username, authentication) def check_allowed_groups(self, username, authentication=None): """ @@ -1308,8 +1437,20 @@ class DummyAuthenticator(Authenticator): if it logs in with that password. .. versionadded:: 1.0 + + .. versionadded:: 5.0 + `allow_all` defaults to True, + preserving default behavior. """ + @default("allow_all") + def _allow_all_default(self): + if self.allowed_users: + return False + else: + # allow all by default + return True + password = Unicode( config=True, help=""" @@ -1319,6 +1460,12 @@ class DummyAuthenticator(Authenticator): """, ) + def check_allow_config(self): + super().check_allow_config() + self.log.warning( + f"Using testing authenticator {self.__class__.__name__}! This is not meant for production!" + ) + async def authenticate(self, handler, data): """Checks against a global password if it's been set. If not, allow any user/pass combo""" if self.password: diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 6b5f8f92..c85872c5 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -24,6 +24,12 @@ from tornado.log import app_log from tornado.web import RequestHandler, addslash from .. import __version__, orm, roles, scopes +from .._xsrf_utils import ( + _anonymous_xsrf_id, + _set_xsrf_cookie, + check_xsrf_cookie, + get_xsrf_token, +) from ..metrics import ( PROXY_ADD_DURATION_SECONDS, PROXY_DELETE_DURATION_SECONDS, @@ -38,7 +44,6 @@ from ..metrics import ( ServerStopStatus, ) from ..objects import Server -from ..scopes import needs_scope from ..spawner import LocalProcessSpawner from ..user import User from ..utils import ( @@ -100,7 +105,14 @@ class BaseHandler(RequestHandler): self.log.error("Rolling back session due to database error") self.db.rollback() self._resolve_roles_and_scopes() - return await maybe_future(super().prepare()) + await maybe_future(super().prepare()) + # run xsrf check after prepare + # because our version takes auth info into account + if ( + self.request.method not in self._xsrf_safe_methods + and self.application.settings.get("xsrf_cookies") + ): + self.check_xsrf_cookie() @property def log(self): @@ -205,9 +217,13 @@ class BaseHandler(RequestHandler): """The default Content-Security-Policy header Can be overridden by defining Content-Security-Policy in settings['headers'] + + ..versionchanged:: 4.1 + + Change default frame-ancestors from 'self' to 'none' """ return '; '.join( - ["frame-ancestors 'self'", "report-uri " + self.csp_report_uri] + ["frame-ancestors 'none'", "report-uri " + self.csp_report_uri] ) def get_content_type(self): @@ -217,7 +233,6 @@ class BaseHandler(RequestHandler): """ Set any headers passed as tornado_settings['headers']. - By default sets Content-Security-Policy of frame-ancestors 'self'. Also responsible for setting content-type header """ # wrap in HTTPHeaders for case-insensitivity @@ -239,17 +254,63 @@ class BaseHandler(RequestHandler): # Login and cookie-related # --------------------------------------------------------------- + _xsrf_safe_methods = {"GET", "HEAD", "OPTIONS"} + + @property + def _xsrf_token_id(self): + """Value to be signed/encrypted for xsrf token + + include login info in xsrf token + this means xsrf tokens are tied to logged-in users, + and change after a user logs in. + + While the user is not yet logged in, + an anonymous value is used, to prevent portability. + These anonymous values are short-lived. + """ + # cases: + # 1. logged in, session id (session_id:user_id) + # 2. logged in, no session id (anonymous_id:user_id) + # 3. not logged in, session id (session_id:anonymous_id) + # 4. no cookies at all, use single anonymous value (:anonymous_id) + session_id = self.get_session_cookie() + if self.current_user: + if isinstance(self.current_user, User): + user_id = self.current_user.cookie_id + else: + # this shouldn't happen, but may if e.g. a Service attempts to fetch a page, + # which usually won't work, but this method should not be what raises + user_id = "" + if not session_id: + # no session id, use non-portable anonymous id + session_id = _anonymous_xsrf_id(self) + else: + # not logged in yet, use non-portable anonymous id + user_id = _anonymous_xsrf_id(self) + xsrf_id = f"{session_id}:{user_id}".encode("utf8", "replace") + return xsrf_id + + @property + def xsrf_token(self): + """Override tornado's xsrf token with further restrictions + + - only set cookie for regular pages + - include login info in xsrf token + - verify signature + """ + return get_xsrf_token(self, cookie_path=self.hub.base_url) + def check_xsrf_cookie(self): - try: - return super().check_xsrf_cookie() - except web.HTTPError as e: - # ensure _jupyterhub_user is defined on rejected requests - if not hasattr(self, "_jupyterhub_user"): - self._jupyterhub_user = None - self._resolve_roles_and_scopes() - # rewrite message because we use this on methods other than POST - e.log_message = e.log_message.replace("POST", self.request.method) - raise + """Check that xsrf cookie matches xsrf token in request""" + # overrides tornado's implementation + # because we changed what a correct value should be in xsrf_token + + if not hasattr(self, "_jupyterhub_user"): + # run too early to check the value + # tornado runs this before 'prepare', + # but we run it again after so auth info is available, which happens in 'prepare' + return None + return check_xsrf_cookie(self) @property def admin_users(self): @@ -526,6 +587,16 @@ class BaseHandler(RequestHandler): user = self._user_from_orm(u) return user + def clear_cookie(self, cookie_name, **kwargs): + """Clear a cookie + + overrides RequestHandler to always handle __Host- prefix correctly + """ + if cookie_name.startswith("__Host-"): + kwargs["path"] = "/" + kwargs["secure"] = True + return super().clear_cookie(cookie_name, **kwargs) + def clear_login_cookie(self, name=None): kwargs = {} user = self.get_current_user_cookie() @@ -597,6 +668,11 @@ class BaseHandler(RequestHandler): kwargs.update(self.settings.get('cookie_options', {})) kwargs.update(overrides) + if key.startswith("__Host-"): + # __Host- cookies must be secure and on / + kwargs["path"] = "/" + kwargs["secure"] = True + if encrypted: set_cookie = self.set_secure_cookie else: @@ -626,7 +702,9 @@ class BaseHandler(RequestHandler): Session id cookie is *not* encrypted, so other services on this domain can read it. """ - session_id = uuid.uuid4().hex + if not hasattr(self, "_session_id"): + self._session_id = uuid.uuid4().hex + session_id = self._session_id # if using subdomains, set session cookie on the domain, # which allows it to be shared by subdomains. # if domain is unspecified, it is _more_ restricted to only the setting domain @@ -666,10 +744,20 @@ class BaseHandler(RequestHandler): if not self.get_session_cookie(): self.set_session_cookie() - # create and set a new cookie token for the hub - if not self.get_current_user_cookie(): + # create and set a new cookie for the hub + cookie_user = self.get_current_user_cookie() + if cookie_user is None or cookie_user.id != user.id: + if cookie_user: + self.log.info(f"User {cookie_user.name} is logging in as {user.name}") self.set_hub_cookie(user) + # make sure xsrf cookie is updated + # this avoids needing a second request to set the right xsrf cookie + self._jupyterhub_user = user + _set_xsrf_cookie( + self, self._xsrf_token_id, cookie_path=self.hub.base_url, authenticated=True + ) + def authenticate(self, data): return maybe_future(self.authenticator.get_authenticated_user(self, data)) @@ -1426,7 +1514,7 @@ class BaseHandler(RequestHandler): try: html = self.render_template('%s.html' % status_code, sync=True, **ns) except TemplateNotFound: - self.log.debug("No template for %d", status_code) + self.log.debug("Using default error template for %d", status_code) try: html = self.render_template('error.html', sync=True, **ns) except Exception: @@ -1561,10 +1649,28 @@ class UserUrlHandler(BaseHandler): delete = non_get @web.authenticated - @needs_scope("access:servers") async def get(self, user_name, user_path): if not user_path: user_path = '/' + path_parts = user_path.split("/", 2) + server_names = [""] + if len(path_parts) >= 3: + # second part _may_ be a server name + server_names.append(path_parts[1]) + + access_scopes = [ + f"access:servers!server={user_name}/{server_name}" + for server_name in server_names + ] + if not any(self.has_scope(scope) for scope in access_scopes): + self.log.warning( + "Not authorizing access to %s. Requires any of [%s], not derived from scopes [%s]", + self.request.path, + ", ".join(access_scopes), + ", ".join(self.expanded_scopes), + ) + raise web.HTTPError(404, "No access to resources or resources not found") + current_user = self.current_user if user_name != current_user.name: user = self.find_user(user_name) diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 97319d1c..ce26c44a 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -657,7 +657,7 @@ class ProxyErrorHandler(BaseHandler): try: html = await self.render_template('%s.html' % status_code, **ns) except TemplateNotFound: - self.log.debug("No template for %d", status_code) + self.log.debug("Using default error template for %d", status_code) html = await self.render_template('error.html', **ns) self.write(html) diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py index 861af988..503235ea 100644 --- a/jupyterhub/services/auth.py +++ b/jupyterhub/services/auth.py @@ -35,6 +35,7 @@ import socket import string import time import warnings +from functools import partial from http import HTTPStatus from unittest import mock from urllib.parse import urlencode, urlparse @@ -43,8 +44,10 @@ from tornado.httpclient import AsyncHTTPClient, HTTPRequest from tornado.httputil import url_concat from tornado.log import app_log from tornado.web import HTTPError, RequestHandler +from tornado.websocket import WebSocketHandler from traitlets import ( Any, + Bool, Dict, Instance, Integer, @@ -56,8 +59,15 @@ from traitlets import ( ) from traitlets.config import SingletonConfigurable +from .._xsrf_utils import ( + _anonymous_xsrf_id, + _needs_check_xsrf, + _set_xsrf_cookie, + check_xsrf_cookie, + get_xsrf_token, +) from ..scopes import _intersect_expanded_scopes -from ..utils import get_browser_protocol, url_path_join +from ..utils import _bool_env, get_browser_protocol, url_path_join def check_scopes(required_scopes, scopes): @@ -356,6 +366,46 @@ class HubAuth(SingletonConfigurable): """, ).tag(config=True) + allow_token_in_url = Bool( + _bool_env("JUPYTERHUB_ALLOW_TOKEN_IN_URL", default=False), + help="""Allow requests to pages with ?token=... in the URL + + This allows starting a user session by sharing a URL with credentials, + bypassing authentication with the Hub. + + If False, tokens in URLs will be ignored by the server, + except on websocket requests. + + Has no effect on websocket requests, + which can only reliably authenticate via token in the URL, + as recommended by browser Websocket implementations. + + This will default to False in JupyterHub 5. + + .. versionadded:: 4.1 + + .. versionchanged:: 5.0 + default changed to False + """, + ).tag(config=True) + + allow_websocket_cookie_auth = Bool( + _bool_env("JUPYTERHUB_ALLOW_WEBSOCKET_COOKIE_AUTH", default=True), + help="""Allow websocket requests with only cookie for authentication + + Cookie-authenticated websockets cannot be protected from other user servers unless per-user domains are used. + Disabling cookie auth on websockets protects user servers from each other, + but may break some user applications. + Per-user domains eliminate the need to lock this down. + + JupyterLab 4.1.2 and Notebook 6.5.6, 7.1.0 will not work + because they rely on cookie authentication without + API or XSRF tokens. + + .. versionadded:: 4.1 + """, + ).tag(config=True) + cookie_options = Dict( help="""Additional options to pass when setting cookies. @@ -374,6 +424,40 @@ class HubAuth(SingletonConfigurable): else: return {} + cookie_host_prefix_enabled = Bool( + False, + help="""Enable `__Host-` prefix on authentication cookies. + + The `__Host-` prefix on JupyterHub cookies provides further + protection against cookie tossing when untrusted servers + may control subdomains of your jupyterhub deployment. + + _However_, it also requires that cookies be set on the path `/`, + which means they are shared by all JupyterHub components, + so a compromised server component will have access to _all_ JupyterHub-related + cookies of the visiting browser. + It is recommended to only combine `__Host-` cookies with per-user domains. + + Set via $JUPYTERHUB_COOKIE_HOST_PREFIX_ENABLED + """, + ).tag(config=True) + + @default("cookie_host_prefix_enabled") + def _default_cookie_host_prefix_enabled(self): + return _bool_env("JUPYTERHUB_COOKIE_HOST_PREFIX_ENABLED") + + @property + def cookie_path(self): + """ + Path prefix on which to set cookies + + self.base_url, but '/' when cookie_host_prefix_enabled is True + """ + if self.cookie_host_prefix_enabled: + return "/" + else: + return self.base_url + cookie_cache_max_age = Integer(help="DEPRECATED. Use cache_max_age") @observe('cookie_cache_max_age') @@ -636,6 +720,17 @@ class HubAuth(SingletonConfigurable): auth_header_name = 'Authorization' auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE) + def _get_token_url(self, handler): + """Get the token from the URL + + Always run for websockets, + otherwise run only if self.allow_token_in_url + """ + fetch_mode = handler.request.headers.get("Sec-Fetch-Mode", "unspecified") + if self.allow_token_in_url or fetch_mode == "websocket": + return handler.get_argument("token", "") + return "" + def get_token(self, handler, in_cookie=True): """Get the token authenticating a request @@ -651,8 +746,7 @@ class HubAuth(SingletonConfigurable): Args: handler (tornado.web.RequestHandler): the current request handler """ - - user_token = handler.get_argument('token', '') + user_token = self._get_token_url(handler) if not user_token: # get it from Authorization header m = self.auth_header_pat.match( @@ -702,6 +796,14 @@ class HubAuth(SingletonConfigurable): """ return self._call_coroutine(sync, self._get_user, handler) + def _patch_xsrf(self, handler): + """Overridden in HubOAuth + + HubAuth base class doesn't handle xsrf, + which is only relevant for cookie-based auth + """ + return + async def _get_user(self, handler): # only allow this to be called once per handler # avoids issues if an error is raised, @@ -709,6 +811,9 @@ class HubAuth(SingletonConfigurable): if hasattr(handler, '_cached_hub_user'): return handler._cached_hub_user + # patch XSRF checks, which will apply after user check + self._patch_xsrf(handler) + handler._cached_hub_user = user_model = None session_id = self.get_session_id(handler) @@ -758,6 +863,10 @@ class HubAuth(SingletonConfigurable): if not hasattr(self, 'set_cookie'): # only HubOAuth can persist cookies return + fetch_mode = handler.request.headers.get("Sec-Fetch-Mode", "navigate") + if isinstance(handler, WebSocketHandler) or fetch_mode != "navigate": + # don't do this on websockets or non-navigate requests + return self.log.info( "Storing token from url in cookie for %s", handler.request.remote_ip, @@ -794,7 +903,10 @@ class HubOAuth(HubAuth): because we don't want to use the same cookie name across OAuth clients. """ - return self.oauth_client_id + cookie_name = self.oauth_client_id + if self.cookie_host_prefix_enabled: + cookie_name = "__Host-" + cookie_name + return cookie_name @property def state_cookie_name(self): @@ -806,22 +918,115 @@ class HubOAuth(HubAuth): def _get_token_cookie(self, handler): """Base class doesn't store tokens in cookies""" + if hasattr(handler, "_hub_auth_token_cookie"): + return handler._hub_auth_token_cookie + + fetch_mode = handler.request.headers.get("Sec-Fetch-Mode", "unset") + if fetch_mode == "websocket" and not self.allow_websocket_cookie_auth: + # disallow cookie auth on websockets + return None + token = handler.get_secure_cookie(self.cookie_name) if token: # decode cookie bytes token = token.decode('ascii', 'replace') return token - async def _get_user_cookie(self, handler): + def _get_xsrf_token_id(self, handler): + """Get contents for xsrf token for a given Handler + + This is the value to be encrypted & signed in the xsrf token + """ token = self._get_token_cookie(handler) session_id = self.get_session_id(handler) + if token: + token_hash = hashlib.sha256(token.encode("ascii", "replace")).hexdigest() + if not session_id: + session_id = _anonymous_xsrf_id(handler) + else: + token_hash = _anonymous_xsrf_id(handler) + return f"{session_id}:{token_hash}".encode("ascii", "replace") + + def _patch_xsrf(self, handler): + """Patch handler to inject JuptyerHub xsrf token behavior""" + if isinstance(handler, HubAuthenticated): + # doesn't need patch + return + + # patch in our xsrf token handling + # overrides tornado and jupyter_server defaults, + # but not others. + # subclasses will still inherit our overridden behavior, + # but their overrides (if any) will take precedence over ours + # such as jupyter-server-proxy + for cls in handler.__class__.__mro__: + # search for the nearest parent class defined + # in one of the 'base' Handler-defining packages. + # In current implementations, this will + # generally be jupyter_server.base.handlers.JupyterHandler + # or tornado.web.RequestHandler, + # but doing it this way ensures consistent results + if (cls.__module__ or '').partition('.')[0] not in { + "jupyter_server", + "notebook", + "tornado", + }: + continue + # override check_xsrf_cookie where it's defined + if "check_xsrf_cookie" in cls.__dict__: + if "_get_xsrf_token_id" in cls.__dict__: + # already patched + return + cls._xsrf_token_id = property(self._get_xsrf_token_id) + cls.xsrf_token = property( + partial(get_xsrf_token, cookie_path=self.base_url) + ) + cls.check_xsrf_cookie = lambda handler: self.check_xsrf_cookie(handler) + + def check_xsrf_cookie(self, handler): + """check_xsrf_cookie patch + + Applies JupyterHub check_xsrf_cookie if not token authenticated + """ + if getattr(handler, '_token_authenticated', False) or handler.settings.get( + "disable_check_xsrf", False + ): + return + check_xsrf_cookie(handler) + + def _clear_cookie(self, handler, cookie_name, **kwargs): + """Clear a cookie, handling __Host- prefix""" + # Set-Cookie is rejected without 'secure', + # this includes clearing cookies! + if cookie_name.startswith("__Host-"): + kwargs["path"] = "/" + kwargs["secure"] = True + return handler.clear_cookie(cookie_name, **kwargs) + + async def _get_user_cookie(self, handler): + # check xsrf if needed + token = self._get_token_cookie(handler) + session_id = self.get_session_id(handler) + if token and _needs_check_xsrf(handler): + # call handler.check_xsrf_cookie instead of self.check_xsrf_cookie + # to allow subclass overrides + try: + handler.check_xsrf_cookie() + except HTTPError as e: + self.log.debug( + f"Not accepting cookie auth on {handler.request.method} {handler.request.path}: {e.log_message}" + ) + # don't proceed with cookie auth unless xsrf is okay + # don't raise either, because that makes a mess + return None + if token: user_model = await self.user_for_token( token, session_id=session_id, sync=False ) if user_model is None: app_log.warning("Token stored in cookie may have expired") - handler.clear_cookie(self.cookie_name) + self._clear_cookie(handler, self.cookie_name, path=self.cookie_path) return user_model # HubOAuth API @@ -962,7 +1167,7 @@ class HubOAuth(HubAuth): cookie_name = self.state_cookie_name state_id = self.generate_state(next_url, **extra_state) kwargs = { - 'path': self.base_url, + 'path': self.cookie_path, 'httponly': True, # Expire oauth state cookie in ten minutes. # Usually this will be cleared by completed login @@ -1020,9 +1225,10 @@ class HubOAuth(HubAuth): """Clear persisted oauth state""" for cookie_name, cookie in handler.request.cookies.items(): if cookie_name.startswith(self.state_cookie_name): - handler.clear_cookie( + self._clear_cookie( + handler, cookie_name, - path=self.base_url, + path=self.cookie_path, ) def _decode_state(self, state_id, /): @@ -1044,8 +1250,11 @@ class HubOAuth(HubAuth): def set_cookie(self, handler, access_token): """Set a cookie recording OAuth result""" - kwargs = {'path': self.base_url, 'httponly': True} - if get_browser_protocol(handler.request) == 'https': + kwargs = {'path': self.cookie_path, 'httponly': True} + if ( + get_browser_protocol(handler.request) == 'https' + or self.cookie_host_prefix_enabled + ): kwargs['secure'] = True # load user cookie overrides kwargs.update(self.cookie_options) @@ -1056,6 +1265,15 @@ class HubOAuth(HubAuth): kwargs, ) handler.set_secure_cookie(self.cookie_name, access_token, **kwargs) + # set updated xsrf token cookie, + # which changes after login + handler._hub_auth_token_cookie = access_token + _set_xsrf_cookie( + handler, + handler._xsrf_token_id, + cookie_path=self.base_url, + authenticated=True, + ) def clear_cookie(self, handler): """Clear the OAuth cookie @@ -1063,7 +1281,7 @@ class HubOAuth(HubAuth): Args: handler (tornado.web.RequestHandler): the current request handler """ - handler.clear_cookie(self.cookie_name, path=self.base_url) + self._clear_cookie(handler, self.cookie_name, path=self.cookie_path) class UserNotAllowed(Exception): @@ -1275,7 +1493,7 @@ class HubAuthenticated: return try: self._hub_auth_user_cache = self.check_hub_user(user_model) - except UserNotAllowed as e: + except UserNotAllowed: # cache None, in case get_user is called again while processing the error self._hub_auth_user_cache = None @@ -1297,6 +1515,25 @@ class HubAuthenticated: self.hub_auth._persist_url_token_if_set(self) return self._hub_auth_user_cache + @property + def _xsrf_token_id(self): + if hasattr(self, "__xsrf_token_id"): + return self.__xsrf_token_id + if not isinstance(self.hub_auth, HubOAuth): + return "" + return self.hub_auth._get_xsrf_token_id(self) + + @_xsrf_token_id.setter + def _xsrf_token_id(self, value): + self.__xsrf_token_id = value + + @property + def xsrf_token(self): + return get_xsrf_token(self, cookie_path=self.hub_auth.base_url) + + def check_xsrf_cookie(self): + return self.hub_auth.check_xsrf_cookie(self) + class HubOAuthenticated(HubAuthenticated): """Simple subclass of HubAuthenticated using OAuth instead of old shared cookies""" @@ -1332,7 +1569,7 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler): cookie_state = self.get_secure_cookie(cookie_name) # clear cookie state now that we've consumed it if cookie_state: - self.clear_cookie(cookie_name, path=self.hub_auth.base_url) + self.hub_auth.clear_oauth_state_cookies(self) else: # completing oauth with stale state, but already logged in. # stop here and redirect to default URL @@ -1349,8 +1586,13 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler): # check that state matches if arg_state != cookie_state: - app_log.warning("oauth state %r != %r", arg_state, cookie_state) - raise HTTPError(403, "OAuth state does not match. Try logging in again.") + app_log.warning( + "oauth state argument %r != cookie %s=%r", + arg_state, + cookie_name, + cookie_state, + ) + raise HTTPError(403, "oauth state does not match. Try logging in again.") next_url = self.hub_auth.get_next_url(cookie_state) # clear consumed state from _oauth_states cache now that we're done with it self.hub_auth.clear_oauth_state(cookie_state) diff --git a/jupyterhub/singleuser/_decorator.py b/jupyterhub/singleuser/_decorator.py new file mode 100644 index 00000000..71f8c89f --- /dev/null +++ b/jupyterhub/singleuser/_decorator.py @@ -0,0 +1,14 @@ +from typing import Any, Callable, TypeVar + +try: + from jupyter_server.auth.decorator import allow_unauthenticated +except ImportError: + FuncT = TypeVar("FuncT", bound=Callable[..., Any]) + + # if using an older jupyter-server version this can be a no-op, + # as these do not support marking endpoints anyways + def allow_unauthenticated(method: FuncT) -> FuncT: + return method + + +__all__ = ["allow_unauthenticated"] diff --git a/jupyterhub/singleuser/extension.py b/jupyterhub/singleuser/extension.py index 4fe2d1dd..e34e05fc 100644 --- a/jupyterhub/singleuser/extension.py +++ b/jupyterhub/singleuser/extension.py @@ -44,28 +44,19 @@ from jupyterhub._version import __version__, _check_version from jupyterhub.log import log_request from jupyterhub.services.auth import HubOAuth, HubOAuthCallbackHandler from jupyterhub.utils import ( + _bool_env, exponential_backoff, isoformat, make_ssl_context, url_path_join, ) +from ._decorator import allow_unauthenticated from ._disable_user_config import _disable_user_config SINGLEUSER_TEMPLATES_DIR = str(Path(__file__).parent.resolve().joinpath("templates")) -def _bool_env(key): - """Cast an environment variable to bool - - 0, empty, or unset is False; All other values are True. - """ - if os.environ.get(key, "") in {"", "0"}: - return False - else: - return True - - def _exclude_home(path_list): """Filter out any entries in a path list that are in my home directory. @@ -78,6 +69,7 @@ def _exclude_home(path_list): class JupyterHubLogoutHandler(LogoutHandler): + @allow_unauthenticated def get(self): hub_auth = self.identity_provider.hub_auth # clear token stored in single-user cookie (set by hub_auth) @@ -105,6 +97,10 @@ class JupyterHubOAuthCallbackHandler(HubOAuthCallbackHandler): def initialize(self, hub_auth): self.hub_auth = hub_auth + @allow_unauthenticated + async def get(self): + return await super().get() + class JupyterHubIdentityProvider(IdentityProvider): """Identity Provider for JupyterHub OAuth @@ -127,6 +123,9 @@ class JupyterHubIdentityProvider(IdentityProvider): # HubAuth gets most of its config from the environment return HubOAuth(parent=self) + def _patch_xsrf(self, handler): + self.hub_auth._patch_xsrf(handler) + def _patch_get_login_url(self, handler): original_get_login_url = handler.get_login_url @@ -161,6 +160,7 @@ class JupyterHubIdentityProvider(IdentityProvider): if hasattr(handler, "_jupyterhub_user"): return handler._jupyterhub_user self._patch_get_login_url(handler) + self._patch_xsrf(handler) user = await self.hub_auth.get_user(handler, sync=False) if user is None: handler._jupyterhub_user = None @@ -632,6 +632,9 @@ class JupyterHubSingleUser(ExtensionApp): app.web_app.settings["page_config_hook"] = ( app.identity_provider.page_config_hook ) + # disable xsrf_cookie checks by Tornado, which run too early + # checks in Jupyter Server are unconditional + app.web_app.settings["xsrf_cookies"] = False # if the user has configured a log function in the tornado settings, do not override it if not 'log_function' in app.config.ServerApp.get('tornado_settings', {}): app.web_app.settings["log_function"] = log_request @@ -642,6 +645,9 @@ class JupyterHubSingleUser(ExtensionApp): # check jupyterhub version app.io_loop.run_sync(self.check_hub_version) + # set default CSP to prevent iframe embedding across jupyterhub components + headers.setdefault("Content-Security-Policy", "frame-ancestors 'none'") + async def _start_activity(): self._activity_task = asyncio.ensure_future(self.keep_activity_updated()) diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py index 83b34aed..3217e4ad 100755 --- a/jupyterhub/singleuser/mixins.py +++ b/jupyterhub/singleuser/mixins.py @@ -45,21 +45,16 @@ from traitlets.config import Configurable from .._version import __version__, _check_version from ..log import log_request from ..services.auth import HubOAuth, HubOAuthCallbackHandler, HubOAuthenticated -from ..utils import exponential_backoff, isoformat, make_ssl_context, url_path_join +from ..utils import ( + _bool_env, + exponential_backoff, + isoformat, + make_ssl_context, + url_path_join, +) +from ._decorator import allow_unauthenticated from ._disable_user_config import _disable_user_config, _exclude_home - -def _bool_env(key): - """Cast an environment variable to bool - - 0, empty, or unset is False; All other values are True. - """ - if os.environ.get(key, "") in {"", "0"}: - return False - else: - return True - - # Authenticate requests with the Hub @@ -138,6 +133,7 @@ class JupyterHubLoginHandlerMixin: class JupyterHubLogoutHandlerMixin: + @allow_unauthenticated def get(self): self.settings['hub_auth'].clear_cookie(self) self.redirect( @@ -153,6 +149,10 @@ class OAuthCallbackHandlerMixin(HubOAuthCallbackHandler): def hub_auth(self): return self.settings['hub_auth'] + @allow_unauthenticated + async def get(self): + return await super().get() + # register new hub related command-line aliases aliases = { @@ -683,10 +683,10 @@ class SingleUserNotebookAppMixin(Configurable): ) headers = s.setdefault('headers', {}) headers['X-JupyterHub-Version'] = __version__ - # set CSP header directly to workaround bugs in jupyter/notebook 5.0 + # set default CSP to prevent iframe embedding across jupyterhub components headers.setdefault( 'Content-Security-Policy', - ';'.join(["frame-ancestors 'self'", "report-uri " + csp_report_uri]), + ';'.join(["frame-ancestors 'none'", "report-uri " + csp_report_uri]), ) super().init_webapp() @@ -833,7 +833,7 @@ def patch_base_handler(BaseHandler, log=None): # but we also need to ensure BaseHandler *itself* doesn't # override the public tornado API methods we have inserted. # If they are defined in BaseHandler, explicitly replace them with our methods. - for name in ("get_current_user", "get_login_url"): + for name in ("get_current_user", "get_login_url", "check_xsrf_cookie"): if name in BaseHandler.__dict__: log.debug( f"Overriding {BaseHandler}.{name} with HubAuthenticatedHandler.{name}" diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 6c0306d2..392d67e8 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -163,6 +163,7 @@ class Spawner(LoggingConfigurable): hub = Any() orm_spawner = Any() cookie_options = Dict() + cookie_host_prefix_enabled = Bool() public_url = Unicode(help="Public URL of this spawner's server") public_hub_url = Unicode(help="Public URL of the Hub itself") @@ -1006,6 +1007,10 @@ class Spawner(LoggingConfigurable): env['JUPYTERHUB_CLIENT_ID'] = self.oauth_client_id if self.cookie_options: env['JUPYTERHUB_COOKIE_OPTIONS'] = json.dumps(self.cookie_options) + + env["JUPYTERHUB_COOKIE_HOST_PREFIX_ENABLED"] = str( + int(self.cookie_host_prefix_enabled) + ) env['JUPYTERHUB_HOST'] = self.hub.public_host env['JUPYTERHUB_OAUTH_CALLBACK_URL'] = url_path_join( self.user.url, url_escape_path(self.name), 'oauth_callback' diff --git a/jupyterhub/tests/browser/test_browser.py b/jupyterhub/tests/browser/test_browser.py index 9054138a..b3821948 100644 --- a/jupyterhub/tests/browser/test_browser.py +++ b/jupyterhub/tests/browser/test_browser.py @@ -1,6 +1,8 @@ """Tests for the Playwright Python""" +import asyncio import json +import pprint import re from unittest import mock from urllib.parse import parse_qs, urlparse @@ -11,7 +13,8 @@ from tornado.escape import url_escape from tornado.httputil import url_concat from jupyterhub import orm, roles, scopes -from jupyterhub.tests.utils import public_host, public_url, ujoin +from jupyterhub.tests.test_named_servers import named_servers # noqa +from jupyterhub.tests.utils import async_requests, public_host, public_url, ujoin from jupyterhub.utils import url_escape_path, url_path_join pytestmark = pytest.mark.browser @@ -44,7 +47,7 @@ async def test_submit_login_form(app, browser, user_special_chars): login_url = url_path_join(public_host(app), app.hub.base_url, "login") await browser.goto(login_url) await login(browser, user.name, password=user.name) - expected_url = ujoin(public_url(app), f"/user/{user_special_chars.urlname}/") + expected_url = public_url(app, user) await expect(browser).to_have_url(expected_url) @@ -56,7 +59,7 @@ async def test_submit_login_form(app, browser, user_special_chars): # will encode given parameters for an unauthenticated URL in the next url # the next parameter will contain the app base URL (replaces BASE_URL in tests) 'spawn', - [('param', 'value')], + {'param': 'value'}, '/hub/login?next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue', '/hub/login?next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue', ), @@ -64,15 +67,15 @@ async def test_submit_login_form(app, browser, user_special_chars): # login?param=fromlogin&next=encoded(/hub/spawn?param=value) # will drop parameters given to the login page, passing only the next url 'login', - [('param', 'fromlogin'), ('next', '/hub/spawn?param=value')], - '/hub/login?param=fromlogin&next=%2Fhub%2Fspawn%3Fparam%3Dvalue', - '/hub/login?next=%2Fhub%2Fspawn%3Fparam%3Dvalue', + {'param': 'fromlogin', 'next': '/hub/spawn?param=value'}, + '/hub/login?param=fromlogin&next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue', + '/hub/login?next={{BASE_URL}}hub%2Fspawn%3Fparam%3Dvalue', ), ( # login?param=value&anotherparam=anothervalue # will drop parameters given to the login page, and use an empty next url 'login', - [('param', 'value'), ('anotherparam', 'anothervalue')], + {'param': 'value', 'anotherparam': 'anothervalue'}, '/hub/login?param=value&anotherparam=anothervalue', '/hub/login?next=', ), @@ -80,7 +83,7 @@ async def test_submit_login_form(app, browser, user_special_chars): # login # simplest case, accessing the login URL, gives an empty next url 'login', - [], + {}, '/hub/login', '/hub/login?next=', ), @@ -98,6 +101,8 @@ async def test_open_url_login( user = user_special_chars.user login_url = url_path_join(public_host(app), app.hub.base_url, url) await browser.goto(login_url) + if params.get("next"): + params["next"] = url_path_join(app.base_url, params["next"]) url_new = url_path_join(public_host(app), app.hub.base_url, url_concat(url, params)) print(url_new) await browser.goto(url_new) @@ -853,12 +858,15 @@ async def test_oauth_page( oauth_client.allowed_scopes = sorted(roles.roles_to_scopes([service_role])) app.db.commit() # open the service url in the browser - service_url = url_path_join(public_url(app, service) + 'owhoami/?arg=x') + service_url = url_path_join(public_url(app, service), 'owhoami/?arg=x') await browser.goto(service_url) - expected_redirect_url = url_path_join( - app.base_url + f"services/{service.name}/oauth_callback" - ) + if app.subdomain_host: + expected_redirect_url = url_path_join( + public_url(app, service), "oauth_callback" + ) + else: + expected_redirect_url = url_path_join(service.prefix, "oauth_callback") expected_client_id = f"service-{service.name}" # decode the URL @@ -1236,3 +1244,266 @@ async def test_start_stop_server_on_admin_page( await expect(browser.get_by_role("button", name="Spawn Page")).to_have_count( len(users_list) ) + + +@pytest.mark.parametrize( + "case", + [ + "fresh", + "invalid", + "valid-prefix-invalid-root", + "valid-prefix-invalid-other-prefix", + ], +) +async def test_login_xsrf_initial_cookies(app, browser, case, username): + """Test that login works with various initial states for xsrf tokens + + Page will be reloaded with correct values + """ + hub_root = public_host(app) + hub_url = url_path_join(public_host(app), app.hub.base_url) + hub_parent = hub_url.rstrip("/").rsplit("/", 1)[0] + "/" + login_url = url_path_join( + hub_url, url_concat("login", {"next": url_path_join(app.base_url, "/hub/home")}) + ) + # start with all cookies cleared + await browser.context.clear_cookies() + if case == "invalid": + await browser.context.add_cookies( + [{"name": "_xsrf", "value": "invalid-hub-prefix", "url": hub_url}] + ) + elif case.startswith("valid-prefix"): + if "invalid-root" in case: + invalid_url = hub_root + else: + invalid_url = hub_parent + await browser.goto(login_url) + # first visit sets valid xsrf cookie + cookies = await browser.context.cookies() + assert len(cookies) == 1 + # second visit is also made with invalid xsrf on `/` + # handling of this behavior is undefined in HTTP itself! + # _either_ the invalid cookie on / is ignored + # _or_ both will be cleared + # currently, this test assumes the observed behavior, + # which is that the invalid cookie on `/` has _higher_ priority + await browser.context.add_cookies( + [{"name": "_xsrf", "value": "invalid-root", "url": invalid_url}] + ) + cookies = await browser.context.cookies() + assert len(cookies) == 2 + + # after visiting page, cookies get re-established + await browser.goto(login_url) + cookies = await browser.context.cookies() + print(cookies) + cookie = cookies[0] + assert cookie['name'] == '_xsrf' + assert cookie["path"] == app.hub.base_url + + # next page visit, cookies don't change + await browser.goto(login_url) + cookies_2 = await browser.context.cookies() + assert cookies == cookies_2 + # login is successful + await login(browser, username, username) + + +def _cookie_dict(cookie_list): + """Convert list of cookies to dict of the form + + { 'path': {'key': {cookie} } } + """ + cookie_dict = {} + for cookie in cookie_list: + path_cookies = cookie_dict.setdefault(cookie['path'], {}) + path_cookies[cookie['name']] = cookie + return cookie_dict + + +async def test_singleuser_xsrf( + app, + browser, + user, + create_user_with_scopes, + full_spawn, + named_servers, # noqa: F811 +): + # full login process, checking XSRF handling + # start two servers + target_user = user + target_start = asyncio.ensure_future(target_user.spawn()) + + browser_user = create_user_with_scopes("self", "access:servers") + # login browser_user + login_url = url_path_join(public_host(app), app.hub.base_url, "login") + await browser.goto(login_url) + await login(browser, browser_user.name, browser_user.name) + # end up at single-user + await expect(browser).to_have_url(re.compile(rf".*/user/{browser_user.name}/.*")) + # wait for target user to start, too + await target_start + await app.proxy.add_user(target_user) + + # visit target user, sets credentials for second server + await browser.goto(public_url(app, target_user)) + await expect(browser).to_have_url(re.compile(r".*/oauth2/authorize")) + auth_button = browser.locator('//input[@type="submit"]') + await expect(auth_button).to_be_enabled() + await auth_button.click() + await expect(browser).to_have_url(re.compile(rf".*/user/{target_user.name}/.*")) + + # at this point, we are on a page served by target_user, + # logged in as browser_user + # basic check that xsrf isolation works + cookies = await browser.context.cookies() + cookie_dict = _cookie_dict(cookies) + pprint.pprint(cookie_dict) + + # we should have xsrf tokens for both singleuser servers and the hub + target_prefix = target_user.prefix + user_prefix = browser_user.prefix + hub_prefix = app.hub.base_url + assert target_prefix in cookie_dict + assert user_prefix in cookie_dict + assert hub_prefix in cookie_dict + target_xsrf = cookie_dict[target_prefix].get("_xsrf", {}).get("value") + assert target_xsrf + user_xsrf = cookie_dict[user_prefix].get("_xsrf", {}).get("value") + assert user_xsrf + hub_xsrf = cookie_dict[hub_prefix].get("_xsrf", {}).get("value") + assert hub_xsrf + assert hub_xsrf != target_xsrf + assert hub_xsrf != user_xsrf + assert target_xsrf != user_xsrf + + # we are on a page served by target_user + # check that we can't access + + async def fetch_user_page(path, params=None): + url = url_path_join(public_url(app, browser_user), path) + if params: + url = url_concat(url, params) + status = await browser.evaluate( + """ + async (user_url) => { + try { + response = await fetch(user_url); + } catch (e) { + return 'error'; + } + return response.status; + } + """, + url, + ) + return status + + if app.subdomain_host: + expected_status = 'error' + else: + expected_status = 403 + status = await fetch_user_page("/api/contents") + assert status == expected_status + status = await fetch_user_page("/api/contents", params={"_xsrf": target_xsrf}) + assert status == expected_status + + if not app.subdomain_host: + expected_status = 200 + status = await fetch_user_page("/api/contents", params={"_xsrf": user_xsrf}) + assert status == expected_status + + # check that we can't iframe the other user's page + async def iframe(src): + return await browser.evaluate( + """ + async (src) => { + const frame = document.createElement("iframe"); + frame.src = src; + return new Promise((resolve, reject) => { + frame.addEventListener("load", (event) => { + if (frame.contentDocument) { + resolve("got document!"); + } else { + resolve("blocked") + } + }); + setTimeout(() => { + // some browsers (firefox) never fire load event + // despite spec appasrently stating it must always do so, + // even for rejected frames + resolve("timeout") + }, 3000) + + document.body.appendChild(frame); + }); + } + """, + src, + ) + + hub_iframe = await iframe(url_path_join(public_url(app), "hub/admin")) + assert hub_iframe in {"timeout", "blocked"} + user_iframe = await iframe(public_url(app, browser_user)) + assert user_iframe in {"timeout", "blocked"} + + # check that server page can still connect to its own kernels + token = target_user.new_api_token(scopes=["access:servers!user"]) + + async def test_kernel(kernels_url): + headers = {"Authorization": f"Bearer {token}"} + r = await async_requests.post(kernels_url, headers=headers) + r.raise_for_status() + kernel = r.json() + kernel_id = kernel["id"] + kernel_url = url_path_join(kernels_url, kernel_id) + kernel_ws_url = "ws" + url_path_join(kernel_url, "channels")[4:] + try: + result = await browser.evaluate( + """ + async (ws_url) => { + ws = new WebSocket(ws_url); + finished = await new Promise((resolve, reject) => { + ws.onerror = (err) => { + reject(err); + }; + ws.onopen = () => { + resolve("ok"); + }; + }); + return finished; + } + """, + kernel_ws_url, + ) + finally: + r = await async_requests.delete(kernel_url, headers=headers) + r.raise_for_status() + assert result == "ok" + + kernels_url = url_path_join(public_url(app, target_user), "/api/kernels") + await test_kernel(kernels_url) + + # final check: make sure named servers work. + # first, visit spawn page to launch server, + # will issue cookies, etc. + server_name = "named" + url = url_path_join( + public_host(app), + url_path_join(app.base_url, f"hub/spawn/{browser_user.name}/{server_name}"), + ) + await browser.goto(url) + await expect(browser).to_have_url( + re.compile(rf".*/user/{browser_user.name}/{server_name}/.*") + ) + # from named server URL, make sure we can talk to a kernel + token = browser_user.new_api_token(scopes=["access:servers!user"]) + # named-server URL + kernels_url = url_path_join( + public_url(app, browser_user), server_name, "api/kernels" + ) + await test_kernel(kernels_url) + # go back to user's own page, test again + # make sure we didn't break anything + await browser.goto(public_url(app, browser_user)) + await test_kernel(url_path_join(public_url(app, browser_user), "api/kernels")) diff --git a/jupyterhub/tests/conftest.py b/jupyterhub/tests/conftest.py index c6337470..82968d97 100644 --- a/jupyterhub/tests/conftest.py +++ b/jupyterhub/tests/conftest.py @@ -502,8 +502,6 @@ def create_user_with_scopes(app, create_temp_role): return app.users[orm_user.id] yield temp_user_creator - for user in temp_users: - app.users.delete(user) @fixture diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index c4686829..f441cd67 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -44,8 +44,8 @@ from .. import metrics, orm, roles from ..app import JupyterHub from ..auth import PAMAuthenticator from ..spawner import SimpleLocalProcessSpawner -from ..utils import random_port, utcnow -from .utils import async_requests, public_url, ssl_setup +from ..utils import random_port, url_path_join, utcnow +from .utils import AsyncSession, public_url, ssl_setup def mock_authenticate(username, password, service, encoding): @@ -243,6 +243,8 @@ class MockHub(JupyterHub): cert_location = kwargs['internal_certs_location'] kwargs['external_certs'] = ssl_setup(cert_location, 'hub-ca') super().__init__(*args, **kwargs) + if 'allow_all' not in self.config.Authenticator: + self.config.Authenticator.allow_all = True @default('subdomain_host') def _subdomain_host_default(self): @@ -372,29 +374,32 @@ class MockHub(JupyterHub): async def login_user(self, name): """Login a user by name, returning her cookies.""" base_url = public_url(self) - external_ca = None + s = AsyncSession() if self.internal_ssl: - external_ca = self.external_certs['files']['ca'] + s.verify = self.external_certs['files']['ca'] login_url = base_url + 'hub/login' - r = await async_requests.get(login_url) + r = await s.get(login_url) r.raise_for_status() xsrf = r.cookies['_xsrf'] - r = await async_requests.post( + r = await s.post( url_concat(login_url, {"_xsrf": xsrf}), - cookies=r.cookies, data={'username': name, 'password': name}, allow_redirects=False, - verify=external_ca, ) r.raise_for_status() - r.cookies["_xsrf"] = xsrf - assert sorted(r.cookies.keys()) == [ + # make second request to get updated xsrf cookie + r2 = await s.get( + url_path_join(base_url, "hub/home"), + allow_redirects=False, + ) + assert r2.status_code == 200 + assert sorted(s.cookies.keys()) == [ '_xsrf', 'jupyterhub-hub-login', 'jupyterhub-session-id', ] - return r.cookies + return s.cookies class InstrumentedSpawner(MockSpawner): diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 8639a038..77d16861 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -99,7 +99,7 @@ async def test_post_content_type(app, content_type, status): assert r.status_code == status -@mark.parametrize("xsrf_in_url", [True, False]) +@mark.parametrize("xsrf_in_url", [True, False, "invalid"]) @mark.parametrize( "method, path", [ @@ -110,6 +110,13 @@ async def test_post_content_type(app, content_type, status): async def test_xsrf_check(app, username, method, path, xsrf_in_url): cookies = await app.login_user(username) xsrf = cookies['_xsrf'] + if xsrf_in_url == "invalid": + cookies.pop("_xsrf") + # a valid old-style tornado xsrf token is no longer valid + xsrf = cookies['_xsrf'] = ( + "2|7329b149|d837ced983e8aac7468bc7a61ce3d51a|1708610065" + ) + url = path.format(username=username) if xsrf_in_url: url = f"{url}?_xsrf={xsrf}" @@ -120,7 +127,7 @@ async def test_xsrf_check(app, username, method, path, xsrf_in_url): noauth=True, cookies=cookies, ) - if xsrf_in_url: + if xsrf_in_url is True: assert r.status_code == 200 else: assert r.status_code == 403 @@ -153,7 +160,7 @@ async def test_permission_error_messages(app, user, auth, expected_message): params["_xsrf"] = cookies["_xsrf"] if auth == "cookie_xsrf_mismatch": params["_xsrf"] = "somethingelse" - + headers['Sec-Fetch-Mode'] = 'cors' r = await async_requests.get(url, **kwargs) assert r.status_code == 403 response = r.json() diff --git a/jupyterhub/tests/test_app.py b/jupyterhub/tests/test_app.py index 9a3f0e2a..819a00e6 100644 --- a/jupyterhub/tests/test_app.py +++ b/jupyterhub/tests/test_app.py @@ -475,6 +475,7 @@ async def test_user_creation(tmpdir, request): ] cfg = Config() + cfg.Authenticator.allow_all = False cfg.Authenticator.allowed_users = allowed_users cfg.JupyterHub.load_groups = groups cfg.JupyterHub.load_roles = roles diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index 73cfeeaa..fdf232ad 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -3,12 +3,13 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import logging +from itertools import chain from unittest import mock from urllib.parse import urlparse import pytest from requests import HTTPError -from traitlets import Any +from traitlets import Any, Tuple from traitlets.config import Config from jupyterhub import auth, crypto, orm @@ -18,7 +19,7 @@ from .utils import add_user, async_requests, get_page, public_url async def test_pam_auth(): - authenticator = MockPAMAuthenticator() + authenticator = MockPAMAuthenticator(allow_all=True) authorized = await authenticator.get_authenticated_user( None, {'username': 'match', 'password': 'match'} ) @@ -37,7 +38,7 @@ async def test_pam_auth(): async def test_pam_auth_account_check_disabled(): - authenticator = MockPAMAuthenticator(check_account=False) + authenticator = MockPAMAuthenticator(allow_all=True, check_account=False) authorized = await authenticator.get_authenticated_user( None, {'username': 'allowedmatch', 'password': 'allowedmatch'} ) @@ -82,7 +83,9 @@ async def test_pam_auth_admin_groups(): return user_group_map[name] authenticator = MockPAMAuthenticator( - admin_groups={'jh_admins', 'wheel'}, admin_users={'override_admin'} + admin_groups={'jh_admins', 'wheel'}, + admin_users={'override_admin'}, + allow_all=True, ) # Check admin_group applies as expected @@ -141,7 +144,10 @@ async def test_pam_auth_admin_groups(): async def test_pam_auth_allowed(): - authenticator = MockPAMAuthenticator(allowed_users={'wash', 'kaylee'}) + authenticator = MockPAMAuthenticator( + allowed_users={'wash', 'kaylee'}, allow_all=False + ) + authorized = await authenticator.get_authenticated_user( None, {'username': 'kaylee', 'password': 'kaylee'} ) @@ -162,7 +168,7 @@ async def test_pam_auth_allowed_groups(): def getgrnam(name): return MockStructGroup('grp', ['kaylee']) - authenticator = MockPAMAuthenticator(allowed_groups={'group'}) + authenticator = MockPAMAuthenticator(allowed_groups={'group'}, allow_all=False) with mock.patch.object(authenticator, '_getgrnam', getgrnam): authorized = await authenticator.get_authenticated_user( @@ -179,14 +185,14 @@ async def test_pam_auth_allowed_groups(): async def test_pam_auth_blocked(): # Null case compared to next case - authenticator = MockPAMAuthenticator() + authenticator = MockPAMAuthenticator(allow_all=True) authorized = await authenticator.get_authenticated_user( None, {'username': 'wash', 'password': 'wash'} ) assert authorized['name'] == 'wash' - # Blacklist basics - authenticator = MockPAMAuthenticator(blocked_users={'wash'}) + # Blocklist basics + authenticator = MockPAMAuthenticator(blocked_users={'wash'}, allow_all=True) authorized = await authenticator.get_authenticated_user( None, {'username': 'wash', 'password': 'wash'} ) @@ -194,7 +200,9 @@ async def test_pam_auth_blocked(): # User in both allowed and blocked: default deny. Make error someday? authenticator = MockPAMAuthenticator( - blocked_users={'wash'}, allowed_users={'wash', 'kaylee'} + blocked_users={'wash'}, + allowed_users={'wash', 'kaylee'}, + allow_all=True, ) authorized = await authenticator.get_authenticated_user( None, {'username': 'wash', 'password': 'wash'} @@ -203,7 +211,8 @@ async def test_pam_auth_blocked(): # User not in blocked set can log in authenticator = MockPAMAuthenticator( - blocked_users={'wash'}, allowed_users={'wash', 'kaylee'} + blocked_users={'wash'}, + allowed_users={'wash', 'kaylee'}, ) authorized = await authenticator.get_authenticated_user( None, {'username': 'kaylee', 'password': 'kaylee'} @@ -221,7 +230,8 @@ async def test_pam_auth_blocked(): # User in neither list authenticator = MockPAMAuthenticator( - blocked_users={'mal'}, allowed_users={'wash', 'kaylee'} + blocked_users={'mal'}, + allowed_users={'wash', 'kaylee'}, ) authorized = await authenticator.get_authenticated_user( None, {'username': 'simon', 'password': 'simon'} @@ -257,7 +267,9 @@ async def test_deprecated_signatures(): async def test_pam_auth_no_such_group(): - authenticator = MockPAMAuthenticator(allowed_groups={'nosuchcrazygroup'}) + authenticator = MockPAMAuthenticator( + allowed_groups={'nosuchcrazygroup'}, + ) authorized = await authenticator.get_authenticated_user( None, {'username': 'kaylee', 'password': 'kaylee'} ) @@ -405,7 +417,7 @@ async def test_auth_state_disabled(app, auth_state_unavailable): async def test_normalize_names(): - a = MockPAMAuthenticator() + a = MockPAMAuthenticator(allow_all=True) authorized = await a.get_authenticated_user( None, {'username': 'ZOE', 'password': 'ZOE'} ) @@ -428,7 +440,7 @@ async def test_normalize_names(): async def test_username_map(): - a = MockPAMAuthenticator(username_map={'wash': 'alpha'}) + a = MockPAMAuthenticator(username_map={'wash': 'alpha'}, allow_all=True) authorized = await a.get_authenticated_user( None, {'username': 'WASH', 'password': 'WASH'} ) @@ -458,7 +470,7 @@ async def test_post_auth_hook(): authentication['testkey'] = 'testvalue' return authentication - a = MockPAMAuthenticator(post_auth_hook=test_auth_hook) + a = MockPAMAuthenticator(allow_all=True, post_auth_hook=test_auth_hook) authorized = await a.get_authenticated_user( None, {'username': 'test_user', 'password': 'test_user'} @@ -566,6 +578,7 @@ async def test_auth_managed_groups( parent=app, authenticated_groups=authenticated_groups, refresh_groups=refresh_groups, + allow_all=True, ) user.groups.append(group) @@ -1019,3 +1032,193 @@ async def test_auth_manage_roles_description_handling(app, user, role_spec, expe assert not app.db.dirty roles = {role.name: role for role in user.roles} assert roles[name].description == expected + + +@pytest.mark.parametrize( + "allowed_users, allow_existing_users", + [ + ('specified', True), + ('', False), + ], +) +async def test_allow_defaults(app, user, allowed_users, allow_existing_users): + if allowed_users: + allowed_users = set(allowed_users.split(',')) + else: + allowed_users = set() + authenticator = auth.Authenticator(allowed_users=allowed_users) + authenticator.authenticate = lambda handler, data: data["username"] + assert authenticator.allow_all is False + assert authenticator.allow_existing_users == allow_existing_users + + # user was already in the database + # this happens during hub startup + authenticator.add_user(user) + if allowed_users: + assert user.name in authenticator.allowed_users + else: + authenticator.allowed_users == set() + + specified_allowed = await authenticator.get_authenticated_user( + None, {"username": "specified"} + ) + if "specified" in allowed_users: + assert specified_allowed is not None + else: + assert specified_allowed is None + + existing_allowed = await authenticator.get_authenticated_user( + None, {"username": user.name} + ) + if allow_existing_users: + assert existing_allowed is not None + else: + assert existing_allowed is None + + +@pytest.mark.parametrize("allow_all", [None, True, False]) +@pytest.mark.parametrize("allow_existing_users", [None, True, False]) +@pytest.mark.parametrize("allowed_users", ["existing", ""]) +def test_allow_existing_users( + app, user, allowed_users, allow_all, allow_existing_users +): + if allowed_users: + allowed_users = set(allowed_users.split(',')) + else: + allowed_users = set() + authenticator = auth.Authenticator( + allowed_users=allowed_users, + ) + if allow_all is None: + # default allow_all + allow_all = authenticator.allow_all + else: + authenticator.allow_all = allow_all + if allow_existing_users is None: + # default allow_all + allow_existing_users = authenticator.allow_existing_users + else: + authenticator.allow_existing_users = allow_existing_users + + # first, nobody in the database + assert authenticator.check_allowed("newuser") == allow_all + + # user was already in the database + # this happens during hub startup + authenticator.add_user(user) + if allow_existing_users or allow_all: + assert authenticator.check_allowed(user.name) + else: + assert not authenticator.check_allowed(user.name) + for username in allowed_users: + assert authenticator.check_allowed(username) + + assert authenticator.check_allowed("newuser") == allow_all + + +@pytest.mark.parametrize("allow_all", [True, False]) +@pytest.mark.parametrize("allow_existing_users", [True, False]) +def test_allow_existing_users_first_time(user, allow_all, allow_existing_users): + # make sure that calling add_user doesn't change results + authenticator = auth.Authenticator( + allow_all=allow_all, + allow_existing_users=allow_existing_users, + ) + allowed_before_one = authenticator.check_allowed(user.name) + allowed_before_two = authenticator.check_allowed("newuser") + # add_user is called after successful login + # it shouldn't change results (e.g. by switching .allowed_users from empty to non-empty) + if allowed_before_one: + authenticator.add_user(user) + assert authenticator.check_allowed(user.name) == allowed_before_one + assert authenticator.check_allowed("newuser") == allowed_before_two + + +class AllowAllIgnoringAuthenticator(auth.Authenticator): + """Test authenticator with custom check_allowed + + not updated for allow_all, allow_existing_users + + Make sure new config doesn't break backward-compatibility + or grant unintended access for Authenticators written before JupyterHub 5. + """ + + allowed_letters = Tuple(config=True, help="Initial letters to allow") + + def authenticate(self, handler, data): + return {"name": data["username"]} + + def check_allowed(self, username, auth=None): + if not self.allowed_users and not self.allowed_letters: + # this subclass doesn't know about the JupyterHub 5 allow_all config + # no allow config, allow all! + return True + if self.allowed_users and username in self.allowed_users: + return True + if self.allowed_letters and username.startswith(self.allowed_letters): + return True + return False + + +# allow_all is not recognized by Authenticator subclass +# make sure it doesn't make anything more permissive, at least +@pytest.mark.parametrize("allow_all", [True, False]) +@pytest.mark.parametrize( + "allowed_users, allowed_letters, allow_existing_users, allowed, not_allowed", + [ + ("", "", None, "anyone,should-be,allowed,existing", ""), + ("", "a,b", None, "alice,bebe", "existing,other"), + ("", "a,b", False, "alice,bebe", "existing,other"), + ("", "a,b", True, "alice,bebe,existing", "other"), + ("specified", "a,b", None, "specified,alice,bebe,existing", "other"), + ("specified", "a,b", False, "specified,alice,bebe", "existing,other"), + ("specified", "a,b", True, "specified,alice,bebe,existing", "other"), + ], +) +async def test_authenticator_without_allow_all( + app, + allowed_users, + allowed_letters, + allow_existing_users, + allowed, + not_allowed, + allow_all, +): + kwargs = {} + if allow_all is not None: + kwargs["allow_all"] = allow_all + if allow_existing_users is not None: + kwargs["allow_existing_users"] = allow_existing_users + if allowed_users: + kwargs["allowed_users"] = set(allowed_users.split(',')) + if allowed_letters: + kwargs["allowed_letters"] = tuple(allowed_letters.split(',')) + + authenticator = AllowAllIgnoringAuthenticator(**kwargs) + + # load one user from db + existing_user = add_user(app.db, app, name="existing") + authenticator.add_user(existing_user) + + if allowed: + allowed = allowed.split(",") + if not_allowed: + not_allowed = not_allowed.split(",") + + expected_allowed = sorted(allowed) + expected_not_allowed = sorted(not_allowed) + to_check = list(chain(expected_allowed, expected_not_allowed)) + if allow_all: + expected_allowed = to_check + expected_not_allowed = [] + + are_allowed = [] + are_not_allowed = [] + for username in to_check: + if await authenticator.get_authenticated_user(None, {"username": username}): + are_allowed.append(username) + else: + are_not_allowed.append(username) + + assert are_allowed == expected_allowed + assert are_not_allowed == expected_not_allowed diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index 52a42d4d..6814cef0 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -213,7 +213,9 @@ async def test_spawn_handler_access(app): r.raise_for_status() -@pytest.mark.parametrize("has_access", ["all", "user", "group", False]) +@pytest.mark.parametrize( + "has_access", ["all", "user", (pytest.param("group", id="in-group")), False] +) async def test_spawn_other_user( app, user, username, group, create_temp_role, has_access ): @@ -300,7 +302,9 @@ async def test_spawn_page_falsy_callable(app): assert history[1] == ujoin(public_url(app), "hub/spawn-pending/erik") -@pytest.mark.parametrize("has_access", ["all", "user", "group", False]) +@pytest.mark.parametrize( + "has_access", ["all", "user", (pytest.param("group", id="in-group")), False] +) async def test_spawn_page_access( app, has_access, group, username, user, create_temp_role ): @@ -403,7 +407,9 @@ async def test_spawn_form(app): } -@pytest.mark.parametrize("has_access", ["all", "user", "group", False]) +@pytest.mark.parametrize( + "has_access", ["all", "user", (pytest.param("group", id="in-group")), False] +) async def test_spawn_form_other_user( app, username, user, group, create_temp_role, has_access ): @@ -624,7 +630,9 @@ async def test_user_redirect_hook(app, username): assert redirected_url.path == ujoin(app.base_url, 'user', username, 'terminals/1') -@pytest.mark.parametrize("has_access", ["all", "user", "group", False]) +@pytest.mark.parametrize( + "has_access", ["all", "user", (pytest.param("group", id="in-group")), False] +) async def test_other_user_url(app, username, user, group, create_temp_role, has_access): """Test accessing /user/someonelse/ URLs when the server is not running @@ -685,11 +693,10 @@ async def test_other_user_url(app, username, user, group, create_temp_role, has_ ], ) async def test_page_with_token(app, user, url, token_in): - cookies = await app.login_user(user.name) token = user.new_api_token() if token_in == "url": url = url_concat(url, {"token": token}) - headers = None + headers = {} elif token_in == "header": headers = { "Authorization": f"token {token}", @@ -734,14 +741,13 @@ async def test_login_strip(app, form_user, auth_user, form_password): """Test that login form strips space form usernames, but not passwords""" form_data = {"username": form_user, "password": form_password} expected_auth = {"username": auth_user, "password": form_password} - base_url = public_url(app) called_with = [] async def mock_authenticate(handler, data): called_with.append(data) with mock.patch.object(app.authenticator, 'authenticate', mock_authenticate): - r = await async_requests.get(base_url + 'hub/login') + r = await get_page('login', app) r.raise_for_status() cookies = r.cookies xsrf = cookies['_xsrf'] @@ -922,17 +928,19 @@ async def test_auto_login(app, request): async def test_auto_login_logout(app): name = 'burnham' cookies = await app.login_user(name) + s = AsyncSession() + s.cookies = cookies with mock.patch.dict( app.tornado_settings, {'authenticator': Authenticator(auto_login=True)} ): - r = await async_requests.get( + r = await s.get( public_host(app) + app.tornado_settings['logout_url'], cookies=cookies ) r.raise_for_status() logout_url = public_host(app) + app.tornado_settings['logout_url'] assert r.url == logout_url - assert r.cookies == {} + assert list(s.cookies.keys()) == ["_xsrf"] # don't include logged-out user in page: try: idx = r.text.index(name) @@ -946,19 +954,23 @@ async def test_auto_login_logout(app): async def test_logout(app): name = 'wash' cookies = await app.login_user(name) - r = await async_requests.get( - public_host(app) + app.tornado_settings['logout_url'], cookies=cookies + s = AsyncSession() + s.cookies = cookies + r = await s.get( + public_host(app) + app.tornado_settings['logout_url'], ) r.raise_for_status() login_url = public_host(app) + app.tornado_settings['login_url'] assert r.url == login_url - assert r.cookies == {} + assert list(s.cookies.keys()) == ["_xsrf"] @pytest.mark.parametrize('shutdown_on_logout', [True, False]) async def test_shutdown_on_logout(app, shutdown_on_logout): name = 'shutitdown' cookies = await app.login_user(name) + s = AsyncSession() + s.cookies = cookies user = app.users[name] # start the user's server @@ -978,14 +990,14 @@ async def test_shutdown_on_logout(app, shutdown_on_logout): with mock.patch.dict( app.tornado_settings, {'shutdown_on_logout': shutdown_on_logout} ): - r = await async_requests.get( + r = await s.get( public_host(app) + app.tornado_settings['logout_url'], cookies=cookies ) r.raise_for_status() login_url = public_host(app) + app.tornado_settings['login_url'] assert r.url == login_url - assert r.cookies == {} + assert list(s.cookies.keys()) == ["_xsrf"] # wait for any pending state to resolve for i in range(50): diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index 42c4fa72..b45ba921 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -86,17 +86,9 @@ async def test_hubauth_token(app, mockservice_url, create_user_with_scopes): sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']} assert sub_reply == {'name': u.name, 'admin': False} - # token in ?token parameter + # token in ?token parameter is not allowed by default r = await async_requests.get( - public_url(app, mockservice_url) + '/whoami/?token=%s' % token - ) - r.raise_for_status() - reply = r.json() - sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']} - assert sub_reply == {'name': u.name, 'admin': False} - - r = await async_requests.get( - public_url(app, mockservice_url) + '/whoami/?token=no-such-token', + public_url(app, mockservice_url) + '/whoami/?token=%s' % token, allow_redirects=False, ) assert r.status_code == 302 @@ -180,21 +172,9 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo else: assert r.status_code == 403 - # token in ?token parameter + # token in ?token parameter is not allowed by default r = await async_requests.get( - public_url(app, mockservice_url) + 'whoami/?token=%s' % token - ) - if allowed: - r.raise_for_status() - assert r.status_code == 200 - reply = r.json() - assert service_model.items() <= reply.items() - assert not r.cookies - else: - assert r.status_code == 403 - - r = await async_requests.get( - public_url(app, mockservice_url) + 'whoami/?token=no-such-token', + public_url(app, mockservice_url) + 'whoami/?token=%s' % token, allow_redirects=False, ) assert r.status_code == 302 @@ -385,20 +365,14 @@ async def test_oauth_service_roles( # token-authenticated request to HubOAuth token = app.users[name].new_api_token() - # token in ?token parameter - r = await async_requests.get(url_concat(url, {'token': token})) + s.headers["Authorization"] = f"Bearer {token}" + r = await async_requests.get(url, headers=s.headers) r.raise_for_status() reply = r.json() assert reply['name'] == name - # verify that ?token= requests set a cookie - assert len(r.cookies) != 0 - # ensure cookie works in future requests - r = await async_requests.get(url, cookies=r.cookies, allow_redirects=False) - r.raise_for_status() - assert r.url == url - reply = r.json() - assert reply['name'] == name + # tokens in headers don't set cookies + assert len(r.cookies) == 0 @pytest.mark.parametrize( @@ -578,9 +552,8 @@ async def test_oauth_cookie_collision( else: raise ValueError(f"finish_first should be 1 or 2, not {finish_first!r}") # submit the oauth form to complete authorization - r = await s.post( - oauth.url, data={'scopes': ['identify'], "_xsrf": s.cookies["_xsrf"]} - ) + hub_xsrf = s.cookies.get("_xsrf", path=app.hub.base_url) + r = await s.post(oauth.url, data={'scopes': ['identify'], "_xsrf": hub_xsrf}) r.raise_for_status() assert r.url == expected_url # after finishing, state cookies are all cleared @@ -596,9 +569,7 @@ async def test_oauth_cookie_collision( assert service_cookie # finish other oauth - r = await s.post( - second_oauth.url, data={'scopes': ['identify'], "_xsrf": s.cookies["_xsrf"]} - ) + r = await s.post(second_oauth.url, data={'scopes': ['identify'], "_xsrf": hub_xsrf}) r.raise_for_status() # second oauth doesn't complete, @@ -664,7 +635,7 @@ async def test_oauth_logout(app, mockservice_url, create_user_with_scopes): r = await s.get(public_url(app, path='hub/logout')) r.raise_for_status() # verify that all cookies other than the service cookie are cleared - assert sorted(s.cookies.keys()) == ["_xsrf", service_cookie_name] + assert sorted(set(s.cookies.keys())) == ["_xsrf", service_cookie_name] # verify that clearing session id invalidates service cookie # i.e. redirect back to login page r = await s.get(url) diff --git a/jupyterhub/tests/test_shares.py b/jupyterhub/tests/test_shares.py index db12757f..2d3d2c92 100644 --- a/jupyterhub/tests/test_shares.py +++ b/jupyterhub/tests/test_shares.py @@ -159,7 +159,9 @@ def expand_scopes(scope_str, user, group=None, share_with=None): return scopes -@pytest.mark.parametrize("share_with", ["user", "group"]) +@pytest.mark.parametrize( + "share_with", ["user", pytest.param("group", id="share_with=group")] +) def test_create_share(app, user, share_user, group, share_with): db = app.db spawner = user.spawner.orm_spawner @@ -427,7 +429,7 @@ def test_share_code_expires(app, user, share_user): "kind", [ ("user"), - ("group"), + (pytest.param("group", id="kind=group")), ], ) async def test_shares_api_user_group_doesnt_exist( diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py index 9bb62183..d2d4be17 100644 --- a/jupyterhub/tests/test_singleuser.py +++ b/jupyterhub/tests/test_singleuser.py @@ -2,6 +2,7 @@ import os import sys +import warnings from contextlib import nullcontext from pathlib import Path from pprint import pprint @@ -75,18 +76,20 @@ async def test_singleuser_auth( spawner = user.spawners[server_name] url = url_path_join(public_url(app, user), server_name) + s = AsyncSession() + # no cookies, redirects to login page - r = await async_requests.get(url) + r = await s.get(url) r.raise_for_status() assert '/hub/login' in r.url # unauthenticated /api/ should 403, not redirect api_url = url_path_join(url, "api/status") - r = await async_requests.get(api_url, allow_redirects=False) + r = await s.get(api_url, allow_redirects=False) assert r.status_code == 403 # with cookies, login successful - r = await async_requests.get(url, cookies=cookies) + r = await s.get(url, cookies=cookies) r.raise_for_status() assert ( urlparse(r.url) @@ -100,7 +103,7 @@ async def test_singleuser_auth( assert r.status_code == 200 # logout - r = await async_requests.get(url_path_join(url, 'logout'), cookies=cookies) + r = await s.get(url_path_join(url, 'logout')) assert len(r.cookies) == 0 # accessing another user's server hits the oauth confirmation page @@ -149,6 +152,8 @@ async def test_singleuser_auth( async def test_disable_user_config(request, app, tmp_path, full_spawn): # login, start the server cookies = await app.login_user('nandy') + s = AsyncSession() + s.cookies = cookies user = app.users['nandy'] # stop spawner, if running: if user.running: @@ -180,10 +185,11 @@ async def test_disable_user_config(request, app, tmp_path, full_spawn): url = public_url(app, user) # with cookies, login successful - r = await async_requests.get(url, cookies=cookies) + r = await s.get(url) r.raise_for_status() assert r.url.endswith('/user/nandy/jupyterhub-test-info') assert r.status_code == 200 + info = r.json() pprint(info) assert info['disable_user_config'] @@ -286,6 +292,57 @@ async def test_notebook_dir( raise ValueError(f"No contents check for {notebook_dir=}") +@pytest.mark.parametrize("extension", [True, False]) +@pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has no auth configuration") +async def test_forbid_unauthenticated_access( + request, app, tmp_path, user, full_spawn, extension +): + try: + from jupyter_server.auth.decorator import allow_unauthenticated # noqa + except ImportError: + pytest.skip("needs jupyter-server 2.13") + + from jupyter_server.utils import JupyterServerAuthWarning + + # login, start the server + cookies = await app.login_user('nandy') + s = AsyncSession() + s.cookies = cookies + user = app.users['nandy'] + # stop spawner, if running: + if user.running: + await user.stop() + # start with new config: + user.spawner.default_url = "/jupyterhub-test-info" + + if extension: + user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "1" + else: + user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "0" + + # make sure it's resolved to start + tmp_path = tmp_path.resolve() + real_home_dir = tmp_path / "realhome" + real_home_dir.mkdir() + # make symlink to test resolution + home_dir = tmp_path / "home" + home_dir.symlink_to(real_home_dir) + # home_dir is defined on SimpleSpawner + user.spawner.home_dir = str(home_dir) + jupyter_config_dir = home_dir / ".jupyter" + jupyter_config_dir.mkdir() + # verify config paths + with (jupyter_config_dir / "jupyter_server_config.py").open("w") as f: + f.write("c.ServerApp.allow_unauthenticated_access = False") + + # If there are core endpoints (added by jupyterhub) without decorators, + # spawn will error out. If there are extension endpoints without decorators + # these will be logged as warnings. + with warnings.catch_warnings(): + warnings.simplefilter("error", JupyterServerAuthWarning) + await user.spawn() + + @pytest.mark.skipif(IS_JUPYVERSE, reason="jupyverse has no --help-all") def test_help_output(): out = check_output( @@ -385,20 +442,31 @@ async def test_nbclassic_control_panel(app, user, full_spawn): @pytest.mark.skipif( IS_JUPYVERSE, reason="jupyverse doesn't implement token authentication" ) -async def test_token_url_cookie(app, user, full_spawn): +@pytest.mark.parametrize("accept_token_in_url", ["1", "0", ""]) +async def test_token_url_cookie(app, user, full_spawn, accept_token_in_url): + if accept_token_in_url: + user.spawner.environment["JUPYTERHUB_ALLOW_TOKEN_IN_URL"] = accept_token_in_url + should_accept = accept_token_in_url == "1" + await user.spawn() await app.proxy.add_user(user) + token = user.new_api_token(scopes=["access:servers!user"]) url = url_path_join(public_url(app, user), user.spawner.default_url or "/tree/") # first request: auth with token in URL - r = await async_requests.get(url + f"?token={token}", allow_redirects=False) + s = AsyncSession() + r = await s.get(url + f"?token={token}", allow_redirects=False) print(r.url, r.status_code) + if not should_accept: + assert r.status_code == 302 + return + assert r.status_code == 200 - assert r.cookies + assert s.cookies # second request, use cookies set by first response, # no token in URL - r = await async_requests.get(url, cookies=r.cookies, allow_redirects=False) + r = await s.get(url, allow_redirects=False) assert r.status_code == 200 await user.stop() @@ -409,7 +477,8 @@ async def test_api_403_no_cookie(app, user, full_spawn): await user.spawn() await app.proxy.add_user(user) url = url_path_join(public_url(app, user), "/api/contents/") - r = await async_requests.get(url, allow_redirects=False) + s = AsyncSession() + r = await s.get(url, allow_redirects=False) # 403, not redirect assert r.status_code == 403 # no state cookie set diff --git a/jupyterhub/tests/utils.py b/jupyterhub/tests/utils.py index 54222658..143b2a9a 100644 --- a/jupyterhub/tests/utils.py +++ b/jupyterhub/tests/utils.py @@ -42,6 +42,13 @@ async_requests = _AsyncRequests() class AsyncSession(requests.Session): """requests.Session object that runs in the background thread""" + def __init__(self, **kwargs): + super().__init__(**kwargs) + # session requests are for cookie authentication + # and should look like regular page views, + # so set Sec-Fetch-Mode: navigate + self.headers.setdefault("Sec-Fetch-Mode", "navigate") + def request(self, *args, **kwargs): return async_requests.executor.submit(super().request, *args, **kwargs) @@ -157,6 +164,7 @@ async def api_request( else: base_url = public_url(app, path='hub') headers = kwargs.setdefault('headers', {}) + headers.setdefault("Sec-Fetch-Mode", "cors") if 'Authorization' not in headers and not noauth and 'cookies' not in kwargs: # make a copy to avoid modifying arg in-place kwargs['headers'] = h = {} @@ -176,7 +184,7 @@ async def api_request( kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key) kwargs["verify"] = app.internal_ssl_ca resp = await f(url, **kwargs) - assert "frame-ancestors 'self'" in resp.headers['Content-Security-Policy'] + assert "frame-ancestors 'none'" in resp.headers['Content-Security-Policy'] assert ( ujoin(app.hub.base_url, "security/csp-report") in resp.headers['Content-Security-Policy'] @@ -197,6 +205,9 @@ def get_page(path, app, hub=True, **kw): else: prefix = app.base_url base_url = ujoin(public_host(app), prefix) + # Sec-Fetch-Mode=navigate to look like a regular page view + headers = kw.setdefault("headers", {}) + headers.setdefault("Sec-Fetch-Mode", "navigate") return async_requests.get(ujoin(base_url, path), **kw) diff --git a/jupyterhub/user.py b/jupyterhub/user.py index 80ceaf31..da4f6edd 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -526,6 +526,9 @@ class User: _deprecated_db_session=self.db, oauth_client_id=client_id, cookie_options=self.settings.get('cookie_options', {}), + cookie_host_prefix_enabled=self.settings.get( + "cookie_host_prefix_enabled", False + ), trusted_alt_names=trusted_alt_names, user_options=orm_spawner.user_options or {}, ) diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 129c5944..6a9ee8c2 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -8,6 +8,7 @@ import errno import functools import hashlib import inspect +import os import random import re import secrets @@ -34,6 +35,21 @@ from tornado.httpclient import AsyncHTTPClient, HTTPError from tornado.log import app_log +def _bool_env(key, default=False): + """Cast an environment variable to bool + + If unset or empty, return `default` + `0` is False; all other values are True. + """ + value = os.environ.get(key, "") + if value == "": + return default + if value.lower() in {"0", "false"}: + return False + else: + return True + + # Deprecated aliases: no longer needed now that we require 3.7 def asyncio_all_tasks(loop=None): warnings.warn( diff --git a/share/jupyterhub/templates/token.html b/share/jupyterhub/templates/token.html index 3f530734..e6f6163a 100644 --- a/share/jupyterhub/templates/token.html +++ b/share/jupyterhub/templates/token.html @@ -6,11 +6,6 @@

Manage JupyterHub Tokens

-
- -
JupyterHub documentation for a list of available scopes.
+
+ +