From 7f1bba613d94c20d09aeebc9b2bd9e26aa6cbb96 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Mon, 25 Jun 2018 10:26:10 +0200 Subject: [PATCH 1/5] Check 'Host' header for local connections --- notebook/base/handlers.py | 34 ++++++++++++++++++++++++++++++++++ notebook/notebookapp.py | 25 +++++++++++++++++++++++++ setup.py | 1 + 3 files changed, 60 insertions(+) diff --git a/notebook/base/handlers.py b/notebook/base/handlers.py index fb8cbe5665..18a35047ad 100755 --- a/notebook/base/handlers.py +++ b/notebook/base/handlers.py @@ -5,6 +5,7 @@ import datetime import functools +import ipaddress import json import mimetypes import os @@ -411,6 +412,39 @@ def check_xsrf_cookie(self): return return super(IPythonHandler, self).check_xsrf_cookie() + def check_host(self): + """Check the host header if remote access disallowed. + + Returns True if the request should continue, False otherwise. + """ + if self.settings.get('allow_remote_access', False): + return True + + # Remove port (e.g. ':8888') from host + host = re.match(r'^(.*?)(:\d+)?$', self.request.host).group(1) + + # Browsers format IPv6 addresses like [::1]; we need to remove the [] + if host.startswith('[') and host.endswith(']'): + host = host[1:-1] + + try: + addr = ipaddress.ip_address(host) + except ValueError: + # Not an IP address: check against hostnames + allow = host in self.settings.get('local_hostnames', []) + else: + allow = addr.is_loopback + + if not allow: + self.log.warning("Blocking request with non-local 'Host' %s (%s)", + host, self.request.host) + return allow + + def prepare(self): + if not self.check_host(): + raise web.HTTPError(403) + return super(IPythonHandler, self).prepare() + #--------------------------------------------------------------- # template rendering #--------------------------------------------------------------- diff --git a/notebook/notebookapp.py b/notebook/notebookapp.py index bbd4625111..fa7499755c 100755 --- a/notebook/notebookapp.py +++ b/notebook/notebookapp.py @@ -252,6 +252,8 @@ def init_settings(self, jupyter_app, kernel_manager, contents_manager, password=jupyter_app.password, xsrf_cookies=True, disable_check_xsrf=jupyter_app.disable_check_xsrf, + allow_remote_access=jupyter_app.allow_remote_access, + local_hostnames=jupyter_app.local_hostnames, # managers kernel_manager=kernel_manager, @@ -831,6 +833,29 @@ def _token_changed(self, change): """ ) + allow_remote_access = Bool(False, config=True, + help="""Allow requests where the Host header doesn't point to a local server + + By default, requests get a 403 forbidden response if the 'Host' header + shows that the browser thinks it's on a non-local domain. + Setting this option to True disables this check. + + This protects against 'DNS rebinding' attacks, where a remote web server + serves you a page and then changes its DNS to send later requests to a + local IP, bypassing same-origin checks. + + Local IP addresses (such as 127.0.0.1 and ::1) are allowed as local, + along with hostnames configured in local_hostnames. + """) + + local_hostnames = List(Unicode(), ['localhost'], config=True, + help="""Hostnames to allow as local when allow_remote_access is False. + + Local IP addresses (such as 127.0.0.1 and ::1) are automatically accepted + as local as well. + """ + ) + open_browser = Bool(True, config=True, help="""Whether to open in a browser after starting. The specific browser used is platform dependent and diff --git a/setup.py b/setup.py index 7867811417..f6b18bfe6e 100755 --- a/setup.py +++ b/setup.py @@ -94,6 +94,7 @@ 'prometheus_client' ], extras_require = { + ':python_version == "2.7"': ['ipaddress'], 'test:python_version == "2.7"': ['mock'], 'test': ['nose', 'coverage', 'requests', 'nose_warnings_filters', 'nbval', 'nose-exclude'], From fa3abf2209d77aa33cf9420c298bd72536b175c7 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Mon, 25 Jun 2018 13:53:14 +0200 Subject: [PATCH 2/5] Allow remote access by default when we're listening on external addresses --- notebook/notebookapp.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/notebook/notebookapp.py b/notebook/notebookapp.py index fa7499755c..60561a767e 100755 --- a/notebook/notebookapp.py +++ b/notebook/notebookapp.py @@ -15,6 +15,7 @@ import hmac import importlib import io +import ipaddress import json import logging import mimetypes @@ -833,7 +834,7 @@ def _token_changed(self, change): """ ) - allow_remote_access = Bool(False, config=True, + allow_remote_access = Bool(config=True, help="""Allow requests where the Host header doesn't point to a local server By default, requests get a 403 forbidden response if the 'Host' header @@ -848,6 +849,21 @@ def _token_changed(self, change): along with hostnames configured in local_hostnames. """) + @default('allow_remote_access') + def _default_allow_remote(self): + """Disallow remote access if we're listening only on loopback addresses""" + try: + addr = ipaddress.ip_address(self.ip) + except ValueError: + # Address is a hostname + for info in socket.getaddrinfo(self.ip, self.port, type=socket.SOCK_STREAM): + addr = ipaddress.ip_address(info[4][0]) + if not addr.is_loopback: + return True + return False + else: + return not addr.is_loopback + local_hostnames = List(Unicode(), ['localhost'], config=True, help="""Hostnames to allow as local when allow_remote_access is False. From 845ea48c317dd226717f6b8d7dc2649e5d4cd4d5 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Mon, 25 Jun 2018 14:49:49 +0200 Subject: [PATCH 3/5] getaddrinfo() on Py2 only takes positional args --- notebook/notebookapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebook/notebookapp.py b/notebook/notebookapp.py index 60561a767e..2b0c8c2095 100755 --- a/notebook/notebookapp.py +++ b/notebook/notebookapp.py @@ -856,7 +856,7 @@ def _default_allow_remote(self): addr = ipaddress.ip_address(self.ip) except ValueError: # Address is a hostname - for info in socket.getaddrinfo(self.ip, self.port, type=socket.SOCK_STREAM): + for info in socket.getaddrinfo(self.ip, self.port, 0, socket.SOCK_STREAM): addr = ipaddress.ip_address(info[4][0]) if not addr.is_loopback: return True From c99935556ef807cc96d384a798706b2dca1498b7 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Mon, 25 Jun 2018 17:26:51 +0200 Subject: [PATCH 4/5] Decode IP addresses on Windows --- notebook/notebookapp.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/notebook/notebookapp.py b/notebook/notebookapp.py index 2b0c8c2095..27cbaa702f 100755 --- a/notebook/notebookapp.py +++ b/notebook/notebookapp.py @@ -857,8 +857,10 @@ def _default_allow_remote(self): except ValueError: # Address is a hostname for info in socket.getaddrinfo(self.ip, self.port, 0, socket.SOCK_STREAM): - addr = ipaddress.ip_address(info[4][0]) - if not addr.is_loopback: + addr = info[4][0] + if not py3compat.PY3: + addr = addr.decode('ascii') + if not ipaddress.ip_address(addr).is_loopback: return True return False else: From 0d6ffa6888781ec4c29a336ce7f6a2ac41d15fdc Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 30 Jun 2018 19:37:42 +0200 Subject: [PATCH 5/5] Explain how to disable host check in warning message --- notebook/base/handlers.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/notebook/base/handlers.py b/notebook/base/handlers.py index 18a35047ad..e3fbddc2e0 100755 --- a/notebook/base/handlers.py +++ b/notebook/base/handlers.py @@ -436,8 +436,12 @@ def check_host(self): allow = addr.is_loopback if not allow: - self.log.warning("Blocking request with non-local 'Host' %s (%s)", - host, self.request.host) + self.log.warning( + ("Blocking request with non-local 'Host' %s (%s). " + "If the notebook should be accessible at that name, " + "set NotebookApp.allow_remote_access to disable the check."), + host, self.request.host + ) return allow def prepare(self):