Repository: jupyterhub/jupyterhub

Issue:
nbgitpuller repo target triggers disallowed redirect
### Bug description

We've been seeing a problem where users following nbgitpuller links would sometimes have to go through a two step process the repository would clone/pull (I think this jupyterhub/nbgitpuller#118 is the same problem). It looks like [next_url check](https://github.com/jupyterhub/jupyterhub/blob/1f515464fe2edf5d9d8a7617c22ebe67002e0ba0/jupyterhub/handlers/base.py#L620-L626) notices when the `repo=` part of the query string contains unencoded special characters (specifically the `://`) and triggers this warning from the hub `Disallowing redirect outside hub` and redirects the user to `/hub/home`, skipping nbgitpuller.

**N.B. This only seems to happen when the user is not authenticated to start with, so visiting the nbgitpuller link a second time works**

#### Expected behaviour
Clicking an ngitpuller link should send the user through authentication then on to nbgitpuller as a single process without the user having to re-click the link.

#### Actual behaviour
If the user is not already logged in and follows an nbgitpuller link they are authenticated but nbgitpuller is not run. If they click the link a second time (now that they are logged in) nbgitpuller will do its thing and clone/pull.

### How to reproduce
1. Make sure you're not authenticated to the hub
2. Visit an nbgitpuller link where the repo target hasn't been encoded, e.g.
```
https://hub.example.com/hub/user-redirect/git-pull?repo=https://github.com/callysto/callysto-sample-notebooks
```
3. Authenticate to the hub
4. You'll be taken to `/hub/home` and nbgitpuller will not run.

### Workaround
Make sure the query string is properly encoded. The [nbgitpuller link generator](https://jupyterhub.github.io/nbgitpuller/link) does this for you automatically, but some URL shortening services (e.g. bit.ly) will undo the encoding and trigger the behaviour.

### Your personal set up
I've reproduced this on a couple of different hubs both running JupyterHub 1.1 & using different browsers (Chrome & Firefox) on the client side.

### Notes
* I'm not sure why this isn't triggered when the user is already authenticated
* This might be the intended behaviour and the solution might be to remind people that those characters should be encoded, but it comes up _a lot_ so I thought I should file an issue.


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
@@ -635,29 +635,32 @@ def get_next_url(self, user=None, default=None):
         next_url = next_url.replace('\\', '%5C')
         proto = get_browser_protocol(self.request)
         host = self.request.host
+        if next_url.startswith("///"):
+            # strip more than 2 leading // down to 2
+            # because urlparse treats that as empty netloc,
+            # whereas browsers treat more than two leading // the same as //,
+            # so netloc is the first non-/ bit
+            next_url = "//" + next_url.lstrip("/")
+        parsed_next_url = urlparse(next_url)
 
         if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
             self.subdomain_host
-            and urlparse(next_url).netloc
-            and ("." + urlparse(next_url).netloc).endswith(
+            and parsed_next_url.netloc
+            and ("." + parsed_next_url.netloc).endswith(
                 "." + urlparse(self.subdomain_host).netloc
             )
         ):
             # treat absolute URLs for our host as absolute paths:
-            # below, redirects that aren't strictly paths
-            parsed = urlparse(next_url)
-            next_url = parsed.path
-            if parsed.query:
-                next_url = next_url + '?' + parsed.query
-            if parsed.fragment:
-                next_url = next_url + '#' + parsed.fragment
+            # below, redirects that aren't strictly paths are rejected
+            next_url = parsed_next_url.path
+            if parsed_next_url.query:
+                next_url = next_url + '?' + parsed_next_url.query
+            if parsed_next_url.fragment:
+                next_url = next_url + '#' + parsed_next_url.fragment
+            parsed_next_url = urlparse(next_url)
 
         # if it still has host info, it didn't match our above check for *this* host
-        if next_url and (
-            '://' in next_url
-            or next_url.startswith('//')
-            or not next_url.startswith('/')
-        ):
+        if next_url and (parsed_next_url.netloc or not next_url.startswith('/')):
             self.log.warning("Disallowing redirect outside JupyterHub: %r", next_url)
             next_url = ''
 


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
@@ -768,6 +768,10 @@ async def mock_authenticate(handler, data):
         (False, '/user/other', '/hub/user/other', None),
         (False, '/absolute', '/absolute', None),
         (False, '/has?query#andhash', '/has?query#andhash', None),
+        # :// in query string or fragment
+        (False, '/has?repo=https/host.git', '/has?repo=https/host.git', None),
+        (False, '/has?repo=https://host.git', '/has?repo=https://host.git', None),
+        (False, '/has#repo=https://host.git', '/has#repo=https://host.git', None),
         # next_url outside is not allowed
         (False, 'relative/path', '', None),
         (False, 'https://other.domain', '', None),
@@ -807,7 +811,9 @@ async def test_login_redirect(app, running, next_url, location, params):
     if params:
         url = url_concat(url, params)
     if next_url:
-        if '//' not in next_url and next_url.startswith('/'):
+        if next_url.startswith('/') and not (
+            next_url.startswith("//") or urlparse(next_url).netloc
+        ):
             next_url = ujoin(app.base_url, next_url, '')
         url = url_concat(url, dict(next=next_url))