Skip to content

Commit ae9ec09

Browse files
committed
validate proxied paths starting with /
- restrict regex to `(/.*|)` to require starting with `/` or being empty string - double-safe ensure proxied path starts with / in get_client_uri
1 parent ffdb4f3 commit ae9ec09

File tree

2 files changed

+75
-48
lines changed

2 files changed

+75
-48
lines changed

jupyter_server_proxy/handlers.py

Lines changed: 57 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,16 @@ def _get_context_path(self, host, port):
209209
return url_path_join(self.base_url, 'proxy', host_and_port)
210210

211211
def get_client_uri(self, protocol, host, port, proxied_path):
212-
context_path = self._get_context_path(host, port)
213212
if self.absolute_url:
213+
context_path = self._get_context_path(host, port)
214214
client_path = url_path_join(context_path, proxied_path)
215215
else:
216216
client_path = proxied_path
217217

218+
# ensure client_path always starts with '/'
219+
if not client_path.startswith("/"):
220+
client_path = "/" + client_path
221+
218222
# Quote spaces, åäö and such, but only enough to send a valid web
219223
# request onwards. To do this, we mark the RFC 3986 specs' "reserved"
220224
# and "un-reserved" characters as safe that won't need quoting. The
@@ -228,7 +232,7 @@ def get_client_uri(self, protocol, host, port, proxied_path):
228232
protocol=protocol,
229233
host=host,
230234
port=port,
231-
path=client_path
235+
path=client_path,
232236
)
233237
if self.request.query:
234238
client_uri += '?' + self.request.query
@@ -297,13 +301,14 @@ async def proxy(self, host, port, proxied_path):
297301
client = httpclient.AsyncHTTPClient()
298302

299303
req = self._build_proxy_request(host, port, proxied_path, body)
304+
self.log.debug(f"Proxying request to {req.url}")
300305

301306
try:
302307
# Here, "response" is a tornado.httpclient.HTTPResponse object.
303308
response = await client.fetch(req, raise_error=False)
304309
except httpclient.HTTPError as err:
305310
# We need to capture the timeout error even with raise_error=False,
306-
# because it only affects the HTTPError raised when a non-200 response
311+
# because it only affects the HTTPError raised when a non-200 response
307312
# code is used, instead of suppressing all errors.
308313
# Ref: https://www.tornadoweb.org/en/stable/httpclient.html#tornado.httpclient.AsyncHTTPClient.fetch
309314
if err.code == 599:
@@ -324,7 +329,7 @@ async def proxy(self, host, port, proxied_path):
324329
else:
325330
# Represent the original response as a RewritableResponse object.
326331
original_response = RewritableResponse(orig_response=response)
327-
332+
328333
# The function (or list of functions) which should be applied to modify the
329334
# response.
330335
rewrite_response = self.rewrite_response
@@ -688,53 +693,57 @@ def options(self, path):
688693
def setup_handlers(web_app, serverproxy_config):
689694
host_allowlist = serverproxy_config.host_allowlist
690695
rewrite_response = serverproxy_config.non_service_rewrite_response
691-
web_app.add_handlers('.*', [
692-
(
693-
url_path_join(
694-
web_app.settings['base_url'],
695-
r'/proxy/([^/]*):(\d+)(.*)',
696+
web_app.add_handlers(
697+
".*",
698+
[
699+
(
700+
url_path_join(
701+
web_app.settings["base_url"],
702+
r"/proxy/([^/:@]+):(\d+)(/.*|)",
703+
),
704+
RemoteProxyHandler,
705+
{
706+
"absolute_url": False,
707+
"host_allowlist": host_allowlist,
708+
"rewrite_response": rewrite_response,
709+
},
696710
),
697-
RemoteProxyHandler,
698-
{
699-
'absolute_url': False,
700-
'host_allowlist': host_allowlist,
701-
'rewrite_response': rewrite_response,
702-
}
703-
),
704-
(
705-
url_path_join(
706-
web_app.settings['base_url'],
707-
r'/proxy/absolute/([^/]*):(\d+)(.*)',
711+
(
712+
url_path_join(
713+
web_app.settings["base_url"],
714+
r"/proxy/absolute/([^/:@]+):(\d+)(/.*|)",
715+
),
716+
RemoteProxyHandler,
717+
{
718+
"absolute_url": True,
719+
"host_allowlist": host_allowlist,
720+
"rewrite_response": rewrite_response,
721+
},
708722
),
709-
RemoteProxyHandler,
710-
{
711-
'absolute_url': True,
712-
'host_allowlist': host_allowlist,
713-
'rewrite_response': rewrite_response,
714-
}
715-
),
716-
(
717-
url_path_join(
718-
web_app.settings['base_url'],
719-
r'/proxy/(\d+)(.*)',
723+
(
724+
url_path_join(
725+
web_app.settings["base_url"],
726+
r"/proxy/(\d+)(/.*|)",
727+
),
728+
LocalProxyHandler,
729+
{
730+
"absolute_url": False,
731+
"rewrite_response": rewrite_response,
732+
},
720733
),
721-
LocalProxyHandler,
722-
{
723-
'absolute_url': False,
724-
'rewrite_response': rewrite_response,
725-
},
726-
),
727-
(
728-
url_path_join(
729-
web_app.settings['base_url'],
730-
r'/proxy/absolute/(\d+)(.*)',
734+
(
735+
url_path_join(
736+
web_app.settings["base_url"],
737+
r"/proxy/absolute/(\d+)(/.*|)",
738+
),
739+
LocalProxyHandler,
740+
{
741+
"absolute_url": True,
742+
"rewrite_response": rewrite_response,
743+
},
731744
),
732-
LocalProxyHandler,
733-
{
734-
'absolute_url': True,
735-
'rewrite_response': rewrite_response,
736-
},
737-
),
738-
])
745+
],
746+
)
747+
739748

740749
# vim: set et ts=4 sw=4:

tests/test_proxies.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,21 @@ async def _websocket_subprotocols():
291291
def test_server_proxy_websocket_subprotocols(event_loop):
292292
event_loop.run_until_complete(_websocket_subprotocols())
293293

294+
@pytest.mark.parametrize(
295+
"proxy_path, status",
296+
[
297+
("127.0.0.1", 404),
298+
("127.0.0.1/path", 404),
299+
("127.0.0.1@192.168.1.1", 404),
300+
("127.0.0.1@192.168.1.1/path", 404),
301+
("user:pass@host:123/foo", 403),
302+
("user:pass@host/foo", 404),
303+
("absolute/127.0.0.1:123@192.168.1.1/path", 404),
304+
]
305+
)
306+
def test_bad_server_proxy_url(proxy_path, status):
307+
r = request_get(PORT, f"/proxy/{proxy_path}", TOKEN)
308+
assert r.code == status
309+
if status >= 400:
310+
# request should not have been proxied
311+
assert 'X-ProxyContextPath' not in r.headers

0 commit comments

Comments
 (0)