From 095025288e391320f773eea4629093e0f97a6ec2 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Wed, 22 May 2024 23:33:42 +0100 Subject: [PATCH 1/4] Escape invalid host when displayed in html --- jupyter_server_proxy/handlers.py | 3 ++- tests/test_proxies.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/jupyter_server_proxy/handlers.py b/jupyter_server_proxy/handlers.py index 272e7c9c..eb33be83 100644 --- a/jupyter_server_proxy/handlers.py +++ b/jupyter_server_proxy/handlers.py @@ -16,6 +16,7 @@ from jupyter_server.utils import ensure_async, url_path_join from simpervisor import SupervisedProcess from tornado import httpclient, httputil, web +from tornado.escape import xhtml_escape from tornado.simple_httpclient import SimpleAsyncHTTPClient from traitlets import Bytes, Dict, Instance, Integer, Unicode, Union, default, observe from traitlets.traitlets import HasTraits @@ -327,7 +328,7 @@ async def proxy(self, host, port, proxied_path): self.write( "Host '{host}' is not allowed. " "See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.".format( - host=host + host=xhtml_escape(host) ) ) return diff --git a/tests/test_proxies.py b/tests/test_proxies.py index ef320e28..b3ec5912 100644 --- a/tests/test_proxies.py +++ b/tests/test_proxies.py @@ -255,6 +255,14 @@ def test_server_proxy_host_absolute(a_server_port_and_token: Tuple[int, str]) -> assert "X-Proxycontextpath" not in s +def test_server_proxy_host_invalid(a_server_port_and_token: Tuple[int, str]) -> None: + PORT, TOKEN = a_server_port_and_token + r = request_get(PORT, "/proxy/absolute/:54321/", TOKEN) + assert r.code == 403 + s = r.read().decode("ascii") + assert s.startswith("Host '<invalid>' is not allowed.") + + def test_server_proxy_port_non_service_rewrite_response( a_server_port_and_token: Tuple[int, str] ) -> None: From 652849c985f66b5f17a05d6f76065a9c04bd75d6 Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 26 May 2024 22:43:53 +0100 Subject: [PATCH 2/4] Use html.escape --- jupyter_server_proxy/handlers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/jupyter_server_proxy/handlers.py b/jupyter_server_proxy/handlers.py index eb33be83..638e9a6a 100644 --- a/jupyter_server_proxy/handlers.py +++ b/jupyter_server_proxy/handlers.py @@ -8,6 +8,7 @@ import socket from asyncio import Lock from copy import copy +from html import escape from tempfile import mkdtemp from urllib.parse import quote, urlparse, urlunparse @@ -16,7 +17,6 @@ from jupyter_server.utils import ensure_async, url_path_join from simpervisor import SupervisedProcess from tornado import httpclient, httputil, web -from tornado.escape import xhtml_escape from tornado.simple_httpclient import SimpleAsyncHTTPClient from traitlets import Bytes, Dict, Instance, Integer, Unicode, Union, default, observe from traitlets.traitlets import HasTraits @@ -328,7 +328,7 @@ async def proxy(self, host, port, proxied_path): self.write( "Host '{host}' is not allowed. " "See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.".format( - host=xhtml_escape(host) + host=escape(host) ) ) return @@ -393,7 +393,7 @@ async def proxy(self, host, port, proxied_path): if err.code == 599: self._record_activity() self.set_status(599) - self.write(str(err)) + self.write(escape(str(err))) return else: raise @@ -404,7 +404,7 @@ async def proxy(self, host, port, proxied_path): # For all non http errors... if response.error and type(response.error) is not httpclient.HTTPError: self.set_status(500) - self.write(str(response.error)) + self.write(escape(str(response.error))) else: # Represent the original response as a RewritableResponse object. original_response = RewritableResponse(orig_response=response) From 390994923a281de106eedcd325a8ee7f51adcc3d Mon Sep 17 00:00:00 2001 From: Simon Li Date: Wed, 29 May 2024 22:59:42 +0100 Subject: [PATCH 3/4] Use web.HTTPError to safely return errors to browser --- jupyter_server_proxy/handlers.py | 19 ++++++------------- tests/test_proxies.py | 9 ++++++--- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/jupyter_server_proxy/handlers.py b/jupyter_server_proxy/handlers.py index 638e9a6a..2d65150d 100644 --- a/jupyter_server_proxy/handlers.py +++ b/jupyter_server_proxy/handlers.py @@ -8,7 +8,6 @@ import socket from asyncio import Lock from copy import copy -from html import escape from tempfile import mkdtemp from urllib.parse import quote, urlparse, urlunparse @@ -324,14 +323,11 @@ async def proxy(self, host, port, proxied_path): """ if not self._check_host_allowlist(host): - self.set_status(403) - self.write( - "Host '{host}' is not allowed. " - "See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.".format( - host=escape(host) - ) + raise web.HTTPError( + 403, + f"Host '{host}' is not allowed. " + "See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.", ) - return # Remove hop-by-hop headers that don't necessarily apply to the request we are making # to the backend. See https://github.com/jupyterhub/jupyter-server-proxy/pull/328 @@ -392,9 +388,7 @@ async def proxy(self, host, port, proxied_path): # Ref: https://www.tornadoweb.org/en/stable/httpclient.html#tornado.httpclient.AsyncHTTPClient.fetch if err.code == 599: self._record_activity() - self.set_status(599) - self.write(escape(str(err))) - return + raise web.HTTPError(599, str(err)) else: raise @@ -403,8 +397,7 @@ async def proxy(self, host, port, proxied_path): # For all non http errors... if response.error and type(response.error) is not httpclient.HTTPError: - self.set_status(500) - self.write(escape(str(response.error))) + raise web.HTTPError(500, str(response.error)) else: # Represent the original response as a RewritableResponse object. original_response = RewritableResponse(orig_response=response) diff --git a/tests/test_proxies.py b/tests/test_proxies.py index b3ec5912..8c2de5a0 100644 --- a/tests/test_proxies.py +++ b/tests/test_proxies.py @@ -255,12 +255,15 @@ def test_server_proxy_host_absolute(a_server_port_and_token: Tuple[int, str]) -> assert "X-Proxycontextpath" not in s -def test_server_proxy_host_invalid(a_server_port_and_token: Tuple[int, str]) -> None: +@pytest.mark.parametrize("absolute", ["", "/absolute"]) +def test_server_proxy_host_invalid( + a_server_port_and_token: Tuple[int, str], absolute: str +) -> None: PORT, TOKEN = a_server_port_and_token - r = request_get(PORT, "/proxy/absolute/:54321/", TOKEN) + r = request_get(PORT, f"/proxy{absolute}/:54321/", TOKEN) assert r.code == 403 s = r.read().decode("ascii") - assert s.startswith("Host '<invalid>' is not allowed.") + assert "Host '<invalid>' is not allowed." in s def test_server_proxy_port_non_service_rewrite_response( From 2fa8c26bb3a5aa94b2808d9bb463a91a72cda06d Mon Sep 17 00:00:00 2001 From: Simon Li Date: Sun, 9 Jun 2024 23:34:33 +0100 Subject: [PATCH 4/4] Update 4.2.0 changelog --- docs/source/changelog.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.md b/docs/source/changelog.md index d669a1f0..3a37ce9f 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -2,7 +2,10 @@ ## 4.2 -### v4.2.0 - 2024-06-DD +### v4.2.0 - 2024-06-11 + +This release includes an important security patch for +[CVE-2024-35225 ](https://github.com/jupyterhub/jupyter-server-proxy/security/advisories/GHSA-fvcq-4x64-hqxr). ([full changelog](https://github.com/jupyterhub/jupyter-server-proxy/compare/v4.1.2...v4.2.0))