mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-13 13:03:01 +00:00
Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a1824a4b3a | ||
![]() |
e28b234cc5 | ||
![]() |
88be9eda05 | ||
![]() |
916632fcc8 | ||
![]() |
0737e6f581 | ||
![]() |
02cbb5f337 | ||
![]() |
99e2720b0f | ||
![]() |
b405361674 | ||
![]() |
0d53ead186 | ||
![]() |
cedd176a34 | ||
![]() |
58e5022d28 | ||
![]() |
f395acd9ca | ||
![]() |
42191672ac | ||
![]() |
669d8d7b65 | ||
![]() |
171026583c | ||
![]() |
78a3dc5b01 | ||
![]() |
21c37309a5 | ||
![]() |
3d40be5890 | ||
![]() |
ac72c60cb3 | ||
![]() |
92264696b1 | ||
![]() |
f2b7b69c3e | ||
![]() |
e0f001271b |
@@ -6,7 +6,7 @@ info:
|
||||
description: The REST API for JupyterHub
|
||||
license:
|
||||
name: BSD-3-Clause
|
||||
version: 4.1.2
|
||||
version: 4.1.6
|
||||
servers:
|
||||
- url: /hub/api
|
||||
security:
|
||||
@@ -1461,8 +1461,9 @@ components:
|
||||
Access the admin page. Permission to take actions via the admin
|
||||
page granted separately.
|
||||
admin:users:
|
||||
Read, write, create and delete users and their authentication
|
||||
state, not including their servers or tokens.
|
||||
Read, modify, create, and delete users and their authentication
|
||||
state, not including their servers or tokens. This is an extremely privileged
|
||||
scope and should be considered tantamount to superuser.
|
||||
admin:auth_state: Read a user’s authentication state.
|
||||
users:
|
||||
Read and write permissions to user models (excluding servers, tokens
|
||||
@@ -1493,8 +1494,8 @@ components:
|
||||
read:tokens: Read user tokens.
|
||||
admin:groups: Read and write group information, create and delete groups.
|
||||
groups:
|
||||
Read and write group information, including adding/removing users
|
||||
to/from groups.
|
||||
"Read and write group information, including adding/removing any
|
||||
users to/from groups. Note: adding users to groups may affect permissions."
|
||||
list:groups: List groups, including at least their names.
|
||||
read:groups: Read group models.
|
||||
read:groups:name: Read group names.
|
||||
|
@@ -178,6 +178,32 @@ Note that only the {ref}`horizontal filtering <horizontal-filtering-target>` can
|
||||
Metascopes `self` and `all`, `<resource>`, `<resource>:<subresource>`, `read:<resource>`, `admin:<resource>`, and `access:<resource>` scopes are predefined and cannot be changed otherwise.
|
||||
```
|
||||
|
||||
(granting-scopes)=
|
||||
|
||||
### Considerations when allowing users to grant permissions via the `groups` scope
|
||||
|
||||
In general, permissions are fixed by role assignments in configuration (or via Authenticator-managed roles in JupyterHub 5) and can only be modified by administrators who can modify the Hub configuration.
|
||||
|
||||
There is only one scope that allows users to modify permissions of themselves or others at runtime instead of via configuration:
|
||||
the `groups` scope, which allows adding and removing users from one or more groups.
|
||||
With the `groups` scope, a user can add or remove any users to/from any group.
|
||||
With the `groups!group=name` filtered scope, a user can add or remove any users to/from a specific group.
|
||||
There are two ways in which adding a user to a group may affect their permissions:
|
||||
|
||||
- if the group is assigned one or more roles, adding a user to the group may increase their permissions (this is usually the point!)
|
||||
- if the group is the _target_ of a filter on this or another group, such as `access:servers!group=students`, adding a user to the group can grant _other_ users elevated access to that user's resources.
|
||||
|
||||
With these in mind, when designing your roles, do not grant users the `groups` scope for any groups which:
|
||||
|
||||
- have roles the user should not have authority over, or
|
||||
- would grant them access they shouldn't have for _any_ user (e.g. don't grant `teachers` both `access:servers!group=students` and `groups!group=students` which is tantamount to the unrestricted `access:servers` because they control which users the `group=students` filter applies to).
|
||||
|
||||
If a group does not have role assignments and the group is not present in any `!group=` filter, there should be no permissions-related consequences for adding users to groups.
|
||||
|
||||
:::{note}
|
||||
The legacy `admin` property of users, which grants extreme superuser permissions and is generally discouraged in favor of more specific roles and scopes, may be modified only by other users with the `admin` property (e.g. added via `admin_users`).
|
||||
:::
|
||||
|
||||
(custom-scopes)=
|
||||
|
||||
### Custom scopes
|
||||
|
@@ -10,6 +10,68 @@ command line for details.
|
||||
|
||||
## 4.1
|
||||
|
||||
### 4.1.6 - 2024-07-31
|
||||
|
||||
4.1.6 is a **security release**, fixing [CVE-2024-41942].
|
||||
All JupyterHub deployments are encouraged to upgrade,
|
||||
but only those with users having the `admin:users` scope are affected.
|
||||
The [full advisory][CVE-2024-41942] will be published 7 days after the release.
|
||||
|
||||
[CVE-2024-41942]: https://github.com/jupyterhub/jupyterhub/security/advisories/GHSA-9x4q-3gxw-849f
|
||||
|
||||
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/4.1.5...4.1.6))
|
||||
|
||||
### 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.
|
||||
|
@@ -3,7 +3,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
# version_info updated by running `tbump`
|
||||
version_info = (4, 1, 2, "", "")
|
||||
version_info = (4, 1, 6, "", "")
|
||||
|
||||
# pep 440 version: no dot before beta/rc, but before .dev
|
||||
# 0.1.0rc1
|
||||
|
@@ -178,10 +178,35 @@ def get_xsrf_token(handler, cookie_path=""):
|
||||
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)
|
||||
|
@@ -186,6 +186,8 @@ class UserListAPIHandler(APIHandler):
|
||||
# admin is set for all users
|
||||
# to create admin and non-admin users requires at least two API requests
|
||||
admin = data.get('admin', False)
|
||||
if admin and not self.current_user.admin:
|
||||
raise web.HTTPError(403, "Only admins can grant admin permissions")
|
||||
|
||||
to_create = []
|
||||
invalid_names = []
|
||||
@@ -259,12 +261,16 @@ class UserAPIHandler(APIHandler):
|
||||
if user is not None:
|
||||
raise web.HTTPError(409, "User %s already exists" % user_name)
|
||||
|
||||
user = self.user_from_username(user_name)
|
||||
if data:
|
||||
self._check_user_model(data)
|
||||
if 'admin' in data:
|
||||
user.admin = data['admin']
|
||||
assign_default_roles(self.db, entity=user)
|
||||
if data.get('admin', False) and not self.current_user.admin:
|
||||
raise web.HTTPError(403, "Only admins can grant admin permissions")
|
||||
|
||||
# create the user
|
||||
user = self.user_from_username(user_name)
|
||||
if data and data.get('admin', False):
|
||||
user.admin = data['admin']
|
||||
assign_default_roles(self.db, entity=user)
|
||||
self.db.commit()
|
||||
|
||||
try:
|
||||
@@ -322,7 +328,17 @@ class UserAPIHandler(APIHandler):
|
||||
400,
|
||||
"User %s already exists, username must be unique" % data['name'],
|
||||
)
|
||||
|
||||
if not self.current_user.admin:
|
||||
if user.admin:
|
||||
raise web.HTTPError(403, "Only admins can modify other admins")
|
||||
if 'admin' in data and data['admin']:
|
||||
raise web.HTTPError(403, "Only admins can grant admin permissions")
|
||||
for key, value in data.items():
|
||||
value_s = "..." if key == "auth_state" else repr(value)
|
||||
self.log.info(
|
||||
f"{self.current_user.name} setting {key}={value_s} for {user.name}"
|
||||
)
|
||||
if key == 'auth_state':
|
||||
await user.save_auth_state(value)
|
||||
else:
|
||||
|
@@ -48,7 +48,7 @@ scope_definitions = {
|
||||
'doc_description': 'Access the admin page. Permission to take actions via the admin page granted separately.',
|
||||
},
|
||||
'admin:users': {
|
||||
'description': 'Read, write, create and delete users and their authentication state, not including their servers or tokens.',
|
||||
'description': 'Read, modify, create, and delete users and their authentication state, not including their servers or tokens. This is an extremely privileged scope and should be considered tantamount to superuser.',
|
||||
'subscopes': ['admin:auth_state', 'users', 'read:roles:users', 'delete:users'],
|
||||
},
|
||||
'admin:auth_state': {'description': 'Read a user’s authentication state.'},
|
||||
@@ -109,7 +109,7 @@ scope_definitions = {
|
||||
'subscopes': ['groups', 'read:roles:groups', 'delete:groups'],
|
||||
},
|
||||
'groups': {
|
||||
'description': 'Read and write group information, including adding/removing users to/from groups.',
|
||||
'description': 'Read and write group information, including adding/removing any users to/from groups. Note: adding users to groups may affect permissions.',
|
||||
'subscopes': ['read:groups', 'list:groups'],
|
||||
},
|
||||
'list:groups': {
|
||||
|
@@ -62,6 +62,7 @@ from traitlets.config import SingletonConfigurable
|
||||
|
||||
from .._xsrf_utils import (
|
||||
_anonymous_xsrf_id,
|
||||
_needs_check_xsrf,
|
||||
_set_xsrf_cookie,
|
||||
check_xsrf_cookie,
|
||||
get_xsrf_token,
|
||||
@@ -931,7 +932,9 @@ class HubOAuth(HubAuth):
|
||||
|
||||
Applies JupyterHub check_xsrf_cookie if not token authenticated
|
||||
"""
|
||||
if getattr(handler, '_token_authenticated', False):
|
||||
if getattr(handler, '_token_authenticated', False) or handler.settings.get(
|
||||
"disable_check_xsrf", False
|
||||
):
|
||||
return
|
||||
check_xsrf_cookie(handler)
|
||||
|
||||
@@ -944,31 +947,11 @@ class HubOAuth(HubAuth):
|
||||
kwargs["secure"] = True
|
||||
return handler.clear_cookie(cookie_name, **kwargs)
|
||||
|
||||
def _needs_check_xsrf(self, 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":
|
||||
self.log.warning(
|
||||
f"Skipping XSRF check for insecure request {handler.request.method} {handler.request.path}"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
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 self._needs_check_xsrf(handler):
|
||||
if token and _needs_check_xsrf(handler):
|
||||
# call handler.check_xsrf_cookie instead of self.check_xsrf_cookie
|
||||
# to allow subclass overrides
|
||||
try:
|
||||
|
@@ -825,7 +825,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}"
|
||||
|
@@ -157,7 +157,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()
|
||||
@@ -665,16 +665,25 @@ async def test_add_multi_user(app):
|
||||
|
||||
@mark.user
|
||||
@mark.role
|
||||
async def test_add_multi_user_admin(app):
|
||||
@mark.parametrize("is_admin", [True, False])
|
||||
async def test_add_multi_user_admin(app, create_user_with_scopes, is_admin):
|
||||
db = app.db
|
||||
requester = create_user_with_scopes("admin:users")
|
||||
requester.admin = is_admin
|
||||
db.commit()
|
||||
names = ['c', 'd']
|
||||
r = await api_request(
|
||||
app,
|
||||
'users',
|
||||
method='post',
|
||||
data=json.dumps({'usernames': names, 'admin': True}),
|
||||
name=requester.name,
|
||||
)
|
||||
assert r.status_code == 201
|
||||
if is_admin:
|
||||
assert r.status_code == 201
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
return
|
||||
reply = r.json()
|
||||
r_names = [user['name'] for user in reply]
|
||||
assert names == r_names
|
||||
@@ -712,13 +721,26 @@ async def test_add_user_duplicate(app):
|
||||
|
||||
@mark.user
|
||||
@mark.role
|
||||
async def test_add_admin(app):
|
||||
@mark.parametrize("is_admin", [True, False])
|
||||
async def test_add_admin(app, create_user_with_scopes, is_admin):
|
||||
db = app.db
|
||||
name = 'newadmin'
|
||||
user = create_user_with_scopes("admin:users")
|
||||
user.admin = is_admin
|
||||
db.commit()
|
||||
r = await api_request(
|
||||
app, 'users', name, method='post', data=json.dumps({'admin': True})
|
||||
app,
|
||||
'users',
|
||||
name,
|
||||
method='post',
|
||||
data=json.dumps({'admin': True}),
|
||||
name=user.name,
|
||||
)
|
||||
assert r.status_code == 201
|
||||
if is_admin:
|
||||
assert r.status_code == 201
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
return
|
||||
user = find_user(db, name)
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
@@ -738,9 +760,14 @@ async def test_delete_user(app):
|
||||
|
||||
@mark.user
|
||||
@mark.role
|
||||
async def test_make_admin(app):
|
||||
@mark.parametrize("is_admin", [True, False])
|
||||
async def test_user_make_admin(app, create_user_with_scopes, is_admin):
|
||||
db = app.db
|
||||
name = 'admin2'
|
||||
requester = create_user_with_scopes('admin:users')
|
||||
requester.admin = is_admin
|
||||
db.commit()
|
||||
|
||||
name = new_username("make_admin")
|
||||
r = await api_request(app, 'users', name, method='post')
|
||||
assert r.status_code == 201
|
||||
user = find_user(db, name)
|
||||
@@ -751,10 +778,18 @@ async def test_make_admin(app):
|
||||
assert orm.Role.find(db, 'admin') not in user.roles
|
||||
|
||||
r = await api_request(
|
||||
app, 'users', name, method='patch', data=json.dumps({'admin': True})
|
||||
app,
|
||||
'users',
|
||||
name,
|
||||
method='patch',
|
||||
data=json.dumps({'admin': True}),
|
||||
name=requester.name,
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
if is_admin:
|
||||
assert r.status_code == 200
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
return
|
||||
user = find_user(db, name)
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
@@ -763,6 +798,38 @@ async def test_make_admin(app):
|
||||
assert orm.Role.find(db, 'admin') in user.roles
|
||||
|
||||
|
||||
@mark.user
|
||||
@mark.parametrize("requester_is_admin", [True, False])
|
||||
@mark.parametrize("user_is_admin", [True, False])
|
||||
async def test_user_set_name(
|
||||
app, user, create_user_with_scopes, requester_is_admin, user_is_admin
|
||||
):
|
||||
db = app.db
|
||||
requester = create_user_with_scopes('admin:users')
|
||||
requester.admin = requester_is_admin
|
||||
user.admin = user_is_admin
|
||||
db.commit()
|
||||
new_name = new_username()
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
'users',
|
||||
user.name,
|
||||
method='patch',
|
||||
data=json.dumps({'name': new_name}),
|
||||
name=requester.name,
|
||||
)
|
||||
if requester_is_admin or not user_is_admin:
|
||||
assert r.status_code == 200
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
return
|
||||
renamed = find_user(db, new_name)
|
||||
assert renamed is not None
|
||||
assert renamed.name == new_name
|
||||
assert renamed.id == user.id
|
||||
|
||||
|
||||
@mark.user
|
||||
async def test_set_auth_state(app, auth_state_enabled):
|
||||
auth_state = {'secret': 'hello'}
|
||||
|
@@ -43,7 +43,7 @@ target_version = [
|
||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||
|
||||
[tool.tbump.version]
|
||||
current = "4.1.2"
|
||||
current = "4.1.6"
|
||||
|
||||
# Example of a semver regexp.
|
||||
# Make sure this matches current_version before
|
||||
|
Reference in New Issue
Block a user