Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/636.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.
208 changes: 49 additions & 159 deletions python/cdp/evm_local_account.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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}"
1 change: 0 additions & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down