mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-10 19:43:01 +00:00
Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
61b0e8bef5 | ||
![]() |
64f3938528 | ||
![]() |
85bc92d88e | ||
![]() |
7bcda18564 | ||
![]() |
86da36857e | ||
![]() |
530833e930 | ||
![]() |
3b0850fa9b | ||
![]() |
1366911be6 | ||
![]() |
fe276eac64 | ||
![]() |
9209ccd0de | ||
![]() |
3b2a1a37f9 | ||
![]() |
6007ba78b0 | ||
![]() |
9cb19cc342 | ||
![]() |
0f471f4e12 | ||
![]() |
68db740998 | ||
![]() |
9c0c6f25b7 | ||
![]() |
5f0077cb5b | ||
![]() |
3610454a12 | ||
![]() |
abc4bbebe4 |
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v2.28.0
|
rev: v2.29.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args:
|
args:
|
||||||
@@ -18,7 +18,7 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
- repo: https://github.com/PyCQA/flake8
|
- repo: https://github.com/PyCQA/flake8
|
||||||
rev: "3.9.2"
|
rev: "4.0.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you believe you’ve found a security vulnerability in a Jupyter
|
||||||
|
project, please report it to security@ipython.org. If you prefer to
|
||||||
|
encrypt your security reports, you can use [this PGP public key](https://jupyter-notebook.readthedocs.io/en/stable/_downloads/1d303a645f2505a8fd283826fafc9908/ipython_security.asc).
|
@@ -72,6 +72,16 @@ and major bug fixes:
|
|||||||
|
|
||||||
- Improve database rollback recovery on broken connections
|
- Improve database rollback recovery on broken connections
|
||||||
|
|
||||||
|
and other changes:
|
||||||
|
|
||||||
|
- Requests to a not-running server (e.g. visiting `/user/someuser/`)
|
||||||
|
will return an HTTP 424 error instead of 503,
|
||||||
|
making it easier to monitor for real deployment problems.
|
||||||
|
JupyterLab in the user environment should be at least version 3.1.16
|
||||||
|
to recognize this error code as a stopped server.
|
||||||
|
You can temporarily opt-in to the older behavior (e.g. if older JupyterLab is required)
|
||||||
|
by setting `c.JupyterHub.use_legacy_stopped_server_status_code = True`.
|
||||||
|
|
||||||
Plus lots of little fixes along the way.
|
Plus lots of little fixes along the way.
|
||||||
|
|
||||||
## 1.4
|
## 1.4
|
||||||
|
@@ -6,7 +6,7 @@ version_info = (
|
|||||||
2,
|
2,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
"b2", # release (b1, rc1, or "" for final or dev)
|
"b3", # release (b1, rc1, or "" for final or dev)
|
||||||
# "dev", # dev or nothing for beta/rc/stable releases
|
# "dev", # dev or nothing for beta/rc/stable releases
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -421,6 +421,7 @@ class UserTokenListAPIHandler(APIHandler):
|
|||||||
token_model = self.token_model(orm.APIToken.find(self.db, api_token))
|
token_model = self.token_model(orm.APIToken.find(self.db, api_token))
|
||||||
token_model['token'] = api_token
|
token_model['token'] = api_token
|
||||||
self.write(json.dumps(token_model))
|
self.write(json.dumps(token_model))
|
||||||
|
self.set_status(201)
|
||||||
|
|
||||||
|
|
||||||
class UserTokenAPIHandler(APIHandler):
|
class UserTokenAPIHandler(APIHandler):
|
||||||
|
@@ -1518,6 +1518,25 @@ class JupyterHub(Application):
|
|||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
use_legacy_stopped_server_status_code = Bool(
|
||||||
|
False,
|
||||||
|
help="""
|
||||||
|
Return 503 rather than 424 when request comes in for a non-running server.
|
||||||
|
|
||||||
|
Prior to JupyterHub 2.0, we returned a 503 when any request came in for
|
||||||
|
a user server that was currently not running. By default, JupyterHub 2.0
|
||||||
|
will return a 424 - this makes operational metric dashboards more useful.
|
||||||
|
|
||||||
|
JupyterLab < 3.2 expected the 503 to know if the user server is no longer
|
||||||
|
running, and prompted the user to start their server. Set this config to
|
||||||
|
true to retain the old behavior, so JupyterLab < 3.2 can continue to show
|
||||||
|
the appropriate UI when the user server is stopped.
|
||||||
|
|
||||||
|
This option will be removed in a future release.
|
||||||
|
""",
|
||||||
|
config=True,
|
||||||
|
)
|
||||||
|
|
||||||
def init_handlers(self):
|
def init_handlers(self):
|
||||||
h = []
|
h = []
|
||||||
# load handlers from the authenticator
|
# load handlers from the authenticator
|
||||||
|
@@ -1357,7 +1357,7 @@ class UserUrlHandler(BaseHandler):
|
|||||||
|
|
||||||
**Changed Behavior as of 1.0** This handler no longer triggers a spawn. Instead, it checks if:
|
**Changed Behavior as of 1.0** This handler no longer triggers a spawn. Instead, it checks if:
|
||||||
|
|
||||||
1. server is not active, serve page prompting for spawn (status: 503)
|
1. server is not active, serve page prompting for spawn (status: 424)
|
||||||
2. server is ready (This shouldn't happen! Proxy isn't updated yet. Wait a bit and redirect.)
|
2. server is ready (This shouldn't happen! Proxy isn't updated yet. Wait a bit and redirect.)
|
||||||
3. server is active, redirect to /hub/spawn-pending to monitor launch progress
|
3. server is active, redirect to /hub/spawn-pending to monitor launch progress
|
||||||
(will redirect back when finished)
|
(will redirect back when finished)
|
||||||
@@ -1376,7 +1376,14 @@ class UserUrlHandler(BaseHandler):
|
|||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Failing suspected API request to not-running server: %s", self.request.path
|
"Failing suspected API request to not-running server: %s", self.request.path
|
||||||
)
|
)
|
||||||
self.set_status(503)
|
|
||||||
|
# If we got here, the server is not running. To differentiate
|
||||||
|
# that the *server* itself is not running, rather than just the particular
|
||||||
|
# resource *in* the server is not found, we return a 424 instead of a 404.
|
||||||
|
# We allow retaining the old behavior to support older JupyterLab versions
|
||||||
|
self.set_status(
|
||||||
|
424 if not self.app.use_legacy_stopped_server_status_code else 503
|
||||||
|
)
|
||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
|
|
||||||
spawn_url = urlparse(self.request.full_url())._replace(query="")
|
spawn_url = urlparse(self.request.full_url())._replace(query="")
|
||||||
@@ -1541,15 +1548,17 @@ class UserUrlHandler(BaseHandler):
|
|||||||
self.redirect(pending_url, status=303)
|
self.redirect(pending_url, status=303)
|
||||||
return
|
return
|
||||||
|
|
||||||
# if we got here, the server is not running
|
# If we got here, the server is not running. To differentiate
|
||||||
# serve a page prompting for spawn and 503 error
|
# that the *server* itself is not running, rather than just the particular
|
||||||
# visiting /user/:name no longer triggers implicit spawn
|
# page *in* the server is not found, we return a 424 instead of a 404.
|
||||||
# without explicit user action
|
# We allow retaining the old behavior to support older JupyterLab versions
|
||||||
spawn_url = url_concat(
|
spawn_url = url_concat(
|
||||||
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
|
url_path_join(self.hub.base_url, "spawn", user.escaped_name, server_name),
|
||||||
{"next": self.request.uri},
|
{"next": self.request.uri},
|
||||||
)
|
)
|
||||||
self.set_status(503)
|
self.set_status(
|
||||||
|
424 if not self.app.use_legacy_stopped_server_status_code else 503
|
||||||
|
)
|
||||||
|
|
||||||
auth_state = await user.get_auth_state()
|
auth_state = await user.get_auth_state()
|
||||||
html = await self.render_template(
|
html = await self.render_template(
|
||||||
|
@@ -1366,8 +1366,8 @@ async def test_get_new_token_deprecated(app, headers, status):
|
|||||||
@mark.parametrize(
|
@mark.parametrize(
|
||||||
"headers, status, note, expires_in",
|
"headers, status, note, expires_in",
|
||||||
[
|
[
|
||||||
({}, 200, 'test note', None),
|
({}, 201, 'test note', None),
|
||||||
({}, 200, '', 100),
|
({}, 201, '', 100),
|
||||||
({'Authorization': 'token bad'}, 403, '', None),
|
({'Authorization': 'token bad'}, 403, '', None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1386,7 +1386,7 @@ async def test_get_new_token(app, headers, status, note, expires_in):
|
|||||||
app, 'users/admin/tokens', method='post', headers=headers, data=body
|
app, 'users/admin/tokens', method='post', headers=headers, data=body
|
||||||
)
|
)
|
||||||
assert r.status_code == status
|
assert r.status_code == status
|
||||||
if status != 200:
|
if status != 201:
|
||||||
return
|
return
|
||||||
# check the new-token reply
|
# check the new-token reply
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
@@ -1424,10 +1424,10 @@ async def test_get_new_token(app, headers, status, note, expires_in):
|
|||||||
@mark.parametrize(
|
@mark.parametrize(
|
||||||
"as_user, for_user, status",
|
"as_user, for_user, status",
|
||||||
[
|
[
|
||||||
('admin', 'other', 200),
|
('admin', 'other', 201),
|
||||||
('admin', 'missing', 403),
|
('admin', 'missing', 403),
|
||||||
('user', 'other', 403),
|
('user', 'other', 403),
|
||||||
('user', 'user', 200),
|
('user', 'user', 201),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_token_for_user(app, as_user, for_user, status):
|
async def test_token_for_user(app, as_user, for_user, status):
|
||||||
@@ -1448,7 +1448,7 @@ async def test_token_for_user(app, as_user, for_user, status):
|
|||||||
)
|
)
|
||||||
assert r.status_code == status
|
assert r.status_code == status
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
if status != 200:
|
if status != 201:
|
||||||
return
|
return
|
||||||
assert 'token' in reply
|
assert 'token' in reply
|
||||||
|
|
||||||
@@ -1486,7 +1486,7 @@ async def test_token_authenticator_noauth(app):
|
|||||||
data=json.dumps(data) if data else None,
|
data=json.dumps(data) if data else None,
|
||||||
noauth=True,
|
noauth=True,
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 201
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert 'token' in reply
|
assert 'token' in reply
|
||||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
||||||
@@ -1509,7 +1509,7 @@ async def test_token_authenticator_dict_noauth(app):
|
|||||||
data=json.dumps(data) if data else None,
|
data=json.dumps(data) if data else None,
|
||||||
noauth=True,
|
noauth=True,
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 201
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert 'token' in reply
|
assert 'token' in reply
|
||||||
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
r = await api_request(app, 'authorizations', 'token', reply['token'])
|
||||||
|
@@ -56,8 +56,8 @@ async def test_root_redirect(app):
|
|||||||
r = await get_page(url, app, cookies=cookies)
|
r = await get_page(url, app, cookies=cookies)
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
||||||
# serve "server not running" page, which has status 503
|
# serve "server not running" page, which has status 424
|
||||||
assert r.status_code == 503
|
assert r.status_code == 424
|
||||||
|
|
||||||
|
|
||||||
async def test_root_default_url_noauth(app):
|
async def test_root_default_url_noauth(app):
|
||||||
@@ -172,7 +172,7 @@ async def test_spawn_redirect(app):
|
|||||||
r = await get_page('user/' + name, app, hub=False, cookies=cookies)
|
r = await get_page('user/' + name, app, hub=False, cookies=cookies)
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
||||||
assert r.status_code == 503
|
assert r.status_code == 424
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_handler_access(app):
|
async def test_spawn_handler_access(app):
|
||||||
@@ -507,13 +507,13 @@ async def test_user_redirect_deprecated(app, username):
|
|||||||
print(urlparse(r.url))
|
print(urlparse(r.url))
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
|
||||||
assert r.status_code == 503
|
assert r.status_code == 424
|
||||||
|
|
||||||
r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False)
|
r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False)
|
||||||
print(urlparse(r.url))
|
print(urlparse(r.url))
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
|
||||||
assert r.status_code == 503
|
assert r.status_code == 424
|
||||||
|
|
||||||
r = await get_page('/user/baduser/test.ipynb', app, hub=False)
|
r = await get_page('/user/baduser/test.ipynb', app, hub=False)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
@@ -1061,13 +1061,20 @@ async def test_token_page(app):
|
|||||||
async def test_server_not_running_api_request(app):
|
async def test_server_not_running_api_request(app):
|
||||||
cookies = await app.login_user("bees")
|
cookies = await app.login_user("bees")
|
||||||
r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies)
|
r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies)
|
||||||
assert r.status_code == 503
|
assert r.status_code == 424
|
||||||
assert r.headers["content-type"] == "application/json"
|
assert r.headers["content-type"] == "application/json"
|
||||||
message = r.json()['message']
|
message = r.json()['message']
|
||||||
assert ujoin(app.base_url, "hub/spawn/bees") in message
|
assert ujoin(app.base_url, "hub/spawn/bees") in message
|
||||||
assert " /user/bees" in message
|
assert " /user/bees" in message
|
||||||
|
|
||||||
|
|
||||||
|
async def test_server_not_running_api_request_legacy_status(app):
|
||||||
|
app.use_legacy_stopped_server_status_code = True
|
||||||
|
cookies = await app.login_user("bees")
|
||||||
|
r = await get_page("user/bees/api/status", app, hub=False, cookies=cookies)
|
||||||
|
assert r.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
async def test_metrics_no_auth(app):
|
async def test_metrics_no_auth(app):
|
||||||
r = await get_page("metrics", app)
|
r = await get_page("metrics", app)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
@@ -661,11 +661,11 @@ async def test_load_roles_user_tokens(tmpdir, request):
|
|||||||
"headers, rolename, scopes, status",
|
"headers, rolename, scopes, status",
|
||||||
[
|
[
|
||||||
# no role requested - gets default 'token' role
|
# no role requested - gets default 'token' role
|
||||||
({}, None, None, 200),
|
({}, None, None, 201),
|
||||||
# role scopes within the user's default 'user' role
|
# role scopes within the user's default 'user' role
|
||||||
({}, 'self-reader', ['read:users'], 200),
|
({}, 'self-reader', ['read:users'], 201),
|
||||||
# role scopes outside of the user's role but within the group's role scopes of which the user is a member
|
# role scopes outside of the user's role but within the group's role scopes of which the user is a member
|
||||||
({}, 'groups-reader', ['read:groups'], 200),
|
({}, 'groups-reader', ['read:groups'], 201),
|
||||||
# non-existing role request
|
# non-existing role request
|
||||||
({}, 'non-existing', [], 404),
|
({}, 'non-existing', [], 404),
|
||||||
# role scopes outside of both user's role and group's role scopes
|
# role scopes outside of both user's role and group's role scopes
|
||||||
|
Reference in New Issue
Block a user