diff --git a/changelog.d/636.bugfix.md b/changelog.d/636.bugfix.md new file mode 100644 index 000000000..0cb131a4e --- /dev/null +++ b/changelog.d/636.bugfix.md @@ -0,0 +1 @@ +Fixed security vulnerability GHSA-5hr4-253g-cpx2 (SSRF via CCIP Read) by updating web3 dependency from `<=7.10.0` to `<8.0.0`. The security fix was released in web3.py v7.15.0. \ No newline at end of file diff --git a/python/cdp/evm_local_account.py b/python/cdp/evm_local_account.py index 2c1cb0d0d..2b6fcc5b4 100644 --- a/python/cdp/evm_local_account.py +++ b/python/cdp/evm_local_account.py @@ -1,7 +1,6 @@ import asyncio from typing import Any -import nest_asyncio from eth_account.datastructures import SignedMessage, SignedTransaction from eth_account.messages import SignableMessage, _hash_eip191_message, encode_typed_data from eth_account.signers.base import BaseAccount @@ -11,22 +10,15 @@ from cdp.errors import UserInputValidationError from cdp.evm_server_account import EvmServerAccount -# Apply nest-asyncio to allow nested event loops, but only if compatible -try: - nest_asyncio.apply() -except ValueError as e: - # If nest_asyncio can't patch the loop (e.g., uvloop), silently continue - # This commonly happens when uvloop is installed and running - if "Can't patch loop" in str(e): - pass - else: - # Re-raise other ValueError exceptions - raise - def _run_async(coroutine): """Run an async coroutine synchronously. + This function handles running async code from synchronous contexts, + including when an event loop is already running (e.g., in Jupyter notebooks). + It uses asyncio.run() when possible, and falls back to creating a new loop + in a separate thread when already inside an async context. + Args: coroutine: The coroutine to run @@ -35,11 +27,21 @@ def _run_async(coroutine): """ try: - loop = asyncio.get_event_loop() + # Check if we're already in an event loop + loop = asyncio.get_running_loop() + if loop.is_running(): + # We're inside a running event loop (e.g., Jupyter, async context) + # Create a new loop in a separate thread to avoid conflicts + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(asyncio.run, coroutine) + return future.result() except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop.run_until_complete(coroutine) + # No event loop is running, we can use asyncio.run directly + pass + + return asyncio.run(coroutine) class EvmLocalAccount(BaseAccount): @@ -72,14 +74,24 @@ def address(self) -> str: """ return self._server_account.address + @property + def key(self) -> str: + """Get the key of the EVM server account. + + Raises: + UserInputValidationError: Always raises an error since the private key is not available. + + """ + raise UserInputValidationError( + "Private key is not available for server-side accounts. " + "Please use the sign_message or sign_transaction methods instead." + ) + def unsafe_sign_hash(self, message_hash: Hash32) -> SignedMessage: """Sign a message hash. - WARNING: Never sign a hash that you didn't generate, - it can be an arbitrary transaction. - Args: - message_hash (Hash32): The 32-byte message hash to sign. + message_hash (Hash32): The message hash to sign. Returns: SignedMessage: The signed message. @@ -97,7 +109,8 @@ def sign_message(self, signable_message: SignableMessage) -> SignedMessage: SignedMessage: The signed message. """ - return _run_async(self._server_account.sign_message(signable_message)) + message_hash = _hash_eip191_message(signable_message) + return self.unsafe_sign_hash(message_hash) def sign_transaction(self, transaction_dict: TransactionDictType) -> SignedTransaction: """Sign a transaction. @@ -113,161 +126,38 @@ def sign_transaction(self, transaction_dict: TransactionDictType) -> SignedTrans def sign_typed_data( self, - domain_data: dict[str, Any] | None = None, - message_types: dict[str, Any] | None = None, - message_data: dict[str, Any] | None = None, - full_message: dict[str, Any] | None = None, + domain: dict[str, Any], + message_types: dict[str, Any], + message_contents: dict[str, Any], ) -> SignedMessage: - """Sign typed data. - - Either provide a full message, or provide the domain data, message types, and message data. + """Sign typed data (EIP-712). Args: - domain_data (dict[str, Any], optional): The domain data. Defaults to None. - message_types (dict[str, Any], optional): The message types. Defaults to None. - message_data (dict[str, Any], optional): The message data. Defaults to None. - full_message (dict[str, Any], optional): The full message. Defaults to None. + domain (dict[str, Any]): The EIP-712 domain. + message_types (dict[str, Any]): The message types. + message_contents (dict[str, Any]): The message contents. Returns: SignedMessage: The signed message. - Raises: - UserInputValidationError: If neither full_message nor both message_types and message_data are provided. - ValueError: If the primaryType cannot be inferred from message_types. - """ - if full_message is not None: - typed_data = full_message - elif message_types is not None and message_data is not None: - primary_types = list(message_types.keys() - {"EIP712Domain"}) - if not primary_types: - raise ValueError("Could not infer primaryType from message_types") - typed_data = { - "domain": domain_data, - "types": message_types, - "primaryType": primary_types[0], - "message": message_data, - } - else: - raise UserInputValidationError( - "Must provide either full_message or both message_types and message_data" - ) - - # Include the EIP712Domain type in the types if not already present - typed_data["domain"] = typed_data.get("domain", {}) - eip712_domain_type = self._get_types_for_eip712_domain(typed_data["domain"]) - typed_data["types"] = { - "EIP712Domain": eip712_domain_type, - **typed_data["types"], - } - - # Process the message to handle bytes32 types properly - typed_data["message"] = self._process_message_bytes( - message=typed_data["message"], - types=typed_data["types"], - type_key=typed_data["primaryType"], - ) - - # https://github.com/ethereum/eth-account/blob/main/eth_account/account.py#L1047 - signable_message = encode_typed_data(full_message=typed_data) - message_hash = _hash_eip191_message(signable_message) - - return _run_async(self._server_account.unsafe_sign_hash(message_hash)) - - def _get_types_for_eip712_domain( - self, domain: dict[str, Any] | None = None - ) -> list[dict[str, str]]: - """Get types for EIP712Domain based on the domain properties that are present. - - This function dynamically generates the EIP712Domain type definition based on - which domain properties are provided. - - Args: - domain: The domain data dictionary - - Returns: - List of field definitions for EIP712Domain type - - """ - types = [] - - if domain is None: - return types - - if isinstance(domain.get("name"), str): - types.append({"name": "name", "type": "string"}) - - if domain.get("version"): - types.append({"name": "version", "type": "string"}) - - if isinstance(domain.get("chainId"), int): - types.append({"name": "chainId", "type": "uint256"}) - - if domain.get("verifyingContract"): - types.append({"name": "verifyingContract", "type": "address"}) - - if domain.get("salt"): - types.append({"name": "salt", "type": "bytes32"}) - - return types - - def _process_message_bytes( - self, - message: dict[str, Any], - types: dict[str, Any], - type_key: str, - ) -> dict[str, Any]: - """Process message data to handle bytes32 types properly. - - Args: - message: The message data - types: The type definitions - type_key: The key of the type to process - - Returns: - The processed message with bytes32 values properly encoded - - """ - - def _find_field_type(field_name: str, fields: list) -> str | None: - for field in fields: - if field["name"] == field_name: - return field["type"] - return None - - type_fields = types[type_key] - processed_message = {} - - for key, value in message.items(): - processed_message[key] = value - if isinstance(value, dict): - # Handle nested objects by recursively processing them - value_type = _find_field_type(key, type_fields) - if value_type: - processed_message[key] = self._process_message_bytes(value, types, value_type) - elif isinstance(value, bytes) and _find_field_type(key, type_fields) == "bytes32": - # Handle bytes32 values so our internal sign typed data can serialize them properly - value_str = value.hex() - processed_message[key] = ( - "0x" + value_str if not value_str.startswith("0x") else value_str - ) - - return processed_message + signable_message = encode_typed_data(domain, message_types, message_contents) + return self.sign_message(signable_message) def __str__(self) -> str: - """Return a string representation of the EthereumAccount object. + """Return a string representation of the EvmLocalAccount. Returns: - str: A string representation of the EthereumAccount. + str: A string representation of the EvmLocalAccount. """ return f"Ethereum Account Address: {self.address}" def __repr__(self) -> str: - """Return a string representation of the EthereumAccount object. + """Return a repr representation of the EvmLocalAccount. Returns: - str: A string representation of the EthereumAccount. + str: A repr representation of the EvmLocalAccount. """ - return str(self) + return f"Ethereum Account Address: {self.address}" diff --git a/python/pyproject.toml b/python/pyproject.toml index 50882105b..cbca6e237 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -17,7 +17,6 @@ dependencies = [ "web3>=7.6.0,<=7.10.0", "solana>=0.36.6", "solders>=0.26.0", - "nest-asyncio>=1.6.0,<2", "base58>=2.1.1", ]