Skip to content

Commit d2bb39b

Browse files
committed
Add runtime check to ensure handlers have auth decorators
Also adds @ws_authenticated to make the check simpler
1 parent 204d29f commit d2bb39b

File tree

6 files changed

+114
-18
lines changed

6 files changed

+114
-18
lines changed

jupyter_server/auth/decorator.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,31 @@ def wrapper(self, *args, **kwargs):
112112
setattr(wrapper, "__allow_unauthenticated", True)
113113

114114
return cast(FuncT, wrapper)
115+
116+
117+
def ws_authenticated(method: FuncT) -> FuncT:
118+
"""A decorator for websockets derived from `WebSocketHandler`
119+
that authenticates user before allowing to proceed.
120+
121+
Differently from tornado.web.authenticated, does not redirect
122+
to the login page, which would be meaningless for websockets.
123+
124+
.. versionadded:: 2.13
125+
126+
Parameters
127+
----------
128+
method : bound callable
129+
the endpoint method to add authentication for.
130+
"""
131+
132+
@wraps(method)
133+
def wrapper(self, *args, **kwargs):
134+
user = self.current_user
135+
if user is None:
136+
self.log.warning("Couldn't authenticate WebSocket connection")
137+
raise HTTPError(403)
138+
return method(self, *args, **kwargs)
139+
140+
setattr(wrapper, "__allow_unauthenticated", False)
141+
142+
return cast(FuncT, wrapper)

jupyter_server/base/handlers.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,7 +1072,7 @@ class TrailingSlashHandler(web.RequestHandler):
10721072
This should be the first, highest priority handler.
10731073
"""
10741074

1075-
# does not require `allow_unauthenticated` (inherits from `web.RequestHandler`)
1075+
@allow_unauthenticated
10761076
def get(self) -> None:
10771077
"""Handle trailing slashes in a get."""
10781078
assert self.request.uri is not None
@@ -1136,14 +1136,14 @@ async def get(self, path: str = "") -> None:
11361136

11371137

11381138
class RedirectWithParams(web.RequestHandler):
1139-
"""Sam as web.RedirectHandler, but preserves URL parameters"""
1139+
"""Same as web.RedirectHandler, but preserves URL parameters"""
11401140

11411141
def initialize(self, url: str, permanent: bool = True) -> None:
11421142
"""Initialize a redirect handler."""
11431143
self._url = url
11441144
self._permanent = permanent
11451145

1146-
# does not require `allow_unauthenticated` (inherits from `web.RequestHandler`)
1146+
@allow_unauthenticated
11471147
def get(self) -> None:
11481148
"""Get a redirect."""
11491149
sep = "&" if "?" in self._url else "?"
@@ -1166,6 +1166,18 @@ def get(self) -> None:
11661166
self.write(prometheus_client.generate_latest(prometheus_client.REGISTRY))
11671167

11681168

1169+
class PublicStaticFileHandler(web.StaticFileHandler):
1170+
"""Same as web.StaticFileHandler, but decorated to acknowledge that auth is not required."""
1171+
1172+
@allow_unauthenticated
1173+
def head(self, *args, **kwargs) -> None:
1174+
return super().head(*args, **kwargs)
1175+
1176+
@allow_unauthenticated
1177+
def get(self, *args, **kwargs) -> None:
1178+
return super().get(*args, **kwargs)
1179+
1180+
11691181
# -----------------------------------------------------------------------------
11701182
# URL pattern fragments for reuse
11711183
# -----------------------------------------------------------------------------
@@ -1181,6 +1193,6 @@ def get(self) -> None:
11811193
default_handlers = [
11821194
(r".*/", TrailingSlashHandler),
11831195
(r"api", APIVersionHandler),
1184-
(r"/(robots\.txt|favicon\.ico)", web.StaticFileHandler),
1196+
(r"/(robots\.txt|favicon\.ico)", PublicStaticFileHandler),
11851197
(r"/metrics", PrometheusMetricsHandler),
11861198
]

jupyter_server/serverapp.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from tornado.httputil import url_concat
4242
from tornado.log import LogFormatter, access_log, app_log, gen_log
4343
from tornado.netutil import bind_sockets
44+
from tornado.routing import Matcher, Rule
4445

4546
if not sys.platform.startswith("win"):
4647
from tornado.netutil import bind_unix_socket
@@ -280,8 +281,52 @@ def __init__(
280281
)
281282
handlers = self.init_handlers(default_services, settings)
282283

284+
undecorated_methods = []
285+
for matcher, handler, *_ in handlers:
286+
undecorated_methods.extend(self._check_handler_auth(matcher, handler))
287+
288+
if undecorated_methods:
289+
message = (
290+
"Core endpoints without @allow_unauthenticated, @ws_authenticated, nor @web.authenticated:\n"
291+
+ "\n".join(undecorated_methods)
292+
)
293+
if jupyter_app.allow_unauthenticated_access:
294+
warnings.warn(
295+
message,
296+
RuntimeWarning,
297+
stacklevel=2,
298+
)
299+
else:
300+
raise Exception(message)
301+
283302
super().__init__(handlers, **settings)
284303

304+
def add_handlers(self, host_pattern, host_handlers):
305+
undecorated_methods = []
306+
for rule in host_handlers:
307+
if isinstance(rule, Rule):
308+
matcher = rule.matcher
309+
handler = rule.target
310+
else:
311+
matcher, handler, *_ = rule
312+
undecorated_methods.extend(self._check_handler_auth(matcher, handler))
313+
314+
if undecorated_methods:
315+
message = (
316+
"Extension endpoints without @allow_unauthenticated, @ws_authenticated, nor @web.authenticated:\n"
317+
+ "\n".join(undecorated_methods)
318+
)
319+
if self.settings["allow_unauthenticated_access"]:
320+
warnings.warn(
321+
message,
322+
RuntimeWarning,
323+
stacklevel=2,
324+
)
325+
else:
326+
raise Exception(message)
327+
328+
return super().add_handlers(host_pattern, host_handlers)
329+
285330
def init_settings(
286331
self,
287332
jupyter_app,
@@ -487,6 +532,21 @@ def last_activity(self):
487532
sources.extend(self.settings["last_activity_times"].values())
488533
return max(sources)
489534

535+
def _check_handler_auth(self, matcher: t.Union[str, Matcher], handler: web.RequestHandler):
536+
missing_authentication = []
537+
for method_name in handler.SUPPORTED_METHODS:
538+
method = getattr(handler, method_name.lower())
539+
is_unimplemented = method == web.RequestHandler._unimplemented_method
540+
is_allowlisted = hasattr(method, "__allow_unauthenticated")
541+
possibly_blocklisted = hasattr(
542+
method, "__wrapped__"
543+
) # TODO: can we make web.auth leave a better footprint?
544+
if not is_unimplemented and not is_allowlisted and not possibly_blocklisted:
545+
missing_authentication.append(
546+
f"- {method_name} of {handler.__class__.__name__} registered for {matcher}"
547+
)
548+
return missing_authentication
549+
490550

491551
class JupyterPasswordApp(JupyterApp):
492552
"""Set a password for the Jupyter server.

jupyter_server/services/api/handlers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ def initialize(self):
2525
"""Initialize the API spec handler."""
2626
web.StaticFileHandler.initialize(self, path=os.path.dirname(__file__))
2727

28+
@web.authenticated
29+
@authorized
30+
def head(self):
31+
return self.get("api.yaml", include_body=False)
32+
2833
@web.authenticated
2934
@authorized
3035
def get(self):

jupyter_server/services/events/handlers.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from jupyter_core.utils import ensure_async
1313
from tornado import web, websocket
1414

15-
from jupyter_server.auth.decorator import authorized
15+
from jupyter_server.auth.decorator import authorized, ws_authenticated
1616
from jupyter_server.base.handlers import JupyterHandler
1717

1818
from ...base.handlers import APIHandler
@@ -29,23 +29,18 @@ class SubscribeWebsocket(
2929
auth_resource = AUTH_RESOURCE
3030

3131
async def pre_get(self):
32-
"""Handles authentication/authorization when
32+
"""Handles authorization when
3333
attempting to subscribe to events emitted by
3434
Jupyter Server's eventbus.
3535
"""
36-
# authenticate the request before opening the websocket
37-
user = self.current_user
38-
if user is None:
39-
self.log.warning("Couldn't authenticate WebSocket connection")
40-
raise web.HTTPError(403)
41-
4236
# authorize the user.
4337
authorized = await ensure_async(
4438
self.authorizer.is_authorized(self, user, "execute", "events")
4539
)
4640
if not authorized:
4741
raise web.HTTPError(403)
4842

43+
@ws_authenticated
4944
async def get(self, *args, **kwargs):
5045
"""Get an event socket."""
5146
await ensure_async(self.pre_get())

jupyter_server/services/kernels/websocket.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from tornado import web
77
from tornado.websocket import WebSocketHandler
88

9+
from jupyter_server.auth.decorator import ws_authenticated
910
from jupyter_server.base.handlers import JupyterHandler
1011
from jupyter_server.base.websocket import WebSocketMixin
1112

@@ -34,12 +35,6 @@ def get_compression_options(self):
3435

3536
async def pre_get(self):
3637
"""Handle a pre_get."""
37-
# authenticate first
38-
user = self.current_user
39-
if user is None:
40-
self.log.warning("Couldn't authenticate WebSocket connection")
41-
raise web.HTTPError(403)
42-
4338
# authorize the user.
4439
authorized = await ensure_async(
4540
self.authorizer.is_authorized(self, user, "execute", "kernels")
@@ -61,6 +56,7 @@ async def pre_get(self):
6156
if hasattr(self.connection, "prepare"):
6257
await self.connection.prepare()
6358

59+
@ws_authenticated
6460
async def get(self, kernel_id):
6561
"""Handle a get request for a kernel."""
6662
self.kernel_id = kernel_id

0 commit comments

Comments
 (0)