Skip to content

Commit 0eda26c

Browse files
authored
Allow proxying to remote host (#154)
Allow proxying to remote host
2 parents 4b5ec52 + d02e213 commit 0eda26c

File tree

7 files changed

+127
-10
lines changed

7 files changed

+127
-10
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
*.pyc
2+
*.egg-info/
23
docs/_build
34
node_modules

docs/arbitrary-ports.rst renamed to docs/arbitrary-ports-hosts.rst

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
.. _arbitrary-ports:
22

3-
=========================
4-
Accessing Arbitrary Ports
5-
=========================
3+
==================================
4+
Accessing Arbitrary Ports or Hosts
5+
==================================
66

77
If you already have a server running on localhost listening on
88
a port, you can access it through the notebook at
@@ -15,6 +15,11 @@ URL in the request.
1515

1616
This works for all ports listening on the local machine.
1717

18+
You can also specify arbitrary hosts in order to proxy traffic from
19+
another machine on the network ``<notebook-base>/proxy/<host>:<port>``.
20+
21+
For security reasons the host must match an entry in the whitelist in your configuration.
22+
1823
With JupyterHub
1924
===============
2025

@@ -38,7 +43,7 @@ Without JupyterHub
3843
==================
3944

4045
A very similar set up works when you don't use JupyterHub. You
41-
can construct the URL with ``<notebook-url>/proxy/<port>``.
46+
can construct the URL with ``<notebook-url>/proxy/<port>``.
4247

4348
If your notebook url is ``http://localhost:8888`` and you have
4449
a process running listening on port 8080, you can access it with

docs/index.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ The primary use cases are:
2020
#. Allow access from frontend javascript (in classic notebook or
2121
JupyterLab extensions) to access web APIs of other processes
2222
running locally in a safe manner. This is used by the `JupyterLab
23-
extension <https://github.com/dask/dask-labextension>`_ for
23+
extension <https://github.com/dask/dask-labextension>`_ for
2424
`dask <https://dask.org/>`_.
2525

2626

@@ -33,7 +33,7 @@ Contents
3333
install
3434
server-process
3535
launchers
36-
arbitrary-ports
36+
arbitrary-ports-hosts
3737

3838

3939
Convenience packages for popular applications

jupyter_server_proxy/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def load_jupyter_server_extension(nbapp):
2929
nbapp.web_app.add_handlers('.*', server_handlers)
3030

3131
# Set up default handler
32-
setup_handlers(nbapp.web_app)
32+
setup_handlers(nbapp.web_app, serverproxy.host_whitelist)
3333

3434
launcher_entries = []
3535
icons = {}
@@ -40,4 +40,4 @@ def load_jupyter_server_extension(nbapp):
4040
nbapp.web_app.add_handlers('.*', [
4141
(ujoin(base_url, 'server-proxy/servers-info'), ServersInfoHandler, {'server_processes': server_proccesses}),
4242
(ujoin(base_url, 'server-proxy/icon/(.*)'), IconHandler, {'icons': icons})
43-
])
43+
])

jupyter_server_proxy/config.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22
Traitlets based configuration for jupyter_server_proxy
33
"""
44
from notebook.utils import url_path_join as ujoin
5-
from traitlets import Dict
5+
from traitlets import Dict, List, Union, default
66
from traitlets.config import Configurable
77
from .handlers import SuperviseAndProxyHandler, AddSlashHandler
88
import pkg_resources
99
from collections import namedtuple
1010
from .utils import call_with_asked_args
1111

12+
try:
13+
# Traitlets >= 4.3.3
14+
from traitlets import Callable
15+
except ImportError:
16+
from .utils import Callable
17+
1218
def _make_serverproxy_handler(name, command, environment, timeout, absolute_url, port, mappath):
1319
"""
1420
Create a SuperviseAndProxyHandler subclass with given parameters
@@ -170,3 +176,30 @@ class ServerProxy(Configurable):
170176
""",
171177
config=True
172178
)
179+
180+
host_whitelist = Union(
181+
trait_types=[List(), Callable()],
182+
help="""
183+
List of allowed hosts.
184+
Can also be a function that decides whether a host can be proxied.
185+
186+
If implemented as a function, this should return True if a host should
187+
be proxied and False if it should not. Such a function could verify
188+
that the host matches a particular regular expression pattern or falls
189+
into a specific subnet. It should probably not be a slow check against
190+
some external service. Here is an example that could be placed in a
191+
site-wide Jupyter notebook config:
192+
193+
def host_whitelist(handler, host):
194+
handler.log.info("Request to proxy to host " + host)
195+
return host.startswith("10.")
196+
c.ServerProxy.host_whitelist = host_whitelist
197+
198+
Defaults to a list of ["localhost", "127.0.0.1"].
199+
""",
200+
config=True
201+
)
202+
203+
@default("host_whitelist")
204+
def _host_whitelist_default(self):
205+
return ["localhost", "127.0.0.1"]

jupyter_server_proxy/handlers.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class ProxyHandler(WebSocketHandlerMixin, IPythonHandler):
4343
def __init__(self, *args, **kwargs):
4444
self.proxy_base = ''
4545
self.absolute_url = kwargs.pop('absolute_url', False)
46+
self.host_whitelist = kwargs.pop('host_whitelist', ['localhost', '127.0.0.1'])
4647
super().__init__(*args, **kwargs)
4748

4849
# Support all the methods that tornado does by default except for GET which
@@ -167,6 +168,12 @@ def _build_proxy_request(self, host, port, proxied_path, body):
167168
headers=headers, **self.proxy_request_options())
168169
return req
169170

171+
def _check_host_whitelist(self, host):
172+
if callable(self.host_whitelist):
173+
return self.host_whitelist(self, host)
174+
else:
175+
return host in self.host_whitelist
176+
170177
@web.authenticated
171178
async def proxy(self, host, port, proxied_path):
172179
'''
@@ -176,6 +183,12 @@ async def proxy(self, host, port, proxied_path):
176183
{base_url}/{proxy_base}/{proxied_path}
177184
'''
178185

186+
if not self._check_host_whitelist(host):
187+
self.set_status(403)
188+
self.write("Host '{host}' is not whitelisted. "
189+
"See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.".format(host=host))
190+
return
191+
179192
if 'Proxy-Connection' in self.request.headers:
180193
del self.request.headers['Proxy-Connection']
181194

@@ -227,6 +240,14 @@ async def proxy_open(self, host, port, proxied_path=''):
227240
We establish a websocket connection to the proxied backend &
228241
set up a callback to relay messages through.
229242
"""
243+
244+
if not self._check_host_whitelist(host):
245+
self.set_status(403)
246+
self.log.info("Host '{host}' is not whitelisted. "
247+
"See https://jupyter-server-proxy.readthedocs.io/en/latest/arbitrary-ports-hosts.html for info.".format(host=host))
248+
self.close()
249+
return
250+
230251
if not proxied_path.startswith('/'):
231252
proxied_path = '/' + proxied_path
232253

@@ -335,6 +356,40 @@ def proxy(self, port, proxied_path):
335356
return super().proxy('localhost', port, proxied_path)
336357

337358

359+
class RemoteProxyHandler(ProxyHandler):
360+
"""
361+
A tornado request handler that proxies HTTP and websockets
362+
from a port on a specified remote system.
363+
"""
364+
365+
async def http_get(self, host, port, proxied_path):
366+
return await self.proxy(host, port, proxied_path)
367+
368+
def post(self, host, port, proxied_path):
369+
return self.proxy(host, port, proxied_path)
370+
371+
def put(self, host, port, proxied_path):
372+
return self.proxy(host, port, proxied_path)
373+
374+
def delete(self, host, port, proxied_path):
375+
return self.proxy(host, port, proxied_path)
376+
377+
def head(self, host, port, proxied_path):
378+
return self.proxy(host, port, proxied_path)
379+
380+
def patch(self, host, port, proxied_path):
381+
return self.proxy(host, port, proxied_path)
382+
383+
def options(self, host, port, proxied_path):
384+
return self.proxy(host, port, proxied_path)
385+
386+
async def open(self, host, port, proxied_path):
387+
return await self.proxy_open(host, port, proxied_path)
388+
389+
def proxy(self, host, port, proxied_path):
390+
return super().proxy(host, port, proxied_path)
391+
392+
338393
# FIXME: Move this to its own file. Too many packages now import this from nbrserverproxy.handlers
339394
class SuperviseAndProxyHandler(LocalProxyHandler):
340395
'''Manage a given process and requests to it '''
@@ -474,9 +529,13 @@ def options(self, path):
474529
return self.proxy(self.port, path)
475530

476531

477-
def setup_handlers(web_app):
532+
def setup_handlers(web_app, host_whitelist):
478533
host_pattern = '.*$'
479534
web_app.add_handlers('.*', [
535+
(url_path_join(web_app.settings['base_url'], r'/proxy/(.*):(\d+)(.*)'),
536+
RemoteProxyHandler, {'absolute_url': False, 'host_whitelist': host_whitelist}),
537+
(url_path_join(web_app.settings['base_url'], r'/proxy/absolute/(.*):(\d+)(.*)'),
538+
RemoteProxyHandler, {'absolute_url': True, 'host_whitelist': host_whitelist}),
480539
(url_path_join(web_app.settings['base_url'], r'/proxy/(\d+)(.*)'),
481540
LocalProxyHandler, {'absolute_url': False}),
482541
(url_path_join(web_app.settings['base_url'], r'/proxy/absolute/(\d+)(.*)'),

jupyter_server_proxy/utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from traitlets import TraitType
2+
import six
3+
14
def call_with_asked_args(callback, args):
25
"""
36
Call callback with only the args it wants from args
@@ -29,3 +32,19 @@ def call_with_asked_args(callback, args):
2932
)
3033
)
3134
return callback(*asked_arg_values)
35+
36+
# copy-pasted from the master of Traitlets source
37+
class Callable(TraitType):
38+
"""A trait which is callable.
39+
Notes
40+
-----
41+
Classes are callable, as are instances
42+
with a __call__() method."""
43+
44+
info_text = 'a callable'
45+
46+
def validate(self, obj, value):
47+
if six.callable(value):
48+
return value
49+
else:
50+
self.error(obj, value)

0 commit comments

Comments
 (0)