From 0fd2d34a66a9a4b752792862a9a127cc82607441 Mon Sep 17 00:00:00 2001 From: Emin Martinian Date: Wed, 29 Oct 2025 15:38:42 -0400 Subject: [PATCH 1/4] Bugfix for issue 1566. This commit provides a fix for the bug in issue #1566. Basically we keep track of when the server side cookie secret was generated and only trust cookies created after that point. When a password change occurs, we touch the cookie secret file as a way of invalidating cookies created before the password change. We could also generate a new cookie secret which might be better but I am not familiar enough with the code to know the implications. This patch seems like the minimal way to get the bug fixed. --- jupyter_server/auth/identity.py | 20 ++++++++++++++++++++ jupyter_server/serverapp.py | 15 +++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/jupyter_server/auth/identity.py b/jupyter_server/auth/identity.py index fc4b02992..7058f40ec 100644 --- a/jupyter_server/auth/identity.py +++ b/jupyter_server/auth/identity.py @@ -14,6 +14,7 @@ import os import re import sys +import time import typing as t import uuid from dataclasses import asdict, dataclass @@ -350,6 +351,7 @@ def user_to_cookie(self, user: User) -> str: "display_name": user.display_name, "initials": user.initials, "color": user.color, + 'cookie_creation_time': time.time(), } ) return cookie @@ -357,6 +359,19 @@ def user_to_cookie(self, user: User) -> str: def user_from_cookie(self, cookie_value: str) -> User | None: """Inverse of user_to_cookie""" user = json.loads(cookie_value) + cookie_creation_time = user.get('cookie_creation_time', None) + + if cookie_creation_time is None: + self.clear_login_cookie() + raise ValueError( + 'No cookie_creation_time in cookie; must recreate cookie') + secret_creation_time = self.parent._cookie_secret_creation_time + if cookie_creation_time < secret_creation_time: + self.clear_login_cookie() + raise ValueError( + f'Stale cookie created at {cookie_creation_time};' + f' but server secret created {secret_creation_time}') + return User( user["username"], user["name"], @@ -448,6 +463,7 @@ def get_user_cookie( ) if not _user_cookie: return None + user_cookie = _user_cookie.decode() # TODO: try/catch in case of change in config? try: @@ -722,6 +738,10 @@ def process_login_form(self, handler: web.RequestHandler) -> User | None: config_file = os.path.join(config_dir, "jupyter_server_config.json") self.hashed_password = set_password(new_password, config_file=config_file) self.log.info(_i18n("Wrote hashed password to {file}").format(file=config_file)) + self.parent._write_cookie_secret_file( + self.parent.cookie_secret) + self.log.info(_i18n("Touched cookie secret file to update" + " server secert time")) return user diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 1afbef4d0..88a36f5c9 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -611,6 +611,10 @@ def start(self): from jupyter_server.auth.security import set_password set_password(config_file=self.config_file) + self.parent._write_cookie_secret_file( + self.parent.cookie_secret) + self.log.info(_i18n("Touched cookie secret file to update" + " server secert time")) self.log.info("Wrote hashed password to %s" % self.config_file) @@ -1159,6 +1163,13 @@ def _default_cookie_secret_file(self) -> str: """, ) + # If the server side cookie secret is changed/created then we should + # not trust cookies created before that. We can use this as a way + # to invalidate old cookies when a password is changed by rewriting + # the cookie secret again (without necessarily changing it). + # See https://github.com/jupyter-server/jupyter_server/issues/1566 + _cookie_secret_creation_time = None + @default("cookie_secret") def _default_cookie_secret(self) -> bytes: if os.path.exists(self.cookie_secret_file): @@ -1167,6 +1178,8 @@ def _default_cookie_secret(self) -> bytes: else: key = encodebytes(os.urandom(32)) self._write_cookie_secret_file(key) + self._cookie_secret_creation_time = os.stat( + self.cookie_secret_file).st_mtime h = hmac.new(key, digestmod=hashlib.sha256) h.update(self.password.encode()) return h.digest() @@ -1183,6 +1196,8 @@ def _write_cookie_secret_file(self, secret: bytes) -> None: self.cookie_secret_file, e, ) + self._cookie_secret_creation_time = os.stat( + self.cookie_secret_file) _token_set = False From 741f2fcbd1be54c941adff5687473317af8bc729 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 19:41:07 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- jupyter_server/auth/identity.py | 20 +++++++++----------- jupyter_server/serverapp.py | 12 ++++-------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/jupyter_server/auth/identity.py b/jupyter_server/auth/identity.py index 7058f40ec..e25f162a3 100644 --- a/jupyter_server/auth/identity.py +++ b/jupyter_server/auth/identity.py @@ -351,7 +351,7 @@ def user_to_cookie(self, user: User) -> str: "display_name": user.display_name, "initials": user.initials, "color": user.color, - 'cookie_creation_time': time.time(), + "cookie_creation_time": time.time(), } ) return cookie @@ -359,18 +359,18 @@ def user_to_cookie(self, user: User) -> str: def user_from_cookie(self, cookie_value: str) -> User | None: """Inverse of user_to_cookie""" user = json.loads(cookie_value) - cookie_creation_time = user.get('cookie_creation_time', None) + cookie_creation_time = user.get("cookie_creation_time", None) if cookie_creation_time is None: self.clear_login_cookie() - raise ValueError( - 'No cookie_creation_time in cookie; must recreate cookie') + raise ValueError("No cookie_creation_time in cookie; must recreate cookie") secret_creation_time = self.parent._cookie_secret_creation_time if cookie_creation_time < secret_creation_time: self.clear_login_cookie() raise ValueError( - f'Stale cookie created at {cookie_creation_time};' - f' but server secret created {secret_creation_time}') + f"Stale cookie created at {cookie_creation_time};" + f" but server secret created {secret_creation_time}" + ) return User( user["username"], @@ -463,7 +463,7 @@ def get_user_cookie( ) if not _user_cookie: return None - + user_cookie = _user_cookie.decode() # TODO: try/catch in case of change in config? try: @@ -738,10 +738,8 @@ def process_login_form(self, handler: web.RequestHandler) -> User | None: config_file = os.path.join(config_dir, "jupyter_server_config.json") self.hashed_password = set_password(new_password, config_file=config_file) self.log.info(_i18n("Wrote hashed password to {file}").format(file=config_file)) - self.parent._write_cookie_secret_file( - self.parent.cookie_secret) - self.log.info(_i18n("Touched cookie secret file to update" - " server secert time")) + self.parent._write_cookie_secret_file(self.parent.cookie_secret) + self.log.info(_i18n("Touched cookie secret file to update" " server secert time")) return user diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 88a36f5c9..1bc234047 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -611,10 +611,8 @@ def start(self): from jupyter_server.auth.security import set_password set_password(config_file=self.config_file) - self.parent._write_cookie_secret_file( - self.parent.cookie_secret) - self.log.info(_i18n("Touched cookie secret file to update" - " server secert time")) + self.parent._write_cookie_secret_file(self.parent.cookie_secret) + self.log.info(_i18n("Touched cookie secret file to update" " server secert time")) self.log.info("Wrote hashed password to %s" % self.config_file) @@ -1178,8 +1176,7 @@ def _default_cookie_secret(self) -> bytes: else: key = encodebytes(os.urandom(32)) self._write_cookie_secret_file(key) - self._cookie_secret_creation_time = os.stat( - self.cookie_secret_file).st_mtime + self._cookie_secret_creation_time = os.stat(self.cookie_secret_file).st_mtime h = hmac.new(key, digestmod=hashlib.sha256) h.update(self.password.encode()) return h.digest() @@ -1196,8 +1193,7 @@ def _write_cookie_secret_file(self, secret: bytes) -> None: self.cookie_secret_file, e, ) - self._cookie_secret_creation_time = os.stat( - self.cookie_secret_file) + self._cookie_secret_creation_time = os.stat(self.cookie_secret_file) _token_set = False From 4fa9535a110a4b89aa89a89081ad9c768f9b2f9d Mon Sep 17 00:00:00 2001 From: Emin Martinian Date: Wed, 29 Oct 2025 16:17:23 -0400 Subject: [PATCH 3/4] fix errors --- jupyter_server/auth/identity.py | 8 ++++---- jupyter_server/auth/login.py | 1 - jupyter_server/serverapp.py | 9 ++++++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/jupyter_server/auth/identity.py b/jupyter_server/auth/identity.py index 7058f40ec..23340be3c 100644 --- a/jupyter_server/auth/identity.py +++ b/jupyter_server/auth/identity.py @@ -361,13 +361,13 @@ def user_from_cookie(self, cookie_value: str) -> User | None: user = json.loads(cookie_value) cookie_creation_time = user.get('cookie_creation_time', None) - if cookie_creation_time is None: - self.clear_login_cookie() + if not cookie_creation_time: raise ValueError( 'No cookie_creation_time in cookie; must recreate cookie') secret_creation_time = self.parent._cookie_secret_creation_time + if not secret_creation_time: + raise ValueError('Secret creation time not set') if cookie_creation_time < secret_creation_time: - self.clear_login_cookie() raise ValueError( f'Stale cookie created at {cookie_creation_time};' f' but server secret created {secret_creation_time}') @@ -741,7 +741,7 @@ def process_login_form(self, handler: web.RequestHandler) -> User | None: self.parent._write_cookie_secret_file( self.parent.cookie_secret) self.log.info(_i18n("Touched cookie secret file to update" - " server secert time")) + " server secret time")) return user diff --git a/jupyter_server/auth/login.py b/jupyter_server/auth/login.py index 451212753..559ef40b2 100644 --- a/jupyter_server/auth/login.py +++ b/jupyter_server/auth/login.py @@ -214,7 +214,6 @@ def get_user(cls, handler): # because that can erroneously log you out (see gh-3365) if handler.get_cookie(handler.cookie_name) is not None: handler.log.warning("Clearing invalid/expired login cookie %s", handler.cookie_name) - handler.clear_login_cookie() if not handler.login_available: # Completely insecure! No authentication at all. # No need to warn here, though; validate_security will have already done that. diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 88a36f5c9..4f16a94da 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -610,11 +610,14 @@ def start(self): """Start the password app.""" from jupyter_server.auth.security import set_password + if self.parent is None: + raise ValueError( + 'Unable to change password without parent app') set_password(config_file=self.config_file) self.parent._write_cookie_secret_file( self.parent.cookie_secret) self.log.info(_i18n("Touched cookie secret file to update" - " server secert time")) + " server secret time")) self.log.info("Wrote hashed password to %s" % self.config_file) @@ -1168,7 +1171,7 @@ def _default_cookie_secret_file(self) -> str: # to invalidate old cookies when a password is changed by rewriting # the cookie secret again (without necessarily changing it). # See https://github.com/jupyter-server/jupyter_server/issues/1566 - _cookie_secret_creation_time = None + _cookie_secret_creation_time = 0 @default("cookie_secret") def _default_cookie_secret(self) -> bytes: @@ -1197,7 +1200,7 @@ def _write_cookie_secret_file(self, secret: bytes) -> None: e, ) self._cookie_secret_creation_time = os.stat( - self.cookie_secret_file) + self.cookie_secret_file).st_mtime _token_set = False From f8d83f6e7fad7559baf11c54fd4ab36534c7c8f9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:21:47 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- jupyter_server/auth/identity.py | 3 +-- jupyter_server/serverapp.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/jupyter_server/auth/identity.py b/jupyter_server/auth/identity.py index 35ef12ae0..25d5e7c96 100644 --- a/jupyter_server/auth/identity.py +++ b/jupyter_server/auth/identity.py @@ -366,7 +366,7 @@ def user_from_cookie(self, cookie_value: str) -> User | None: raise ValueError("No cookie_creation_time in cookie; must recreate cookie") secret_creation_time = self.parent._cookie_secret_creation_time if not secret_creation_time: - raise ValueError('Secret creation time not set') + raise ValueError("Secret creation time not set") if cookie_creation_time < secret_creation_time: raise ValueError( f"Stale cookie created at {cookie_creation_time};" @@ -742,7 +742,6 @@ def process_login_form(self, handler: web.RequestHandler) -> User | None: self.parent._write_cookie_secret_file(self.parent.cookie_secret) self.log.info(_i18n("Touched cookie secret file to update server secret time")) - return user def validate_security( diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 04db4e592..f892787ad 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -611,8 +611,7 @@ def start(self): from jupyter_server.auth.security import set_password if self.parent is None: - raise ValueError( - 'Unable to change password without parent app') + raise ValueError("Unable to change password without parent app") set_password(config_file=self.config_file) self.parent._write_cookie_secret_file(self.parent.cookie_secret) self.log.info(_i18n("Touched cookie secret file to update server secret time"))