[meta-python][scarthgap][PATCH] python3-web3: Fix CVE-2026-40072
"Hetvi Thakar -X (hthakar - E INFOCHIPS PRIVATE LIMITED at Cisco)" <[email protected]>
| Newsgroups | org.openembedded.lists.openembedded-devel |
|---|---|
| Message-ID | <[email protected]> |
From: Hetvi Thakar <[email protected]> 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 <[email protected]> --- .../python/python3-web3/CVE-2026-40072.patch | 434 ++++++++++++++++++ .../python/python3-web3_6.17.0.bb | 1 + 2 files changed, 435 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-web3/CVE-2026-40072.patch diff --git a/meta-python/recipes-devtools/python/python3-web3/CVE-2026-40072.patch b/meta-python/recipes-devtools/python/python3-web3/CVE-2026-40072.patch new file mode 100644 index 0000000000..35e94cde60 --- /dev/null +++ b/meta-python/recipes-devtools/python/python3-web3/CVE-2026-40072.patch @@ -0,0 +1,434 @@ +From 21ee858ea75287d781eb0a878d9463346da648b3 Mon Sep 17 00:00:00 2001 +From: fselmo <[email protected]> +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 <[email protected]> +--- + .../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 diff --git a/meta-python/recipes-devtools/python/python3-web3_6.17.0.bb b/meta-python/recipes-devtools/python/python3-web3_6.17.0.bb index f1be4dcf4d..6c093c794d 100644 --- a/meta-python/recipes-devtools/python/python3-web3_6.17.0.bb +++ b/meta-python/recipes-devtools/python/python3-web3_6.17.0.bb @@ -4,6 +4,7 @@ SECTION = "devel/python" LICENSE = "MIT" LIC_FILES_CHKSUM = "file://LICENSE;md5=373fede350846fdffd23648fba504635" +SRC_URI += "file://CVE-2026-40072.patch" SRC_URI[sha256sum] = "1b535272a40da3d8d2b120856edb53b84b0c08bcc8fe1a5bbd5f816fd72f4ec6" inherit pypi setuptools3 -- 2.35.6