Skip to content

Commit 4ad12f2

Browse files
krassowskibollwyvl
andauthored
Merge pull request from GHSA-4qhp-652w-c22x
* Add auth decorators, add traversal guard * Fix mocks resolving most test failures; `test_listeners` still fails not sure how to fix it * Address review comments * add tests for (un)authn'd REST and WebSocket handlers * Restore old import for 1.x compat, remove a log * handle advertised jupyter-server 1.x version * Lint (isort any mypy) * More tests for paths --------- Co-authored-by: Nicholas Bollweg <[email protected]>
1 parent 1f1ddca commit 4ad12f2

File tree

10 files changed

+260
-14
lines changed

10 files changed

+260
-14
lines changed

python_packages/jupyter_lsp/jupyter_lsp/handlers.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,32 @@
22
"""
33
from typing import Optional, Text
44

5+
from jupyter_core.utils import ensure_async
56
from jupyter_server.base.handlers import APIHandler
6-
from jupyter_server.base.zmqhandlers import WebSocketHandler, WebSocketMixin
77
from jupyter_server.utils import url_path_join as ujoin
8+
from tornado import web
9+
from tornado.websocket import WebSocketHandler
10+
11+
try:
12+
from jupyter_server.auth.decorator import authorized
13+
except ImportError:
14+
15+
def authorized(method): # type: ignore
16+
"""A no-op fallback for `jupyter_server 1.x`"""
17+
return method
18+
19+
20+
try:
21+
from jupyter_server.base.websocket import WebSocketMixin
22+
except ImportError:
23+
from jupyter_server.base.zmqhandlers import WebSocketMixin
824

925
from .manager import LanguageServerManager
1026
from .schema import SERVERS_RESPONSE
1127
from .specs.utils import censored_spec
1228

29+
AUTH_RESOURCE = "lsp"
30+
1331

1432
class BaseHandler(APIHandler):
1533
manager = None # type: LanguageServerManager
@@ -21,10 +39,43 @@ def initialize(self, manager: LanguageServerManager):
2139
class LanguageServerWebSocketHandler( # type: ignore
2240
WebSocketMixin, WebSocketHandler, BaseHandler
2341
):
24-
"""Setup tornado websocket to route to language server sessions"""
42+
"""Setup tornado websocket to route to language server sessions.
43+
44+
The logic of `get` and `pre_get` methods is derived from jupyter-server ws handlers,
45+
and should be kept in sync to follow best practice established by upstream; see:
46+
https://github.com/jupyter-server/jupyter_server/blob/v2.12.5/jupyter_server/services/kernels/websocket.py#L36
47+
"""
48+
49+
auth_resource = AUTH_RESOURCE
2550

2651
language_server: Optional[Text] = None
2752

53+
async def pre_get(self):
54+
"""Handle a pre_get."""
55+
# authenticate first
56+
# authenticate the request before opening the websocket
57+
user = self.current_user
58+
if user is None:
59+
self.log.warning("Couldn't authenticate WebSocket connection")
60+
raise web.HTTPError(403)
61+
62+
if not hasattr(self, "authorizer"):
63+
return
64+
65+
# authorize the user.
66+
is_authorized = await ensure_async(
67+
self.authorizer.is_authorized(self, user, "execute", AUTH_RESOURCE)
68+
)
69+
if not is_authorized:
70+
raise web.HTTPError(403)
71+
72+
async def get(self, *args, **kwargs):
73+
"""Get an event socket."""
74+
await self.pre_get()
75+
res = super().get(*args, **kwargs)
76+
if res is not None:
77+
await res
78+
2879
async def open(self, language_server):
2980
await self.manager.ready()
3081
self.language_server = language_server
@@ -47,11 +98,11 @@ class LanguageServersHandler(BaseHandler):
4798
Response should conform to schema in schema/servers.schema.json
4899
"""
49100

101+
auth_resource = AUTH_RESOURCE
50102
validator = SERVERS_RESPONSE
51103

52-
def initialize(self, *args, **kwargs):
53-
super().initialize(*args, **kwargs)
54-
104+
@web.authenticated
105+
@authorized
55106
async def get(self):
56107
"""finish with the JSON representations of the sessions"""
57108
await self.manager.ready()

python_packages/jupyter_lsp/jupyter_lsp/paths.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
2-
import pathlib
32
import re
3+
from pathlib import Path
4+
from typing import Union
45
from urllib.parse import unquote, urlparse
56

67
RE_PATH_ANCHOR = r"^file://([^/]+|/[A-Z]:)"
@@ -12,7 +13,7 @@ def normalized_uri(root_dir):
1213
Special care must be taken around windows paths: the canonical form of
1314
windows drives and UNC paths is lower case
1415
"""
15-
root_uri = pathlib.Path(root_dir).expanduser().resolve().as_uri()
16+
root_uri = Path(root_dir).expanduser().resolve().as_uri()
1617
root_uri = re.sub(
1718
RE_PATH_ANCHOR, lambda m: "file://{}".format(m.group(1).lower()), root_uri
1819
)
@@ -33,3 +34,12 @@ def file_uri_to_path(file_uri):
3334
else:
3435
result = file_uri_path_unquoted # pragma: no cover
3536
return result
37+
38+
39+
def is_relative(root: Union[str, Path], path: Union[str, Path]) -> bool:
40+
"""Return if path is relative to root"""
41+
try:
42+
Path(path).resolve().relative_to(Path(root).resolve())
43+
return True
44+
except ValueError:
45+
return False

python_packages/jupyter_lsp/jupyter_lsp/serverextension.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55

66
import traitlets
7+
from tornado import ioloop
78

89
from .handlers import add_handlers
910
from .manager import LanguageServerManager
@@ -73,4 +74,11 @@ def load_jupyter_server_extension(nbapp):
7374
page_config.update(rootUri=root_uri, virtualDocumentsUri=virtual_documents_uri)
7475

7576
add_handlers(nbapp)
76-
nbapp.io_loop.call_later(0, initialize, nbapp, virtual_documents_uri)
77+
78+
if hasattr(nbapp, "io_loop"):
79+
io_loop = nbapp.io_loop
80+
else:
81+
# handle jupyter_server 1.x
82+
io_loop = ioloop.IOLoop.current()
83+
84+
io_loop.call_later(0, initialize, nbapp, virtual_documents_uri)

python_packages/jupyter_lsp/jupyter_lsp/tests/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from jupyter_server.serverapp import ServerApp
77
from pytest import fixture
8+
from tornado.httpserver import HTTPRequest
89
from tornado.httputil import HTTPServerRequest
910
from tornado.queues import Queue
1011
from tornado.web import Application
@@ -141,9 +142,11 @@ def send_ping(self):
141142

142143
class MockHandler(LanguageServersHandler):
143144
_payload = None
145+
_jupyter_current_user = "foo" # type:ignore[assignment]
144146

145147
def __init__(self):
146-
pass
148+
self.request = HTTPRequest("GET")
149+
self.application = Application()
147150

148151
def finish(self, payload):
149152
self._payload = payload
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Integration tests of authorization running under jupyter-server."""
2+
import json
3+
import os
4+
import socket
5+
import subprocess
6+
import time
7+
import uuid
8+
from typing import Generator, Tuple
9+
from urllib.error import HTTPError, URLError
10+
from urllib.request import urlopen
11+
12+
import pytest
13+
14+
from .conftest import KNOWN_SERVERS
15+
16+
LOCALHOST = "127.0.0.1"
17+
REST_ROUTES = ["/lsp/status"]
18+
WS_ROUTES = [f"/lsp/ws/{ls}" for ls in KNOWN_SERVERS]
19+
20+
21+
@pytest.mark.parametrize("route", REST_ROUTES)
22+
def test_auth_rest(route: str, a_server_url_and_token: Tuple[str, str]) -> None:
23+
"""Verify a REST route only provides access to an authenticated user."""
24+
base_url, token = a_server_url_and_token
25+
26+
verify_response(base_url, route)
27+
28+
url = f"{base_url}{route}"
29+
30+
with urlopen(f"{url}?token={token}") as response:
31+
raw_body = response.read().decode("utf-8")
32+
33+
decode_error = None
34+
35+
try:
36+
json.loads(raw_body)
37+
except json.decoder.JSONDecodeError as err:
38+
decode_error = err
39+
assert not decode_error, f"the response for {url} was not JSON"
40+
41+
42+
@pytest.mark.parametrize("route", WS_ROUTES)
43+
def test_auth_websocket(route: str, a_server_url_and_token: Tuple[str, str]) -> None:
44+
"""Verify a WebSocket does not provide access to an unauthenticated user."""
45+
verify_response(a_server_url_and_token[0], route)
46+
47+
48+
@pytest.fixture(scope="module")
49+
def a_server_url_and_token(
50+
tmp_path_factory: pytest.TempPathFactory,
51+
) -> Generator[Tuple[str, str], None, None]:
52+
"""Start a temporary, isolated jupyter server."""
53+
token = str(uuid.uuid4())
54+
port = get_unused_port()
55+
56+
root_dir = tmp_path_factory.mktemp("root_dir")
57+
home = tmp_path_factory.mktemp("home")
58+
server_conf = home / "etc/jupyter/jupyter_config.json"
59+
60+
server_conf.parent.mkdir(parents=True)
61+
extensions = {"jupyter_lsp": True, "jupyterlab": False, "nbclassic": False}
62+
app = {"jpserver_extensions": extensions, "token": token}
63+
config_data = {"ServerApp": app, "IdentityProvider": {"token": token}}
64+
65+
server_conf.write_text(json.dumps(config_data), encoding="utf-8")
66+
args = ["jupyter-server", f"--port={port}", "--no-browser"]
67+
env = dict(os.environ)
68+
env.update(
69+
HOME=str(home),
70+
USERPROFILE=str(home),
71+
JUPYTER_CONFIG_DIR=str(server_conf.parent),
72+
)
73+
proc = subprocess.Popen(args, cwd=str(root_dir), env=env, stdin=subprocess.PIPE)
74+
url = f"http://{LOCALHOST}:{port}"
75+
retries = 20
76+
while retries:
77+
time.sleep(1)
78+
try:
79+
urlopen(f"{url}/favicon.ico")
80+
break
81+
except URLError:
82+
print(f"[{retries} / 20] ...", flush=True)
83+
retries -= 1
84+
continue
85+
yield url, token
86+
proc.terminate()
87+
proc.communicate(b"y\n")
88+
proc.wait()
89+
assert proc.returncode is not None, "jupyter-server probably still running"
90+
91+
92+
def get_unused_port():
93+
"""Get an unused port by trying to listen to any random port.
94+
95+
Probably could introduce race conditions if inside a tight loop.
96+
"""
97+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
98+
sock.bind((LOCALHOST, 0))
99+
sock.listen(1)
100+
port = sock.getsockname()[1]
101+
sock.close()
102+
return port
103+
104+
105+
def verify_response(base_url: str, route: str, expect: int = 403):
106+
"""Verify that a response returns the expected error."""
107+
error = None
108+
body = None
109+
url = f"{base_url}{route}"
110+
try:
111+
with urlopen(url) as res:
112+
body = res.read()
113+
except HTTPError as err:
114+
error = err
115+
assert error, f"no HTTP error for {url}: {body}"
116+
http_code = error.getcode()
117+
assert http_code == expect, f"{url} HTTP code was unexpected: {body}"

python_packages/jupyter_lsp/jupyter_lsp/tests/test_paths.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from ..paths import file_uri_to_path, normalized_uri
7+
from ..paths import file_uri_to_path, is_relative, normalized_uri
88

99
WIN = platform.system() == "Windows"
1010
HOME = pathlib.Path("~").expanduser()
@@ -17,6 +17,45 @@ def test_normalize_posix_path_home(root_dir, expected_root_uri): # pragma: no c
1717
assert normalized_uri(root_dir) == expected_root_uri
1818

1919

20+
@pytest.mark.skipif(WIN, reason="can't test POSIX paths on Windows")
21+
@pytest.mark.parametrize(
22+
"root, path",
23+
[["~", "~/a"], ["~", "~/a/../b/"], ["/", "/"], ["/a", "/a/b"], ["/a", "/a/b/../c"]],
24+
)
25+
def test_is_relative_ok(root, path):
26+
assert is_relative(root, path)
27+
28+
29+
@pytest.mark.skipif(WIN, reason="can't test POSIX paths on Windows")
30+
@pytest.mark.parametrize(
31+
"root, path",
32+
[
33+
["~", "~/.."],
34+
["~", "/"],
35+
["/a", "/"],
36+
["/a/b", "/a"],
37+
["/a/b", "/a/b/.."],
38+
["/a", "/a/../b"],
39+
["/a", "a//"],
40+
],
41+
)
42+
def test_is_relative_not_ok(root, path):
43+
assert not is_relative(root, path)
44+
45+
46+
@pytest.mark.skipif(not WIN, reason="can't test Windows paths on POSIX")
47+
@pytest.mark.parametrize(
48+
"root, path",
49+
[
50+
["c:\\Users\\user1", "c:\\Users\\"],
51+
["c:\\Users\\user1", "d:\\"],
52+
["c:\\Users", "c:\\Users\\.."],
53+
],
54+
)
55+
def test_is_relative_not_ok_win(root, path):
56+
assert not is_relative(root, path)
57+
58+
2059
@pytest.mark.skipif(PY35, reason="can't test non-existent paths on py35")
2160
@pytest.mark.skipif(WIN, reason="can't test POSIX paths on Windows")
2261
@pytest.mark.parametrize(

python_packages/jupyter_lsp/jupyter_lsp/tests/test_virtual_documents_shadow.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,21 @@ def run_shadow(message):
210210
)
211211

212212

213+
@pytest.mark.asyncio
214+
async def test_shadow_traversal(shadow_path, manager):
215+
file_beyond_shadow_root_uri = (Path(shadow_path) / ".." / "test.py").as_uri()
216+
217+
shadow = setup_shadow_filesystem(Path(shadow_path).as_uri())
218+
219+
def run_shadow(message):
220+
return shadow("client", message, "python-lsp-server", manager)
221+
222+
with pytest.raises(
223+
ShadowFilesystemError, match="is not relative to shadow filesystem root"
224+
):
225+
await run_shadow(did_open(file_beyond_shadow_root_uri, "content"))
226+
227+
213228
@pytest.fixture
214229
def forbidden_shadow_path(tmpdir):
215230
path = Path(tmpdir) / "no_permission_dir"
@@ -238,7 +253,7 @@ def send_change():
238253
# no message should be emitted during the first two attempts
239254
assert caplog.text == ""
240255

241-
# a wargning should be emitted on third failure
256+
# a warning should be emitted on third failure
242257
with caplog.at_level(logging.WARNING):
243258
assert await send_change() is None
244259
assert "initialization of shadow filesystem failed three times" in caplog.text

python_packages/jupyter_lsp/jupyter_lsp/virtual_documents_shadow.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from tornado.gen import convert_yielded
99

1010
from .manager import lsp_message_listener
11-
from .paths import file_uri_to_path
11+
from .paths import file_uri_to_path, is_relative
1212
from .types import LanguageServerManagerAPI
1313

1414
# TODO: make configurable
@@ -171,6 +171,11 @@ async def shadow_virtual_documents(scope, message, language_server, manager):
171171
initialized = True
172172

173173
path = file_uri_to_path(uri)
174+
if not is_relative(shadow_filesystem, path):
175+
raise ShadowFilesystemError(
176+
f"Path {path} is not relative to shadow filesystem root"
177+
)
178+
174179
editable_file = EditableFile(path)
175180

176181
await editable_file.read()

requirements/utest.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,3 @@ pytest-cov
66
pytest-flake8
77
pytest-runner
88
python-lsp-server
9-
pluggy<1.0,>=0.12 # Python 3.5 CI Travis, may need update with new pytest releases, see issue 259

0 commit comments

Comments
 (0)