Improper Access Control in jupyterhub-firstuseauthenticator
Description
FirstUseAuthenticator before 1.0.0 allows unauthorized account access by setting a password for any known username when user creation is enabled.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
FirstUseAuthenticator before 1.0.0 allows unauthorized account access by setting a password for any known username when user creation is enabled.
Vulnerability
The vulnerability exists in JupyterHub's FirstUseAuthenticator versions prior to 1.0.0. When create_users=True, any username that is known or guessed can be used to set a password on the first login, bypassing authentication [1][2].
Exploitation
An attacker needs only a valid username (or to guess one) and network access to the JupyterHub instance. They can navigate to the login page, enter the username, and set a password without any prior credentials [2].
Impact
Successful exploitation grants the attacker full access to the victim's JupyterHub account, including any associated resources, notebooks, or data [2].
Mitigation
Upgrade to version 1.0.0 [1][2]. If upgrade is not possible, set c.FirstUseAuthenticator.create_users = False as a partial workaround, though users who never logged in with a normalized username remain vulnerable until patched [2]. The patch also includes a startup check to normalize existing usernames [1].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
jupyterhub-firstuseauthenticatorPyPI | < 1.0.0 | 1.0.0 |
Affected products
2- jupyterhub/firstuseauthenticatorv5Range: < 1.0.0
Patches
1953418e2450dMerge pull request from GHSA-5xvc-vgmp-jgc3
2 files changed · +293 −18
firstuseauthenticator/firstuseauthenticator.py+173 −16 modified@@ -5,8 +5,11 @@ password for that account. It is hashed with bcrypt & stored locally in a dbm file, and checked next time they log in. """ -import dbm import os +import shutil + +import bcrypt +import dbm from jinja2 import ChoiceLoader, FileSystemLoader from jupyterhub.auth import Authenticator from jupyterhub.handlers import BaseHandler @@ -16,8 +19,6 @@ from tornado import web from traitlets.traitlets import Unicode, Bool, Integer -import bcrypt - TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), 'templates') @@ -45,7 +46,6 @@ def __init__(self, *args, **kwargs): self._loaded = False super().__init__(*args, **kwargs) - def _register_template_path(self): if self._loaded: return @@ -59,14 +59,12 @@ def _register_template_path(self): self._loaded = True - @web.authenticated async def get(self): self._register_template_path() html = await self.render_template('reset.html') self.finish(html) - @web.authenticated async def post(self): user = self.current_user @@ -120,6 +118,164 @@ class FirstUseAuthenticator(Authenticator): """ ) + check_passwords_on_startup = Bool( + True, + config=True, + help=""" + Check for non-normalized-username passwords on startup. + + Prior to 1.0, multiple passwords could be set for the same username, + without normalization. + + When True, duplicate usernames will be detected and removed, + and ensure all usernames are normalized. + + If any duplicates are found, a backup of the original is created, + which can be inspected manually. + + Typically, this will only need to run once. + """, + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self.check_passwords_on_startup: + self._check_passwords() + + def _check_passwords(self): + """Validation checks on the password database at startup + + Mainly checks for the presence of passwords for non-normalized usernames + + If a username is present only in one non-normalized form, + it will be renamed to the normalized form. + + If multiple forms of the same normalized username are present, + ensure that at least the normalized form is also present. + It will continue to produce warnings until manual intervention removes the non-normalized entries. + + Non-normalized entries will never be used during login. + """ + + # it's nontrival to check for db existence, because there are so many extensions + # and you don't give dbm a path, you give it a *base* name, + # which may point to one or more paths. + # There's no way to retrieve the actual path(s) for a db + dbm_extensions = ("", ".db", ".pag", ".dir", ".dat", ".bak") + dbm_files = list( + filter(os.path.isfile, (self.dbm_path + ext for ext in dbm_extensions)) + ) + if not dbm_files: + # no database, nothing to do + return + + backup_path = self.dbm_path + "-backup" + backup_files = list( + filter(os.path.isfile, (backup_path + ext for ext in dbm_extensions)) + ) + + collision_warning = ( + f"Duplicate password entries have been found, and stored in {backup_path!r}." + f" Duplicate entries have been removed from {self.dbm_path!r}." + f" If you are happy with the solution, you can delete the backup file(s): {' '.join(backup_files)}." + " Or you can inspect the backup database with:\n" + " import dbm\n" + f" with dbm.open({backup_path!r}, 'r') as db:\n" + " for username in db.keys():\n" + " print(username, db[username])\n" + ) + + if backup_files: + self.log.warning(collision_warning) + return + + # create a temporary backup of the passwords db + # to be retained only if collisions are detected + # or deleted if no collisions are detected + backup_files = [] + for path in dbm_files: + base, ext = os.path.splitext(path) + if ext not in dbm_extensions: + # catch weird names with '.' and no .db extension + base = path + ext = "" + backup = f"{base}-backup{ext}" + shutil.copyfile(path, backup) + backup_files.append(backup) + + collision_found = False + + with dbm.open(self.dbm_path, "w") as db: + # load the username:hashed_password dict + passwords = {} + for key in db.keys(): + passwords[key.decode("utf8")] = db[key] + + # normalization map + # compute the full map before checking in case two non-normalized forms are used + # keys are normalized usernames, + # values are lists of all names present in the db + # which normalize to the same user + normalized_usernames = {} + for username in passwords: + normalized_username = self.normalize_username(username) + normalized_usernames.setdefault(normalized_username, []).append( + username + ) + + # check if any non-normalized usernames are in the db + for normalized_username, usernames in normalized_usernames.items(): + # case 1. only one form, make sure it's stored in the normalized username + if len(usernames) == 1: + username = usernames[0] + # case 1.a only normalized form, nothing to do + if username == normalized_username: + continue + # 1.b only one form, not normalized. Unambiguous to fix. + # move password from non-normalized to normalized. + self.log.warning( + f"Normalizing username in password db {username}->{normalized_username}" + ) + db[normalized_username.encode("utf8")] = passwords[username] + del db[username] + else: + # collision! Multiple passwords for the same Hub user with different normalization + # do not clear these automatically because the 'right' answer is ambiguous, + # but make sure the normalized_username is set, + # so that after upgrade, there is always a password set + # the non-normalized username passwords will never be used + # after jupyterhub-firstuseauthenticator 1.0 + self.log.warning( + f"{len(usernames)} variations of the username {normalized_username} present in password database: {usernames}." + f" Only the password stored for the normalized {normalized_username} will be used." + ) + collision_found = True + if normalized_username not in passwords: + # we choose usernames[0] as most likely to be the first entry + # this isn't guaranteed, but it's the best information we have + username = usernames[0] + self.log.warning( + f"Normalizing username in password db {username}->{normalized_username}" + ) + db[normalized_username.encode("utf8")] = passwords[username] + for username in usernames: + if username != normalized_username: + self.log.warning( + f"Removing un-normalized username from password db {username}" + ) + del db[username] + + if collision_found: + self.log.warning(collision_warning) + else: + # remove backup files, if we didn't find anything to backup + self.log.debug(f"No collisions found, removing backup files {backup_files}") + for path in backup_files: + try: + os.remove(path) + except FileNotFoundError: + pass + def _user_exists(self, username): """ Return true if given user already exists. @@ -141,19 +297,19 @@ def validate_username(self, name): return super().validate_username(name) async def authenticate(self, handler, data): - username = self.normalize_username(data['username']) - password = data['password'] + username = self.normalize_username(data["username"]) + password = data["password"] if not self.create_users: if not self._user_exists(username): return None with dbm.open(self.dbm_path, 'c', 0o600) as db: - stored_pw = db.get(username.encode(), None) + stored_pw = db.get(username.encode("utf8"), None) if stored_pw is not None: # for existing passwords: ensure password hash match - if bcrypt.hashpw(password.encode(), stored_pw) != stored_pw: + if bcrypt.hashpw(password.encode("utf8"), stored_pw) != stored_pw: return None else: # for new users: ensure password validity and store password hash @@ -164,7 +320,7 @@ async def authenticate(self, handler, data): ) self.log.error(handler.custom_login_error) return None - db[username] = bcrypt.hashpw(password.encode(), bcrypt.gensalt()) + db[username] = bcrypt.hashpw(password.encode("utf8"), bcrypt.gensalt()) return username @@ -181,7 +337,6 @@ def delete_user(self, user): except KeyError: pass - def reset_password(self, username, new_password): """ This allows changing the password of a logged user. @@ -194,12 +349,14 @@ def reset_password(self, username, new_password): self.log.error(login_err) # Resetting the password will fail if the new password is too short. return login_err - with dbm.open(self.dbm_path, 'c', 0o600) as db: - db[username] = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt()) + with dbm.open(self.dbm_path, "c", 0o600) as db: + db[username] = bcrypt.hashpw(new_password.encode("utf8"), bcrypt.gensalt()) login_msg = "Your password has been changed successfully!" self.log.info(login_msg) return login_msg - def get_handlers(self, app): - return [(r'/login', CustomLoginHandler), (r'/auth/change-password', ResetPasswordHandler)] + return [ + (r"/login", CustomLoginHandler), + (r"/auth/change-password", ResetPasswordHandler), + ]
tests/test_authenticator.py+120 −2 modified@@ -1,9 +1,10 @@ """tests for first-use authenticator""" -import pytest -import logging from unittest import mock +import dbm +import pytest + from firstuseauthenticator import FirstUseAuthenticator @@ -74,3 +75,120 @@ def user_exists(username): 'Password too short! Please choose a password at least %d characters long.' % auth.min_password_length ) + + +async def test_normalized_check(caplog, tmpcwd): + # cases: + # 1.a - normalized + # 1.b not normalized, no collision + # 2.a normalized present, collision + # 2.b normalized not present, collision + # disable normalization, populate db with duplicates + to_load = [ + "onlynormalized", + "onlyNotNormalized", + "collisionnormalized", + "collisionNormalized", + "collisionNotNormalized", + "collisionNotnormalized", + ] + + # load passwords + auth1 = FirstUseAuthenticator() + with mock.patch.object(auth1, "normalize_username", lambda x: x): + for username in to_load: + assert await auth1.authenticate( + mock.Mock(), + { + "username": username, + "password": username, + }, + ) + + # first make sure normalization was skipped + with dbm.open(auth1.dbm_path) as db: + for username in to_load: + assert db.get(username.encode("utf8")) + # at startup, normalization is checked + auth2 = FirstUseAuthenticator() + with dbm.open(auth1.dbm_path) as db: + passwords = {key.decode("utf8"): db[key].decode("utf8") for key in db.keys()} + in_db = set(passwords) + # 1.a no-op + assert "onlynormalized" in in_db + # 1.b renamed + assert "onlynotnormalized" in in_db + assert "onlyNotNormalized" not in in_db + # 2.a collision, preserve normalized + assert "collisionnormalized" in in_db + assert "collisionNormalized" not in in_db + # 2.b collision, preserve and add normalized + assert "collisionnotnormalized" in in_db + assert "collisionNotNormalized" not in in_db + assert "collisionNotnormalized" not in in_db + + # check the backup + with dbm.open(auth1.dbm_path + "-backup") as db: + backup_passwords = { + key.decode("utf8"): db[key].decode("utf8") for key in db.keys() + } + + for name in to_load: + assert name in backup_passwords + + # now verify logins + m = mock.Mock() + for username, password in ( + ("onlynormalized", "onlynormalized"), + ("onlyNormalized", "onlynormalized"), + ("onlynotnormalized", "onlyNotNormalized"), + ("onlyNotNormalized", "onlyNotNormalized"), + ("collisionnormalized", "collisionnormalized"), + ("collisionNormalized", "collisionnormalized"), + ("collisionnotnormalized", "collisionNotNormalized"), + ("collisionNotNormalized", "collisionNotNormalized"), + ): + # normalized form, doesn't reset password + authenticated = await auth2.authenticate( + m, + { + "username": username, + "password": "firstuse", + }, + ) + assert authenticated is None + + # non-normalized form, doesn't reset password + authenticated = await auth2.authenticate( + m, + { + "username": username.upper(), + "password": "firstuse", + }, + ) + assert authenticated is None + + # normalized form, accepts correct password + authenticated = await auth2.authenticate( + m, + { + "username": username, + "password": password, + }, + ) + assert authenticated + assert authenticated == auth2.normalize_username(username) + + # non-normalized form, accepts correct password + authenticated = await auth2.authenticate( + m, + { + "username": username.upper(), + "password": password, + }, + ) + assert authenticated + assert authenticated == auth2.normalize_username(username) + + # load again, should skip the + auth3 = FirstUseAuthenticator()
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
9- github.com/advisories/GHSA-5xvc-vgmp-jgc3ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-41194ghsaADVISORY
- github.com/jupyterhub/firstuseauthenticator/commit/953418e2450dbc2d854e332350849533b0ebc7baghsaWEB
- github.com/jupyterhub/firstuseauthenticator/pull/38ghsax_refsource_MISCWEB
- github.com/jupyterhub/firstuseauthenticator/pull/38.patchghsax_refsource_MISCWEB
- github.com/jupyterhub/firstuseauthenticator/pull/38/commits/32b21898fb2b53b1a2e36270de6854ad70e9e9bfghsaWEB
- github.com/jupyterhub/firstuseauthenticator/pull/38/commits/9e200d974e0cb85d828a6afedb8ab90a37878f28ghsaWEB
- github.com/jupyterhub/firstuseauthenticator/security/advisories/GHSA-5xvc-vgmp-jgc3ghsax_refsource_CONFIRMWEB
- github.com/pypa/advisory-database/tree/main/vulns/jupyterhub-firstuseauthenticator/PYSEC-2021-384.yamlghsaWEB
News mentions
0No linked articles in our index yet.