Repository: jupyterhub/jupyterhub

Issue:
Users with some special characters can't connect to their servers with Chrome
Chrome is substituting a few url encoded characters for the literal characters in URLs. The tilde is such a character, so it will substitute `%7E` for `~` in the request. This will make the notebook respond with a 404 to the request. Using another browser (eg Firefox), does not run into the same issue.

**How to reproduce the issue**
Create an user with a tilde in it and attempt to access your server in a jupyterhub deployment with Google Chrome.

**What you expected to happen**
Get a 200 response with the notebook server interface.
**What actually happens**
Get a 404 response.
**Share what version of JupyterHub you are using**
z2jh@0.6 so `jupyterhub==0.8.1`

See https://phabricator.wikimedia.org/T189525


Discussion:
I'm going to label this as a bug so we recheck Chrome for 0.9
Still present in 0.9b3

Resolving patch:
diff --git a/jupyterhub/user.py b/jupyterhub/user.py
--- a/jupyterhub/user.py
+++ b/jupyterhub/user.py
@@ -282,7 +282,7 @@ def server(self):
     @property
     def escaped_name(self):
         """My name, escaped for use in URLs, cookies, etc."""
-        return quote(self.name, safe='@')
+        return quote(self.name, safe='@~')
 
     @property
     def proxy_spec(self):
@@ -295,15 +295,17 @@ def proxy_spec(self):
     @property
     def domain(self):
         """Get the domain for my server."""
-        # FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
-        return self.escaped_name + '.' + self.settings['domain']
+        # use underscore as escape char for domains
+        return quote(self.name).replace('%', '_').lower() + '.' + self.settings['domain']
 
     @property
     def host(self):
         """Get the *host* for my server (proto://domain[:port])"""
         # FIXME: escaped_name probably isn't escaped enough in general for a domain fragment
         parsed = urlparse(self.settings['subdomain_host'])
-        h = '%s://%s.%s' % (parsed.scheme, self.escaped_name, parsed.netloc)
+        h = '%s://%s' % (parsed.scheme, self.domain)
+        if parsed.port:
+            h += ':%i' % parsed.port
         return h
 
     @property


Test patch:
diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py
--- a/jupyterhub/tests/test_spawner.py
+++ b/jupyterhub/tests/test_spawner.py
@@ -11,6 +11,7 @@
 import tempfile
 import time
 from unittest import mock
+from urllib.parse import urlparse
 
 import pytest
 from tornado import gen
@@ -20,7 +21,8 @@
 from .. import spawner as spawnermod
 from ..spawner import LocalProcessSpawner, Spawner
 from ..user import User
-from ..utils import new_token
+from ..utils import new_token, url_path_join
+from .mocking import public_url
 from .test_api import add_user
 from .utils import async_requests
 
@@ -304,7 +306,7 @@ def test_spawner_reuse_api_token(db, app):
 @pytest.mark.gen_test
 def test_spawner_insert_api_token(app):
     """Token provided by spawner is not in the db
-    
+
     Insert token into db as a user-provided token.
     """
     # setup: new user, double check that they don't have any tokens registered
@@ -379,3 +381,28 @@ def test_spawner_delete_server(app):
     # verify that both ORM and top-level references are None
     assert spawner.orm_spawner.server is None
     assert spawner.server is None
+
+
+@pytest.mark.parametrize(
+    "name",
+    [
+        "has@x",
+        "has~x",
+        "has%x",
+        "has%40x",
+    ]
+)
+@pytest.mark.gen_test
+def test_spawner_routing(app, name):
+    """Test routing of names with special characters"""
+    db = app.db
+    with mock.patch.dict(app.config.LocalProcessSpawner, {'cmd': [sys.executable, '-m', 'jupyterhub.tests.mocksu']}):
+        user = add_user(app.db, app, name=name)
+        yield user.spawn()
+        yield wait_for_spawner(user.spawner)
+        yield app.proxy.add_user(user)
+    url = url_path_join(public_url(app, user), "test/url")
+    r = yield async_requests.get(url, allow_redirects=False)
+    r.raise_for_status()
+    assert r.url == url
+    assert r.text == urlparse(url).path