Skip to content

Commit 85e4abc

Browse files
authored
Merge pull request from GHSA-9f66-54xg-pc2c
sync _redirect_safe with upstream
2 parents b328e0a + 20c84e8 commit 85e4abc

File tree

2 files changed

+108
-6
lines changed

2 files changed

+108
-6
lines changed

jupyter_server/auth/login.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,19 @@ def _redirect_safe(self, url, default=None):
3636
"""
3737
if default is None:
3838
default = self.base_url
39-
if not url.startswith(self.base_url):
39+
# protect chrome users from mishandling unescaped backslashes.
40+
# \ is not valid in urls, but some browsers treat it as /
41+
# instead of %5C, causing `\\` to behave as `//`
42+
url = url.replace("\\", "%5C")
43+
parsed = urlparse(url)
44+
if parsed.netloc or not (parsed.path + "/").startswith(self.base_url):
4045
# require that next_url be absolute path within our path
4146
allow = False
4247
# OR pass our cross-origin check
43-
if '://' in url:
48+
if parsed.netloc:
4449
# if full URL, run our cross-origin check:
45-
parsed = urlparse(url.lower())
4650
origin = '%s://%s' % (parsed.scheme, parsed.netloc)
51+
origin = origin.lower()
4752
if self.allow_origin:
4853
allow = self.allow_origin == origin
4954
elif self.allow_origin_pat:
@@ -77,9 +82,11 @@ def post(self):
7782
self.set_login_cookie(self, uuid.uuid4().hex)
7883
elif self.token and self.token == typed_password:
7984
self.set_login_cookie(self, uuid.uuid4().hex)
80-
if new_password and self.settings.get('allow_password_change'):
81-
config_dir = self.settings.get('config_dir')
82-
config_file = os.path.join(config_dir, 'jupyter_server_config.json')
85+
if new_password and self.settings.get("allow_password_change"):
86+
config_dir = self.settings.get("config_dir")
87+
config_file = os.path.join(
88+
config_dir, "jupyter_notebook_config.json"
89+
)
8390
set_password(new_password, config_file=config_file)
8491
self.log.info("Wrote hashed password to %s" % config_file)
8592
else:

tests/auth/test_login.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Tests for login redirects"""
2+
3+
from functools import partial
4+
from urllib.parse import urlencode
5+
6+
import pytest
7+
from tornado.httpclient import HTTPClientError
8+
from tornado.httputil import url_concat, parse_cookie
9+
10+
from jupyter_server.utils import url_path_join
11+
12+
13+
# override default config to ensure a non-empty base url is used
14+
@pytest.fixture
15+
def jp_base_url():
16+
return "/a%40b/"
17+
18+
19+
@pytest.fixture
20+
def jp_server_config(jp_base_url):
21+
return {
22+
"ServerApp": {
23+
"base_url": jp_base_url,
24+
},
25+
}
26+
27+
28+
async def _login(jp_serverapp, http_server_client, jp_base_url, next):
29+
# first: request login page with no creds
30+
login_url = url_path_join(jp_base_url, "login")
31+
first = await http_server_client.fetch(login_url)
32+
cookie_header = first.headers["Set-Cookie"]
33+
cookies = parse_cookie(cookie_header)
34+
35+
# second, submit login form with credentials
36+
try:
37+
resp = await http_server_client.fetch(
38+
url_concat(login_url, {"next": next}),
39+
method="POST",
40+
body=urlencode(
41+
{
42+
"password": jp_serverapp.token,
43+
"_xsrf": cookies.get("_xsrf", ""),
44+
}
45+
),
46+
headers={"Cookie": cookie_header},
47+
follow_redirects=False,
48+
)
49+
except HTTPClientError as e:
50+
if e.code != 302:
51+
raise
52+
return e.response.headers["Location"]
53+
else:
54+
assert resp.code == 302, "Should have returned a redirect!"
55+
56+
57+
@pytest.fixture
58+
def login(jp_serverapp, http_server_client, jp_base_url):
59+
"""Fixture to return a function to login to a Jupyter server
60+
61+
by submitting the login page form
62+
"""
63+
yield partial(_login, jp_serverapp, http_server_client, jp_base_url)
64+
65+
66+
@pytest.mark.parametrize(
67+
"bad_next",
68+
(
69+
r"\\tree",
70+
"//some-host",
71+
"//host{base_url}tree",
72+
"https://google.com",
73+
"/absolute/not/base_url",
74+
),
75+
)
76+
async def test_next_bad(login, jp_base_url, bad_next):
77+
bad_next = bad_next.format(base_url=jp_base_url)
78+
url = await login(bad_next)
79+
assert url == jp_base_url
80+
81+
82+
@pytest.mark.parametrize(
83+
"next_path",
84+
(
85+
"tree/",
86+
"//{base_url}tree",
87+
"notebooks/notebook.ipynb",
88+
"tree//something",
89+
),
90+
)
91+
async def test_next_ok(login, jp_base_url, next_path):
92+
next_path = next_path.format(base_url=jp_base_url)
93+
expected = jp_base_url + next_path
94+
actual = await login(next=expected)
95+
assert actual == expected

0 commit comments

Comments
 (0)