Skip to content

Commit b712eff

Browse files
authored
Allow for other hosts than localhost to be proxied. (#128)
Allow for other hosts than `localhost` to be proxied.
2 parents f12a090 + d293126 commit b712eff

File tree

4 files changed

+175
-73
lines changed

4 files changed

+175
-73
lines changed

jupyter_server_proxy/handlers.py

Lines changed: 118 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -28,59 +28,49 @@ def get(self, *args):
2828
dest = src._replace(path=src.path + '/')
2929
self.redirect(urlunparse(dest))
3030

31-
class LocalProxyHandler(WebSocketHandlerMixin, IPythonHandler):
32-
31+
class ProxyHandler(WebSocketHandlerMixin, IPythonHandler):
32+
"""
33+
A tornado request handler that proxies HTTP and websockets from
34+
a given host/port combination. This class is not meant to be
35+
used directly as a means of overriding CORS. This presents significant
36+
security risks, and could allow arbitrary remote code access. Instead, it is
37+
meant to be subclassed and used for proxying URLs from trusted sources.
38+
39+
Subclasses should implement open, http_get, post, put, delete, head, patch,
40+
and options.
41+
"""
3342
def __init__(self, *args, **kwargs):
3443
self.proxy_base = ''
3544
self.absolute_url = kwargs.pop('absolute_url', False)
3645
super().__init__(*args, **kwargs)
3746

38-
async def open(self, port, proxied_path=''):
39-
"""
40-
Called when a client opens a websocket connection.
47+
# Support all the methods that torando does by default except for GET which
48+
# is passed to WebSocketHandlerMixin and then to WebSocketHandler.
4149

42-
We establish a websocket connection to the proxied backend &
43-
set up a callback to relay messages through.
44-
"""
45-
if not proxied_path.startswith('/'):
46-
proxied_path = '/' + proxied_path
50+
async def open(self, port, proxied_path):
51+
raise NotImplementedError('Subclasses of ProxyHandler should implement open')
4752

48-
client_uri = self.get_client_uri('ws', port, proxied_path)
49-
headers = self.request.headers
53+
async def http_get(self, host, port, proxy_path=''):
54+
'''Our non-websocket GET.'''
55+
raise NotImplementedError('Subclasses of ProxyHandler should implement http_get')
5056

51-
def message_cb(message):
52-
"""
53-
Callback when the backend sends messages to us
57+
def post(self, host, port, proxy_path=''):
58+
raise NotImplementedError('Subclasses of ProxyHandler should implement this post')
5459

55-
We just pass it back to the frontend
56-
"""
57-
# Websockets support both string (utf-8) and binary data, so let's
58-
# make sure we signal that appropriately when proxying
59-
self._record_activity()
60-
if message is None:
61-
self.close()
62-
else:
63-
self.write_message(message, binary=isinstance(message, bytes))
60+
def put(self, port, proxy_path=''):
61+
raise NotImplementedError('Subclasses of ProxyHandler should implement this put')
6462

65-
def ping_cb(data):
66-
"""
67-
Callback when the backend sends pings to us.
63+
def delete(self, host, port, proxy_path=''):
64+
raise NotImplementedError('Subclasses of ProxyHandler should implement delete')
6865

69-
We just pass it back to the frontend.
70-
"""
71-
self._record_activity()
72-
self.ping(data)
66+
def head(self, host, port, proxy_path=''):
67+
raise NotImplementedError('Subclasses of ProxyHandler should implement head')
7368

74-
async def start_websocket_connection():
75-
self.log.info('Trying to establish websocket connection to {}'.format(client_uri))
76-
self._record_activity()
77-
request = httpclient.HTTPRequest(url=client_uri, headers=headers)
78-
self.ws = await pingable_ws_connect(request=request,
79-
on_message_callback=message_cb, on_ping_callback=ping_cb)
80-
self._record_activity()
81-
self.log.info('Websocket connection established to {}'.format(client_uri))
69+
def patch(self, host, port, proxy_path=''):
70+
raise NotImplementedError('Subclasses of ProxyHandler should implement patch')
8271

83-
ioloop.IOLoop.current().add_callback(start_websocket_connection)
72+
def options(self, host, port, proxy_path=''):
73+
raise NotImplementedError('Subclasses of ProxyHandler should implement options')
8474

8575
def on_message(self, message):
8676
"""
@@ -141,7 +131,7 @@ def _get_context_path(self, port):
141131
else:
142132
return url_path_join(self.base_url, 'proxy', str(port))
143133

144-
def get_client_uri(self, protocol, port, proxied_path):
134+
def get_client_uri(self, protocol, host, port, proxied_path):
145135
context_path = self._get_context_path(port)
146136
if self.absolute_url:
147137
client_path = url_path_join(context_path, proxied_path)
@@ -150,7 +140,7 @@ def get_client_uri(self, protocol, port, proxied_path):
150140

151141
client_uri = '{protocol}://{host}:{port}{path}'.format(
152142
protocol=protocol,
153-
host='localhost',
143+
host=host,
154144
port=port,
155145
path=client_path
156146
)
@@ -159,11 +149,11 @@ def get_client_uri(self, protocol, port, proxied_path):
159149

160150
return client_uri
161151

162-
def _build_proxy_request(self, port, proxied_path, body):
152+
def _build_proxy_request(self, host, port, proxied_path, body):
163153

164154
headers = self.proxy_request_headers()
165155

166-
client_uri = self.get_client_uri('http', port, proxied_path)
156+
client_uri = self.get_client_uri('http', host, port, proxied_path)
167157
# Some applications check X-Forwarded-Context and X-ProxyContextPath
168158
# headers to see if and where they are being proxied from.
169159
if not self.absolute_url:
@@ -177,7 +167,7 @@ def _build_proxy_request(self, port, proxied_path, body):
177167
return req
178168

179169
@web.authenticated
180-
async def proxy(self, port, proxied_path):
170+
async def proxy(self, host, port, proxied_path):
181171
'''
182172
This serverextension handles:
183173
{base_url}/proxy/{port([0-9]+)}/{proxied_path}
@@ -205,7 +195,7 @@ async def proxy(self, port, proxied_path):
205195

206196
client = httpclient.AsyncHTTPClient()
207197

208-
req = self._build_proxy_request(port, proxied_path, body)
198+
req = self._build_proxy_request(host, port, proxied_path, body)
209199
response = await client.fetch(req, raise_error=False)
210200
# record activity at start and end of requests
211201
self._record_activity()
@@ -229,40 +219,63 @@ async def proxy(self, port, proxied_path):
229219
if response.body:
230220
self.write(response.body)
231221

232-
def proxy_request_headers(self):
233-
'''A dictionary of headers to be used when constructing
234-
a tornado.httpclient.HTTPRequest instance for the proxy request.'''
235-
return self.request.headers.copy()
222+
async def proxy_open(self, host, port, proxied_path=''):
223+
"""
224+
Called when a client opens a websocket connection.
236225
237-
def proxy_request_options(self):
238-
'''A dictionary of options to be used when constructing
239-
a tornado.httpclient.HTTPRequest instance for the proxy request.'''
240-
return dict(follow_redirects=False)
226+
We establish a websocket connection to the proxied backend &
227+
set up a callback to relay messages through.
228+
"""
229+
if not proxied_path.startswith('/'):
230+
proxied_path = '/' + proxied_path
241231

242-
# Support all the methods that torando does by default except for GET which
243-
# is passed to WebSocketHandlerMixin and then to WebSocketHandler.
232+
client_uri = self.get_client_uri('ws', host, port, proxied_path)
233+
headers = self.request.headers
244234

245-
async def http_get(self, port, proxy_path=''):
246-
'''Our non-websocket GET.'''
247-
return await self.proxy(port, proxy_path)
235+
def message_cb(message):
236+
"""
237+
Callback when the backend sends messages to us
248238
249-
def post(self, port, proxy_path=''):
250-
return self.proxy(port, proxy_path)
239+
We just pass it back to the frontend
240+
"""
241+
# Websockets support both string (utf-8) and binary data, so let's
242+
# make sure we signal that appropriately when proxying
243+
self._record_activity()
244+
if message is None:
245+
self.close()
246+
else:
247+
self.write_message(message, binary=isinstance(message, bytes))
251248

252-
def put(self, port, proxy_path=''):
253-
return self.proxy(port, proxy_path)
249+
def ping_cb(data):
250+
"""
251+
Callback when the backend sends pings to us.
252+
253+
We just pass it back to the frontend.
254+
"""
255+
self._record_activity()
256+
self.ping(data)
257+
258+
async def start_websocket_connection():
259+
self.log.info('Trying to establish websocket connection to {}'.format(client_uri))
260+
self._record_activity()
261+
request = httpclient.HTTPRequest(url=client_uri, headers=headers)
262+
self.ws = await pingable_ws_connect(request=request,
263+
on_message_callback=message_cb, on_ping_callback=ping_cb)
264+
self._record_activity()
265+
self.log.info('Websocket connection established to {}'.format(client_uri))
254266

255-
def delete(self, port, proxy_path=''):
256-
return self.proxy(port, proxy_path)
267+
ioloop.IOLoop.current().add_callback(start_websocket_connection)
257268

258-
def head(self, port, proxy_path=''):
259-
return self.proxy(port, proxy_path)
260269

261-
def patch(self, port, proxy_path=''):
262-
return self.proxy(port, proxy_path)
270+
def proxy_request_headers(self):
271+
'''A dictionary of headers to be used when constructing
272+
a tornado.httpclient.HTTPRequest instance for the proxy request.'''
273+
return self.request.headers.copy()
263274

264-
def options(self, port, proxy_path=''):
265-
return self.proxy(port, proxy_path)
275+
def proxy_request_options(self):
276+
'''A dictionary of options to be used when constructing
277+
a tornado.httpclient.HTTPRequest instance for the proxy request.'''
278+
return dict(follow_redirects=False)
266279

267280
def check_xsrf_cookie(self):
268281
'''
@@ -280,6 +293,40 @@ def select_subprotocol(self, subprotocols):
280293
return super().select_subprotocol(subprotocols)
281294

282295

296+
class LocalProxyHandler(ProxyHandler):
297+
"""
298+
A tornado request handler that proxies HTTP and websockets
299+
from a port on the local system. Same as the above ProxyHandler,
300+
but specific to 'localhost'.
301+
"""
302+
async def http_get(self, port, proxied_path):
303+
return await self.proxy(port, proxied_path)
304+
305+
async def open(self, port, proxied_path):
306+
return await self.proxy_open('localhost', port, proxied_path)
307+
308+
def post(self, port, proxied_path):
309+
return self.proxy(port, proxied_path)
310+
311+
def put(self, port, proxied_path):
312+
return self.proxy(port, proxied_path)
313+
314+
def delete(self, port, proxied_path):
315+
return self.proxy(port, proxied_path)
316+
317+
def head(self, port, proxied_path):
318+
return self.proxy(port, proxied_path)
319+
320+
def patch(self, port, proxied_path):
321+
return self.proxy(port, proxied_path)
322+
323+
def options(self, port, proxied_path):
324+
return self.proxy(port, proxied_path)
325+
326+
def proxy(self, port, proxied_path):
327+
return super().proxy('localhost', port, proxied_path)
328+
329+
283330
# FIXME: Move this to its own file. Too many packages now import this from nbrserverproxy.handlers
284331
class SuperviseAndProxyHandler(LocalProxyHandler):
285332
'''Manage a given process and requests to it '''

tests/resources/jupyter_server_config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,8 @@
1111
'port': 54321,
1212
},
1313
}
14+
15+
import sys
16+
sys.path.append('./tests/resources')
17+
c.NotebookApp.nbserver_extensions = { 'proxyextension': True }
1418
#c.Application.log_level = 'DEBUG'

tests/resources/proxyextension.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from jupyter_server_proxy.handlers import ProxyHandler
2+
from notebook.utils import url_path_join
3+
4+
class NewHandler(ProxyHandler):
5+
async def http_get(self):
6+
return await self.proxy()
7+
8+
async def open(self):
9+
host = '127.0.0.1'
10+
port = 54321
11+
return await super().proxy_open(host, port)
12+
13+
def post(self):
14+
return self.proxy()
15+
16+
def put(self):
17+
return self.proxy()
18+
19+
def delete(self):
20+
return self.proxy()
21+
22+
def head(self):
23+
return self.proxy()
24+
25+
def patch(self):
26+
return self.proxy()
27+
28+
def options(self):
29+
return self.proxy()
30+
31+
def proxy(self):
32+
host = '127.0.0.1'
33+
port = 54321
34+
proxied_path = ''
35+
return super().proxy(host, port, proxied_path)
36+
37+
38+
def _jupyter_server_extension_paths():
39+
return [{"module": "dask_labextension"}]
40+
41+
42+
def load_jupyter_server_extension(nb_server_app):
43+
web_app = nb_server_app.web_app
44+
base_url = web_app.settings["base_url"]
45+
proxy_path = url_path_join(base_url, "newproxy/" + "?")
46+
handlers = [(proxy_path, NewHandler)]
47+
web_app.add_handlers(".*$", handlers)

tests/test_proxies.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
TOKEN = os.getenv('JUPYTER_TOKEN', 'secret')
66

77

8-
def request_get(port, path, token):
9-
h = HTTPConnection('localhost', port, 10)
8+
def request_get(port, path, token, host='localhost'):
9+
h = HTTPConnection(host, port, 10)
1010
h.request('GET', '{}?token={}'.format(path, token))
1111
return h.getresponse()
1212

@@ -57,3 +57,7 @@ def test_server_proxy_port_absolute():
5757
assert s.startswith('GET /proxy/absolute/54321/nmo?token=')
5858
assert 'X-Forwarded-Context' not in s
5959
assert 'X-Proxycontextpath' not in s
60+
61+
def test_server_proxy_remote():
62+
r = request_get(PORT, '/newproxy', TOKEN, host='127.0.0.1')
63+
assert r.code == 200

0 commit comments

Comments
 (0)