mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-09-13 08:00:16 +00:00
python3-web3: Fix CVE-2026-40072
This patch applies the v7 upstream fix for CVE-2026-40072 shown in [1]. The security advisory [2] references the patch-equivalent v8/main fix [3]. The v7 commit was selected because it is the nearest source baseline to Scarthgap's web3.py 6.17.0 recipe. [1] https://github.com/ApeWorX/web3.py/commit/d62e67d3b636bd4c5a929696c0f5c4167c31625b [2] https://github.com/ApeWorX/web3.py/security/advisories/GHSA-5hr4-253g-cpx2 [3] https://github.com/ApeWorX/web3.py/commit/b1c57bb0a124359c9902daaefab4d8af7c3c4c1e Signed-off-by: Hetvi Thakar <hthakar@cisco.com> Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
From 21ee858ea75287d781eb0a878d9463346da648b3 Mon Sep 17 00:00:00 2001
|
||||
From: fselmo <fselmo2@gmail.com>
|
||||
Date: Fri, 13 Mar 2026 15:38:09 -0600
|
||||
Subject: [PATCH] feat: added restrictions on CCIP read durin calls
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
SSRF Mitigation for CCIP Read:
|
||||
|
||||
- validate_ccip_url_scheme() — HTTPS-only by default; HTTP allowed via opt-in
|
||||
- validate_ccip_url_host() / async_validate_ccip_url_host() — resolves hostname and blocks private/reserved IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, etc.)
|
||||
- Type aliases: CcipUrlValidator, AsyncCcipUrlValidator
|
||||
- Provider config (base.py, async_base.py):
|
||||
- ccip_read_allow_http: bool = False
|
||||
- ccip_read_url_validator — optional user-supplied hook to reject/allow URLs
|
||||
- Handler changes (exception_handling.py, async_exception_handling.py):
|
||||
- Scheme + host validation before each HTTP request
|
||||
- allow_redirects=False on all requests
|
||||
- Validation failures continue to next URL (consistent with existing error handling)
|
||||
|
||||
- Wiring (eth.py, async_eth.py):
|
||||
- _durin_call passes provider config to handlers
|
||||
|
||||
Tests:
|
||||
- tests/core/utilities/test_ccip_url_validation.py — 23 unit tests for scheme/host validation
|
||||
- tests/core/contracts/test_offchain_lookup.py — 6 new integration tests (HTTP rejection, allow_http, custom validator, private IP blocking, redirect prevention)
|
||||
- Updated test mocks to patch socket.getaddrinfo and assert allow_redirects=False
|
||||
|
||||
CVE: CVE-2026-40072
|
||||
Upstream-Status: Backport [https://github.com/ApeWorX/web3.py/commit/d62e67d3b636bd4c5a929696c0f5c4167c31625b]
|
||||
|
||||
Backport Changes:
|
||||
- Retained the v6.17 request helper APIs and passed
|
||||
`allow_redirects=False` through them instead of using the newer direct
|
||||
session APIs.
|
||||
- Retained the v6.17 POST `data` payload and malformed-URL checks; the
|
||||
newer source uses a `json` payload and a generic POST fallback.
|
||||
- Exported the validator aliases through the v6.17 utility imports; this
|
||||
version has no utility `__all__` list to update.
|
||||
- Kept the v6.17 timeout and POST-payload assertions in the request mocks
|
||||
while adding the upstream redirect assertions.
|
||||
- Reformatted the synchronous `_durin_call` arguments so the new provider
|
||||
options can be passed on the v6.17 call site.
|
||||
- Omitted changes under `tests/core` and `tests/ens` because the PyPI
|
||||
source archive used by this recipe does not ship those directories.
|
||||
The omitted tests were validated separately against the v6.17 Git tag.
|
||||
|
||||
(cherry picked from commit d62e67d3b636bd4c5a929696c0f5c4167c31625b)
|
||||
Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
|
||||
---
|
||||
.../module_testing/module_testing_utils.py | 26 ++++-
|
||||
web3/eth/async_eth.py | 2 +
|
||||
web3/eth/eth.py | 5 +-
|
||||
web3/providers/async_base.py | 5 +
|
||||
web3/providers/base.py | 5 +
|
||||
web3/utils/__init__.py | 4 +
|
||||
web3/utils/async_exception_handling.py | 21 +++-
|
||||
web3/utils/ccip_url_validation.py | 105 ++++++++++++++++++
|
||||
web3/utils/exception_handling.py | 21 +++-
|
||||
9 files changed, 190 insertions(+), 4 deletions(-)
|
||||
create mode 100644 web3/utils/ccip_url_validation.py
|
||||
|
||||
diff --git a/web3/_utils/module_testing/module_testing_utils.py b/web3/_utils/module_testing/module_testing_utils.py
|
||||
index 46c82c22..7b05702b 100644
|
||||
--- a/web3/_utils/module_testing/module_testing_utils.py
|
||||
+++ b/web3/_utils/module_testing/module_testing_utils.py
|
||||
@@ -89,6 +89,24 @@ def assert_contains_log(
|
||||
assert log_entry["transactionHash"] == HexBytes(txn_hash_with_log)
|
||||
|
||||
|
||||
+def _mock_getaddrinfo_public(
|
||||
+ monkeypatch: "MonkeyPatch",
|
||||
+) -> None:
|
||||
+ # Patch socket.getaddrinfo to return a public IP for CCIP test domains
|
||||
+ # so that CCIP URL host validation passes during tests. Pass through
|
||||
+ # to the real getaddrinfo for all other hosts (e.g. 127.0.0.1 for geth).
|
||||
+ import socket as _socket
|
||||
+
|
||||
+ _original_getaddrinfo = _socket.getaddrinfo
|
||||
+
|
||||
+ def _patched_getaddrinfo(host: Any, port: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
+ if host == "web3.py":
|
||||
+ return [(_socket.AF_INET, _socket.SOCK_STREAM, 0, "", ("1.2.3.4", 0))]
|
||||
+ return _original_getaddrinfo(host, port, *args, **kwargs)
|
||||
+
|
||||
+ monkeypatch.setattr("socket.getaddrinfo", _patched_getaddrinfo)
|
||||
+
|
||||
+
|
||||
def mock_offchain_lookup_request_response(
|
||||
monkeypatch: "MonkeyPatch",
|
||||
http_method: Literal["GET", "POST"] = "GET",
|
||||
@@ -100,6 +118,8 @@ def mock_offchain_lookup_request_response(
|
||||
sender: str = None,
|
||||
calldata: str = None,
|
||||
) -> None:
|
||||
+ _mock_getaddrinfo_public(monkeypatch)
|
||||
+
|
||||
class MockedResponse:
|
||||
status_code = mocked_status_code
|
||||
|
||||
@@ -119,6 +139,7 @@ def mock_offchain_lookup_request_response(
|
||||
# mock response only to specified url while validating appropriate fields
|
||||
if url_from_args == mocked_request_url:
|
||||
assert kwargs["timeout"] == 10
|
||||
+ assert kwargs.get("allow_redirects") is False
|
||||
if http_method.upper() == "POST":
|
||||
assert kwargs["data"] == {"data": calldata, "sender": sender}
|
||||
return MockedResponse()
|
||||
@@ -146,6 +167,8 @@ def async_mock_offchain_lookup_request_response(
|
||||
sender: str = None,
|
||||
calldata: str = None,
|
||||
) -> None:
|
||||
+ _mock_getaddrinfo_public(monkeypatch)
|
||||
+
|
||||
class AsyncMockedResponse:
|
||||
status = mocked_status_code
|
||||
|
||||
@@ -169,7 +192,8 @@ def async_mock_offchain_lookup_request_response(
|
||||
# mock response only to specified url while validating appropriate fields
|
||||
if url_from_args == mocked_request_url:
|
||||
assert kwargs["timeout"] == ClientTimeout(10)
|
||||
- if http_method.upper() == "post":
|
||||
+ assert kwargs.get("allow_redirects") is False
|
||||
+ if http_method.upper() == "POST":
|
||||
assert kwargs["data"] == {"data": calldata, "sender": sender}
|
||||
return AsyncMockedResponse()
|
||||
|
||||
diff --git a/web3/eth/async_eth.py b/web3/eth/async_eth.py
|
||||
index b6412a59..14d5679a 100644
|
||||
--- a/web3/eth/async_eth.py
|
||||
+++ b/web3/eth/async_eth.py
|
||||
@@ -293,6 +293,8 @@ class AsyncEth(BaseEth):
|
||||
durin_calldata = await async_handle_offchain_lookup(
|
||||
offchain_lookup.payload,
|
||||
transaction,
|
||||
+ allow_http=self.w3.provider.ccip_read_allow_http,
|
||||
+ url_validator=self.w3.provider.ccip_read_url_validator,
|
||||
)
|
||||
transaction["data"] = durin_calldata
|
||||
|
||||
diff --git a/web3/eth/eth.py b/web3/eth/eth.py
|
||||
index 6e1700ca..e11623a7 100644
|
||||
--- a/web3/eth/eth.py
|
||||
+++ b/web3/eth/eth.py
|
||||
@@ -279,7 +279,10 @@ class Eth(BaseEth):
|
||||
return self._call(transaction, block_identifier, state_override)
|
||||
except OffchainLookup as offchain_lookup:
|
||||
durin_calldata = handle_offchain_lookup(
|
||||
- offchain_lookup.payload, transaction
|
||||
+ offchain_lookup.payload,
|
||||
+ transaction,
|
||||
+ allow_http=self.w3.provider.ccip_read_allow_http,
|
||||
+ url_validator=self.w3.provider.ccip_read_url_validator,
|
||||
)
|
||||
transaction["data"] = durin_calldata
|
||||
|
||||
diff --git a/web3/providers/async_base.py b/web3/providers/async_base.py
|
||||
index 30404b6b..64b9f7f8 100644
|
||||
--- a/web3/providers/async_base.py
|
||||
+++ b/web3/providers/async_base.py
|
||||
@@ -38,6 +38,9 @@ if TYPE_CHECKING:
|
||||
AsyncWeb3,
|
||||
WebsocketProviderV2,
|
||||
)
|
||||
+ from web3.utils.ccip_url_validation import (
|
||||
+ AsyncCcipUrlValidator,
|
||||
+ )
|
||||
|
||||
|
||||
class AsyncBaseProvider:
|
||||
@@ -54,6 +57,8 @@ class AsyncBaseProvider:
|
||||
has_persistent_connection = False
|
||||
global_ccip_read_enabled: bool = True
|
||||
ccip_read_max_redirects: int = 4
|
||||
+ ccip_read_allow_http: bool = False
|
||||
+ ccip_read_url_validator: "AsyncCcipUrlValidator | None" = None
|
||||
|
||||
@property
|
||||
def middlewares(self) -> Tuple[AsyncMiddleware, ...]:
|
||||
diff --git a/web3/providers/base.py b/web3/providers/base.py
|
||||
index d7877546..5d91f635 100644
|
||||
--- a/web3/providers/base.py
|
||||
+++ b/web3/providers/base.py
|
||||
@@ -32,6 +32,9 @@ from web3.types import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from web3 import Web3 # noqa: F401
|
||||
+ from web3.utils.ccip_url_validation import (
|
||||
+ CcipUrlValidator,
|
||||
+ )
|
||||
|
||||
|
||||
class BaseProvider:
|
||||
@@ -46,6 +49,8 @@ class BaseProvider:
|
||||
has_persistent_connection = False
|
||||
global_ccip_read_enabled: bool = True
|
||||
ccip_read_max_redirects: int = 4
|
||||
+ ccip_read_allow_http: bool = False
|
||||
+ ccip_read_url_validator: "CcipUrlValidator | None" = None
|
||||
|
||||
@property
|
||||
def middlewares(self) -> Tuple[Middleware, ...]:
|
||||
diff --git a/web3/utils/__init__.py b/web3/utils/__init__.py
|
||||
index 13c24de6..2c4f1d94 100644
|
||||
--- a/web3/utils/__init__.py
|
||||
+++ b/web3/utils/__init__.py
|
||||
@@ -14,6 +14,10 @@ from .async_exception_handling import ( # NOQA
|
||||
from .caching import ( # NOQA
|
||||
SimpleCache,
|
||||
)
|
||||
+from .ccip_url_validation import ( # NOQA
|
||||
+ AsyncCcipUrlValidator,
|
||||
+ CcipUrlValidator,
|
||||
+)
|
||||
from .exception_handling import ( # NOQA
|
||||
handle_offchain_lookup,
|
||||
)
|
||||
diff --git a/web3/utils/async_exception_handling.py b/web3/utils/async_exception_handling.py
|
||||
index 0619bd5b..e4fa933d 100644
|
||||
--- a/web3/utils/async_exception_handling.py
|
||||
+++ b/web3/utils/async_exception_handling.py
|
||||
@@ -26,11 +26,18 @@ from web3.exceptions import (
|
||||
from web3.types import (
|
||||
TxParams,
|
||||
)
|
||||
+from web3.utils.ccip_url_validation import (
|
||||
+ AsyncCcipUrlValidator,
|
||||
+ async_validate_ccip_url_host,
|
||||
+ validate_ccip_url_scheme,
|
||||
+)
|
||||
|
||||
|
||||
async def async_handle_offchain_lookup(
|
||||
offchain_lookup_payload: Dict[str, Any],
|
||||
transaction: TxParams,
|
||||
+ allow_http: bool = False,
|
||||
+ url_validator: AsyncCcipUrlValidator | None = None,
|
||||
) -> bytes:
|
||||
formatted_sender = to_hex_if_bytes(offchain_lookup_payload["sender"]).lower()
|
||||
formatted_data = to_hex_if_bytes(offchain_lookup_payload["callData"]).lower()
|
||||
@@ -48,13 +55,25 @@ async def async_handle_offchain_lookup(
|
||||
.replace("{data}", str(formatted_data))
|
||||
)
|
||||
|
||||
+ try:
|
||||
+ validate_ccip_url_scheme(formatted_url, allow_http=allow_http)
|
||||
+ await async_validate_ccip_url_host(formatted_url)
|
||||
+ if url_validator is not None:
|
||||
+ await url_validator(formatted_url)
|
||||
+ except Web3ValidationError:
|
||||
+ continue
|
||||
+
|
||||
try:
|
||||
if "{data}" in url and "{sender}" in url:
|
||||
- response = await async_get_response_from_get_request(formatted_url)
|
||||
+ response = await async_get_response_from_get_request(
|
||||
+ formatted_url,
|
||||
+ allow_redirects=False,
|
||||
+ )
|
||||
elif "{sender}" in url:
|
||||
response = await async_get_response_from_post_request(
|
||||
formatted_url,
|
||||
data={"data": formatted_data, "sender": formatted_sender},
|
||||
+ allow_redirects=False,
|
||||
)
|
||||
else:
|
||||
raise Web3ValidationError("url not formatted properly.")
|
||||
diff --git a/web3/utils/ccip_url_validation.py b/web3/utils/ccip_url_validation.py
|
||||
new file mode 100644
|
||||
index 00000000..a86618d8
|
||||
--- /dev/null
|
||||
+++ b/web3/utils/ccip_url_validation.py
|
||||
@@ -0,0 +1,105 @@
|
||||
+import asyncio
|
||||
+import ipaddress
|
||||
+import socket
|
||||
+from typing import (
|
||||
+ Awaitable,
|
||||
+ Callable,
|
||||
+)
|
||||
+from urllib.parse import (
|
||||
+ urlparse,
|
||||
+)
|
||||
+
|
||||
+from web3.exceptions import (
|
||||
+ Web3ValidationError,
|
||||
+)
|
||||
+
|
||||
+CcipUrlValidator = Callable[[str], None]
|
||||
+AsyncCcipUrlValidator = Callable[[str], Awaitable[None]]
|
||||
+
|
||||
+BLOCKED_IP_NETWORKS = [
|
||||
+ ipaddress.ip_network("127.0.0.0/8"),
|
||||
+ ipaddress.ip_network("10.0.0.0/8"),
|
||||
+ ipaddress.ip_network("172.16.0.0/12"),
|
||||
+ ipaddress.ip_network("192.168.0.0/16"),
|
||||
+ ipaddress.ip_network("169.254.0.0/16"),
|
||||
+ ipaddress.ip_network("0.0.0.0/8"),
|
||||
+ ipaddress.ip_network("::1/128"),
|
||||
+ ipaddress.ip_network("fe80::/10"),
|
||||
+ ipaddress.ip_network("fc00::/7"),
|
||||
+ ipaddress.ip_network("::/128"),
|
||||
+]
|
||||
+
|
||||
+
|
||||
+def validate_ccip_url_scheme(url: str, allow_http: bool = False) -> None:
|
||||
+ parsed = urlparse(url)
|
||||
+ scheme = parsed.scheme.lower()
|
||||
+
|
||||
+ if scheme == "https":
|
||||
+ return
|
||||
+
|
||||
+ if scheme == "http" and allow_http:
|
||||
+ return
|
||||
+
|
||||
+ if scheme == "http":
|
||||
+ raise Web3ValidationError(
|
||||
+ f"CCIP Read request to non-HTTPS URL '{url}' is not allowed. "
|
||||
+ "Set ``ccip_read_allow_http=True`` on the provider to allow HTTP URLs."
|
||||
+ )
|
||||
+
|
||||
+ raise Web3ValidationError(
|
||||
+ f"CCIP Read request with scheme '{scheme}' is not allowed. "
|
||||
+ "Only HTTPS URLs are permitted."
|
||||
+ )
|
||||
+
|
||||
+
|
||||
+def _check_ip_blocked(ip_str: str) -> bool:
|
||||
+ try:
|
||||
+ addr = ipaddress.ip_address(ip_str)
|
||||
+ except ValueError:
|
||||
+ return False
|
||||
+ return any(addr in network for network in BLOCKED_IP_NETWORKS)
|
||||
+
|
||||
+
|
||||
+def validate_ccip_url_host(url: str) -> None:
|
||||
+ parsed = urlparse(url)
|
||||
+ hostname = parsed.hostname
|
||||
+ if not hostname:
|
||||
+ raise Web3ValidationError(f"CCIP Read URL '{url}' has no hostname.")
|
||||
+
|
||||
+ try:
|
||||
+ addrinfos = socket.getaddrinfo(hostname, None)
|
||||
+ except socket.gaierror:
|
||||
+ raise Web3ValidationError(
|
||||
+ f"CCIP Read URL hostname '{hostname}' could not be resolved."
|
||||
+ )
|
||||
+
|
||||
+ for addrinfo in addrinfos:
|
||||
+ ip_str = str(addrinfo[4][0])
|
||||
+ if _check_ip_blocked(ip_str):
|
||||
+ raise Web3ValidationError(
|
||||
+ f"CCIP Read request to '{url}' is not allowed: "
|
||||
+ f"resolved IP '{ip_str}' is in a blocked private/reserved range."
|
||||
+ )
|
||||
+
|
||||
+
|
||||
+async def async_validate_ccip_url_host(url: str) -> None:
|
||||
+ parsed = urlparse(url)
|
||||
+ hostname = parsed.hostname
|
||||
+ if not hostname:
|
||||
+ raise Web3ValidationError(f"CCIP Read URL '{url}' has no hostname.")
|
||||
+
|
||||
+ loop = asyncio.get_running_loop()
|
||||
+ try:
|
||||
+ addrinfos = await loop.run_in_executor(None, socket.getaddrinfo, hostname, None)
|
||||
+ except socket.gaierror:
|
||||
+ raise Web3ValidationError(
|
||||
+ f"CCIP Read URL hostname '{hostname}' could not be resolved."
|
||||
+ )
|
||||
+
|
||||
+ for addrinfo in addrinfos:
|
||||
+ ip_str = str(addrinfo[4][0])
|
||||
+ if _check_ip_blocked(ip_str):
|
||||
+ raise Web3ValidationError(
|
||||
+ f"CCIP Read request to '{url}' is not allowed: "
|
||||
+ f"resolved IP '{ip_str}' is in a blocked private/reserved range."
|
||||
+ )
|
||||
diff --git a/web3/utils/exception_handling.py b/web3/utils/exception_handling.py
|
||||
index 77a46fc6..1d5ee0bd 100644
|
||||
--- a/web3/utils/exception_handling.py
|
||||
+++ b/web3/utils/exception_handling.py
|
||||
@@ -25,11 +25,18 @@ from web3.exceptions import (
|
||||
from web3.types import (
|
||||
TxParams,
|
||||
)
|
||||
+from web3.utils.ccip_url_validation import (
|
||||
+ CcipUrlValidator,
|
||||
+ validate_ccip_url_host,
|
||||
+ validate_ccip_url_scheme,
|
||||
+)
|
||||
|
||||
|
||||
def handle_offchain_lookup(
|
||||
offchain_lookup_payload: Dict[str, Any],
|
||||
transaction: TxParams,
|
||||
+ allow_http: bool = False,
|
||||
+ url_validator: CcipUrlValidator | None = None,
|
||||
) -> bytes:
|
||||
formatted_sender = to_hex_if_bytes(offchain_lookup_payload["sender"]).lower()
|
||||
formatted_data = to_hex_if_bytes(offchain_lookup_payload["callData"]).lower()
|
||||
@@ -47,9 +54,20 @@ def handle_offchain_lookup(
|
||||
.replace("{data}", str(formatted_data))
|
||||
)
|
||||
|
||||
+ try:
|
||||
+ validate_ccip_url_scheme(formatted_url, allow_http=allow_http)
|
||||
+ validate_ccip_url_host(formatted_url)
|
||||
+ if url_validator is not None:
|
||||
+ url_validator(formatted_url)
|
||||
+ except Web3ValidationError:
|
||||
+ continue
|
||||
+
|
||||
try:
|
||||
if "{data}" in url and "{sender}" in url:
|
||||
- response = get_response_from_get_request(formatted_url)
|
||||
+ response = get_response_from_get_request(
|
||||
+ formatted_url,
|
||||
+ allow_redirects=False,
|
||||
+ )
|
||||
elif "{sender}" in url:
|
||||
response = get_response_from_post_request(
|
||||
formatted_url,
|
||||
@@ -57,6 +75,7 @@ def handle_offchain_lookup(
|
||||
"data": formatted_data,
|
||||
"sender": formatted_sender,
|
||||
},
|
||||
+ allow_redirects=False,
|
||||
)
|
||||
else:
|
||||
raise Web3ValidationError("url not formatted properly.")
|
||||
--
|
||||
2.35.6
|
||||
Reference in New Issue
Block a user