Repository: jupyterhub/jupyterhub

Issue:
Open redirect during login
Hi! Thanks for using JupyterHub.

If you are reporting an issue with JupyterHub:

**How to reproduce the issue**

Login to JupyterHub with a URL like: https://hub.jupyter.com/hub/login?next=%2f%2fbaddomain.com

**What you expected to happen**

Parameters should be validated before sending the 302, preventing potential phishing attacks or tricking users into visiting malicious sites.

**What actually happens**

Users are redirected.

**Share what version of JupyterHub you are using**

0.9.0 


Discussion:

Resolving patch:
diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py
--- a/jupyterhub/handlers/base.py
+++ b/jupyterhub/handlers/base.py
@@ -417,10 +417,20 @@ def get_next_url(self, user=None):
         - else: /hub/home
         """
         next_url = self.get_argument('next', default='')
-        if (next_url + '/').startswith('%s://%s/' % (self.request.protocol, self.request.host)):
+        if (next_url + '/').startswith(
+            (
+                '%s://%s/' % (self.request.protocol, self.request.host),
+                '//%s/' % self.request.host,
+            )
+        ):
             # treat absolute URLs for our host as absolute paths:
-            next_url = urlparse(next_url).path
-        if next_url and not next_url.startswith('/'):
+            parsed = urlparse(next_url)
+            next_url = parsed.path
+            if parsed.query:
+                next_url = next_url + '?' + parsed.query
+            if parsed.hash:
+                next_url = next_url + '#' + parsed.hash
+        if next_url and (urlparse(next_url).netloc or not next_url.startswith('/')):
             self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
             next_url = ''
         if next_url and next_url.startswith(url_path_join(self.base_url, 'user/')):


Test patch:
diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py
--- a/jupyterhub/tests/test_pages.py
+++ b/jupyterhub/tests/test_pages.py
@@ -3,6 +3,7 @@
 from urllib.parse import urlencode, urlparse
 
 from tornado import gen
+from tornado.httputil import url_concat
 
 from ..handlers import BaseHandler
 from ..utils import url_path_join as ujoin
@@ -366,29 +367,50 @@ def mock_authenticate(handler, data):
     assert called_with == [form_data]
 
 
+@pytest.mark.parametrize(
+    'running, next_url, location',
+    [
+        # default URL if next not specified, for both running and not
+        (True, '', ''),
+        (False, '', ''),
+        # next_url is respected
+        (False, '/hub/admin', '/hub/admin'),
+        (False, '/user/other', '/hub/user/other'),
+        (False, '/absolute', '/absolute'),
+        (False, '/has?query#andhash', '/has?query#andhash'),
+
+        # next_url outside is not allowed
+        (False, 'https://other.domain', ''),
+        (False, 'ftp://other.domain', ''),
+        (False, '//other.domain', ''),
+    ]
+)
 @pytest.mark.gen_test
-def test_login_redirect(app):
+def test_login_redirect(app, running, next_url, location):
     cookies = yield app.login_user('river')
     user = app.users['river']
-    # no next_url, server running
-    yield user.spawn()
-    r = yield get_page('login', app, cookies=cookies, allow_redirects=False)
-    r.raise_for_status()
-    assert r.status_code == 302
-    assert '/user/river' in r.headers['Location']
-
-    # no next_url, server not running
-    yield user.stop()
-    r = yield get_page('login', app, cookies=cookies, allow_redirects=False)
-    r.raise_for_status()
-    assert r.status_code == 302
-    assert '/user/river' in r.headers['Location']
-
-    # next URL given, use it
-    r = yield get_page('login?next=/hub/admin', app, cookies=cookies, allow_redirects=False)
+    if location:
+        location = ujoin(app.base_url, location)
+    else:
+        # use default url
+        location = user.url
+
+    url = 'login'
+    if next_url:
+        if '//' not in next_url:
+            next_url = ujoin(app.base_url, next_url, '')
+        url = url_concat(url, dict(next=next_url))
+
+    if running and not user.active:
+        # ensure running
+        yield user.spawn()
+    elif user.active and not running:
+        # ensure not running
+        yield user.stop()
+    r = yield get_page(url, app, cookies=cookies, allow_redirects=False)
     r.raise_for_status()
     assert r.status_code == 302
-    assert r.headers['Location'].endswith('/hub/admin')
+    assert location == r.headers['Location']
 
 
 @pytest.mark.gen_test