mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-09-09 06:40:17 +00:00
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>
435 lines
16 KiB
Diff
435 lines
16 KiB
Diff
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
|