diff --git a/docs/topic/security.rst b/docs/topic/security.rst index 6734205..dd6a508 100644 --- a/docs/topic/security.rst +++ b/docs/topic/security.rst @@ -18,7 +18,10 @@ permissions. #. The unix user account created for a JupyterHub user named ```` is ``jupyter-``. This prefix helps prevent clashes with users that already exist - otherwise a user named ``root`` can trivially gain full root - access to your server. + access to your server. If the username (including the ``jupyter-`` prefix) + is longer than 26 characters, it is truncated at 26 characters & a 5 charcter + hash is appeneded to it. This keeps usernames under the linux username limit + of 32 characters while also reducing chances of collision. #. A home directory is created for the user under ``/home/jupyter-``. diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index 9d63380..1f3228e 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -8,6 +8,8 @@ import asyncio import pwd import grp import sys +import subprocess +from tljh.normalize import generate_system_username # Use sudo to invoke it, since this is how users invoke it. @@ -112,4 +114,40 @@ async def test_user_admin_remove(): await u.ensure_server() # Assert that the user does *not* have admin rights - assert f'jupyter-{username}' in grp.getgrnam('jupyterhub-admins').gr_mem \ No newline at end of file + assert f'jupyter-{username}' in grp.getgrnam('jupyterhub-admins').gr_mem + + +@pytest.mark.asyncio +async def test_long_username(): + """ + User with a long name logs in, and we check if their name is properly truncated. + """ + # This *must* be localhost, not an IP + # aiohttp throws away cookies if we are connecting to an IP! + hub_url = 'http://localhost' + username = secrets.token_hex(32) + + assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'auth.type', 'dummyauthenticator.DummyAuthenticator')).wait() + assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() + + # FIXME: wait for reload to finish & hub to come up + # Should be part of tljh-config reload + await asyncio.sleep(1) + try: + async with User(username, hub_url, partial(login_dummy, password='')) as u: + await u.login() + await u.ensure_server() + + # Assert that the user exists + system_username = generate_system_username(f'jupyter-{username}') + assert pwd.getpwnam(system_username) is not None + + await u.stop_server() + except: + # If we have any errors, print jupyterhub logs before exiting + subprocess.check_call([ + 'journalctl', + '-u', 'jupyterhub', + '--no-pager' + ]) + raise \ No newline at end of file diff --git a/tests/test_normalize.py b/tests/test_normalize.py new file mode 100644 index 0000000..9676365 --- /dev/null +++ b/tests/test_normalize.py @@ -0,0 +1,24 @@ +""" +Test functions for normalizing various kinds of values +""" +from tljh.normalize import generate_system_username + + +def test_generate_username(): + """ + Test generating system usernames from hub usernames + """ + usernames = { + # Very short + 'jupyter-test': 'jupyter-test', + # Very long + 'jupyter-aelie9sohjeequ9iemeipuimuoshahz4aitugiuteeg4ohioh5yuiha6aei7te5z': 'jupyter-aelie9sohjeequ9iem-4b726', + # 26 characters, just below our cutoff for hashing + 'jupyter-abcdefghijklmnopq': 'jupyter-abcdefghijklmnopq', + # 27 characters, just above our cutoff for hashing + 'jupyter-abcdefghijklmnopqr': 'jupyter-abcdefghijklmnopqr-e375e', + + } + for hub_user, system_user in usernames.items(): + assert generate_system_username(hub_user) == system_user + assert len(system_user) <= 32 \ No newline at end of file diff --git a/tljh/jupyterhub_config.py b/tljh/jupyterhub_config.py index 5ad957b..62c6db4 100644 --- a/tljh/jupyterhub_config.py +++ b/tljh/jupyterhub_config.py @@ -7,17 +7,26 @@ import yaml from glob import glob from systemdspawner import SystemdSpawner -from tljh import user, configurer +from tljh import configurer, user from tljh.config import INSTALL_PREFIX, USER_ENV_PREFIX, CONFIG_DIR +from tljh.normalize import generate_system_username -class CustomSpawner(SystemdSpawner): +class UserCreatingSpawner(SystemdSpawner): + """ + SystemdSpawner with user creation on spawn. + + FIXME: Remove this somehow? + """ def start(self): """ Perform system user activities before starting server """ # FIXME: Move this elsewhere? Into the Authenticator? - system_username = 'jupyter-' + self.user.name + system_username = generate_system_username('jupyter-' + self.user.name) + + # FIXME: This is a hack. Allow setting username directly instead + self.username_template = system_username user.ensure_user(system_username) user.ensure_user_group(system_username, 'jupyterhub-users') if self.user.admin: @@ -26,8 +35,7 @@ class CustomSpawner(SystemdSpawner): user.remove_user_group(system_username, 'jupyterhub-admins') return super().start() - -c.JupyterHub.spawner_class = CustomSpawner +c.JupyterHub.spawner_class = UserCreatingSpawner # leave users running when the Hub restarts c.JupyterHub.cleanup_servers = False diff --git a/tljh/normalize.py b/tljh/normalize.py new file mode 100644 index 0000000..bba2b41 --- /dev/null +++ b/tljh/normalize.py @@ -0,0 +1,24 @@ +""" +Functions to normalize various inputs +""" +import hashlib + + +def generate_system_username(username): + """ + Generate a posix username from given username. + + If username < 26 char, we just return it. + Else, we hash the username, truncate username at + 26 char, append a '-' and first add 5char of hash. + This makes sure our usernames are always under 32char. + """ + + if len(username) < 26: + return username + + userhash = hashlib.sha256(username.encode('utf-8')).hexdigest() + return '{username_trunc}-{hash}'.format( + username_trunc=username[:26], + hash=userhash[:5] + ) \ No newline at end of file