From 48fea77b8aa5ac166df0263675204f529595fa7b Mon Sep 17 00:00:00 2001 From: k9ert <117085+k9ert@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:57:14 +0200 Subject: [PATCH] fix: validate active API tokens (#2685) --- .gitignore | 4 + src/cryptoadvance/specter/api/security.py | 18 +++- src/cryptoadvance/specter/user.py | 8 +- tests/test_jwt.py | 101 ++++++++++++++++++++++ tests/test_rest.py | 23 +++-- 5 files changed, 137 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index ba0a6ec05..321fc7c9b 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ _bmad-output/ # Claude Code local workspace .claude/ + +# Local agent workspaces +/.agents/ +/.history/ diff --git a/src/cryptoadvance/specter/api/security.py b/src/cryptoadvance/specter/api/security.py index ea03e1c00..92d297e26 100644 --- a/src/cryptoadvance/specter/api/security.py +++ b/src/cryptoadvance/specter/api/security.py @@ -40,11 +40,21 @@ def verify_token(jwt_token): return abort(401) try: payload = jwt.decode(jwt_token, app.config["SECRET_KEY"], algorithms=["HS256"]) - username = payload["username"] - the_user = app.specter.user_manager.get_user_by_username(username) - if not the_user: + username = payload.get("username") + jwt_token_id = payload.get("jwt_token_id") + if ( + not isinstance(username, str) + or not username + or not isinstance(jwt_token_id, str) + or not jwt_token_id + ): return abort(401) - g.user = app.specter.user_manager.get_user_by_username(username) + the_user = app.specter.user_manager.get_user_by_username(username) + if not the_user or not the_user.verify_jwt_token_id_and_jwt_token( + jwt_token_id, jwt_token + ): + return abort(401) + g.user = the_user logger.info({"payload": payload}) logger.info(f"Rest-Request for user {username} PASSED JWT-test") return username diff --git a/src/cryptoadvance/specter/user.py b/src/cryptoadvance/specter/user.py index 28aef4d76..5462fa807 100644 --- a/src/cryptoadvance/specter/user.py +++ b/src/cryptoadvance/specter/user.py @@ -463,10 +463,10 @@ class User(UserMixin): def verify_jwt_token_id_and_jwt_token(self, jwt_token_id, jwt_token): # Verifying the JWT token ID and JWT token - if jwt_token_id in self.jwt_tokens: - if self.jwt_tokens[jwt_token_id]["jwt_token"] == jwt_token: - return True - return False + if not isinstance(self.jwt_tokens, dict) or not isinstance(jwt_token_id, str): + return False + token_info = self.jwt_tokens.get(jwt_token_id) + return isinstance(token_info, dict) and token_info.get("jwt_token") == jwt_token def get_jwt_token(self, jwt_token_id): # Getting a JWT token from the hashmap by ID diff --git a/tests/test_jwt.py b/tests/test_jwt.py index f8349fd48..fd3e1c51d 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -82,6 +82,99 @@ def test_token_endpoints(client, empty_data_folder, caplog): assert data["jwt_token_life"] == 360 jwt_token_id = data["jwt_token_id"] + jwt_token = data["jwt_token"] + + # API-created tokens remain registered after the user store is reloaded. + client.application.specter.user_manager.update() + + # An active, registered token authenticates successfully. The missing wallet + # is rejected by authorization after authentication has completed. + token_headers = {"Authorization": "Bearer " + jwt_token} + response = client.get( + "/api/v1alpha/wallets/missing/psbt", + follow_redirects=True, + headers=token_headers, + ) + assert response.status_code == 403 + + # A signed token must still be registered in the user's active token store. + unregistered_token = User.generate_jwt_token( + "someuser", User.generate_token_id(), "unregistered", 360 + ) + response = client.get( + "/api/v1alpha/wallets/missing/psbt", + follow_redirects=True, + headers={"Authorization": "Bearer " + unregistered_token}, + ) + assert response.status_code == 401 + + # Missing and non-string token identifiers fail closed. + expiry = datetime.datetime.utcnow() + datetime.timedelta(seconds=360) + invalid_payloads = [ + {"username": "someuser", "exp": expiry}, + {"username": "someuser", "jwt_token_id": ["invalid"], "exp": expiry}, + ] + for invalid_payload in invalid_payloads: + invalid_token = jwt.encode( + invalid_payload, + client.application.config["SECRET_KEY"], + algorithm="HS256", + ) + response = client.get( + "/api/v1alpha/wallets/missing/psbt", + follow_redirects=True, + headers={"Authorization": "Bearer " + invalid_token}, + ) + assert response.status_code == 401 + + # A different signed token cannot borrow an active token's identifier. + mismatched_token = User.generate_jwt_token( + "someuser", jwt_token_id, "mismatched", 360 + ) + response = client.get( + "/api/v1alpha/wallets/missing/psbt", + follow_redirects=True, + headers={"Authorization": "Bearer " + mismatched_token}, + ) + assert response.status_code == 401 + + # Malformed persisted records fail closed instead of raising an error. + user_details = client.application.specter.user_manager.get_user_by_username( + "someuser" + ) + stored_token_info = user_details.jwt_tokens[jwt_token_id] + user_details.jwt_tokens[jwt_token_id] = {} + user_details.save_info() + client.application.specter.user_manager.update() + response = client.get( + "/api/v1alpha/wallets/missing/psbt", + follow_redirects=True, + headers=token_headers, + ) + assert response.status_code == 401 + user_details = client.application.specter.user_manager.get_user_by_username( + "someuser" + ) + user_details.jwt_tokens[jwt_token_id] = stored_token_info + user_details.save_info() + + # A malformed persisted token container also fails closed after reload. + stored_tokens = user_details.jwt_tokens + user_details.jwt_tokens = [] + user_details.save_info() + client.application.specter.user_manager.update() + response = client.get( + "/api/v1alpha/wallets/missing/psbt", + follow_redirects=True, + headers=token_headers, + ) + assert response.status_code == 401 + user_details = client.application.specter.user_manager.get_user_by_username( + "someuser" + ) + user_details.jwt_tokens = stored_tokens + user_details.save_info() + client.application.specter.user_manager.update() # testing GET request response = client.get("/api/v1alpha/token", follow_redirects=True, headers=headers) @@ -133,6 +226,14 @@ def test_token_endpoints(client, empty_data_folder, caplog): data = json.loads(response.data) assert data["message"] == "Token deleted" + # Deletion immediately revokes the bearer token. + response = client.get( + "/api/v1alpha/wallets/missing/psbt", + follow_redirects=True, + headers=token_headers, + ) + assert response.status_code == 401 + # retry accessing a deleted token response = client.get( "/api/v1alpha/token/" + jwt_token_id, follow_redirects=True, headers=headers diff --git a/tests/test_rest.py b/tests/test_rest.py index 1f2ba2792..9dfb3ae84 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -17,6 +17,17 @@ from cryptoadvance.specter.user import User logger = logging.getLogger(__name__) +def create_registered_jwt_token(specter, username): + user = specter.user_manager.get_user_by_username(username) + jwt_token_id = User.generate_token_id() + jwt_token_life = 3600 + jwt_token = User.generate_jwt_token( + username, jwt_token_id, "test token", jwt_token_life + ) + user.add_jwt_token(jwt_token_id, jwt_token, "test token", jwt_token_life) + return jwt_token + + def almost_equal(a: Number, b: Number, precision: float = 0.01) -> bool: """ Checks if a and b are not very different. @@ -58,9 +69,7 @@ def test_rr_psbt_get(client, specter_regtest_configured, bitcoin_regtest, caplog # Admin but not authorized (admin is NOT allowed to read everything) headers = { "Authorization": "Bearer " - + User.generate_jwt_token( - "admin", "tokenid", "tokendescription", random.randrange(100, 200) - ) + + create_registered_jwt_token(specter_regtest_configured, "admin") } result = client.get( "/api/v1alpha/wallets/a_simple_wallet/psbt", @@ -76,9 +85,7 @@ def test_rr_psbt_get(client, specter_regtest_configured, bitcoin_regtest, caplog # Proper authorized (the wallet is owned by someuser) headers = { "Authorization": "Bearer " - + User.generate_jwt_token( - "someuser", "tokenid", "tokendescription", random.randrange(100, 200) - ) + + create_registered_jwt_token(specter_regtest_configured, "someuser") } result = client.get( "/api/v1alpha/wallets/a_simple_wallet/psbt", @@ -97,9 +104,7 @@ def test_rr_psbt_post(specter_regtest_configured, bitcoin_regtest, client, caplo headers = { "Authorization": "Bearer " - + User.generate_jwt_token( - "someuser", "tokenid", "tokendescription", random.randrange(100, 200) - ), + + create_registered_jwt_token(specter_regtest_configured, "someuser"), "Content-type": "application/json", }