[meta-python][wrynose][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 upstream v7 backport for CVE-2026-40072. The upstream fix commit is referenced in [1], and the public CVE advisory is referenced in [2]. [1] https://github.com/ApeWorX/web3.py/commit/d62e67d3b636bd4c5a929696c0f5c4167c31625b [2] https://github.com/advisories/GHSA-5hr4-253g-cpx2 Signed-off-by: Hetvi Thakar <[email protected]> --- .../python/files/CVE-2026-40072.patch | 861 ++++++++++++++++++ .../python/python3-web3_7.12.1.bb | 1 + 2 files changed, 862 insertions(+) create mode 100644 meta-python/recipes-devtools/python/files/CVE-2026-40072.patch diff --git a/meta-python/recipes-devtools/python/files/CVE-2026-40072.patch b/meta-python/recipes-devtools/python/files/CVE-2026-40072.patch new file mode 100644 index 0000000000..d32438290b --- /dev/null +++ b/meta-python/recipes-devtools/python/files/CVE-2026-40072.patch @@ -0,0 +1,861 @@ +From 694a1217581e39036e0c174ab85616ee6656139d 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] + +(cherry picked from commit d62e67d3b636bd4c5a929696c0f5c4167c31625b) +Signed-off-by: Hetvi Thakar <[email protected]> +--- + tests/core/contracts/test_offchain_lookup.py | 200 ++++++++++++++++++ + .../utilities/test_ccip_url_validation.py | 122 +++++++++++ + tests/ens/test_offchain_resolution.py | 26 +++ + .../module_testing/module_testing_utils.py | 24 +++ + web3/eth/async_eth.py | 2 + + web3/eth/eth.py | 2 + + web3/providers/async_base.py | 5 + + web3/providers/base.py | 5 + + web3/utils/__init__.py | 6 + + web3/utils/async_exception_handling.py | 20 +- + web3/utils/ccip_url_validation.py | 105 +++++++++ + web3/utils/exception_handling.py | 22 +- + 12 files changed, 537 insertions(+), 2 deletions(-) + create mode 100644 tests/core/utilities/test_ccip_url_validation.py + create mode 100644 web3/utils/ccip_url_validation.py + +diff --git a/tests/core/contracts/test_offchain_lookup.py b/tests/core/contracts/test_offchain_lookup.py +index eed8ae23..c6ddb760 100644 +--- a/tests/core/contracts/test_offchain_lookup.py ++++ b/tests/core/contracts/test_offchain_lookup.py +@@ -1,4 +1,5 @@ + import pytest ++import socket + + from eth_abi import ( + abi, +@@ -14,10 +15,14 @@ from web3._utils.type_conversion import ( + to_hex_if_bytes, + ) + from web3.exceptions import ( ++ MultipleFailedRequests, + OffchainLookup, + TooManyRequests, + Web3ValidationError, + ) ++from web3.utils import ( ++ handle_offchain_lookup, ++) + + # "test offchain lookup" as an abi-encoded string + OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA = "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000" # noqa: E501 +@@ -208,3 +213,198 @@ def test_offchain_lookup_raises_on_continuous_redirect( + ) + with pytest.raises(TooManyRequests, match="Too many CCIP read redirects"): + offchain_lookup_contract.caller.continuousOffchainLookup() ++ ++ ++# -- SSRF mitigation tests -- # ++ ++ ++def test_offchain_lookup_rejects_http_urls_by_default( ++ offchain_lookup_contract, ++ monkeypatch, ++): ++ """HTTP URLs should be rejected by default (only HTTPS allowed).""" ++ to_hex_if_bytes(offchain_lookup_contract.address).lower() ++ ++ # Patch getaddrinfo so host validation passes ++ def _mock_getaddrinfo(host, port, *args, **kwargs): ++ return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("1.2.3.4", 0))] ++ ++ monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo) ++ ++ payload = { ++ "sender": offchain_lookup_contract.address, ++ "urls": [ ++ "http://web3.py/gateway/{sender}/{data}.json", ++ ], ++ "callData": OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA, ++ "callbackFunction": b"\x00\x00\x00\x00", ++ "extraData": b"", ++ } ++ transaction = {"to": offchain_lookup_contract.address} ++ ++ with pytest.raises(MultipleFailedRequests): ++ handle_offchain_lookup(payload, transaction) ++ ++ ++def test_offchain_lookup_allows_http_urls_when_configured( ++ w3, ++ offchain_lookup_contract, ++ monkeypatch, ++): ++ """HTTP URLs should be allowed when ccip_read_allow_http=True on provider.""" ++ normalized_address = to_hex_if_bytes(offchain_lookup_contract.address) ++ mock_offchain_lookup_request_response( ++ monkeypatch, ++ mocked_request_url=f"https://web3.py/gateway/{normalized_address}/{OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA}.json", # noqa: E501 ++ mocked_json_data=WEB3PY_AS_HEXBYTES, ++ ) ++ ++ w3.provider.ccip_read_allow_http = True ++ try: ++ response = offchain_lookup_contract.caller.testOffchainLookup( ++ OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA ++ ) ++ assert abi.decode(["string"], response)[0] == "web3py" ++ finally: ++ w3.provider.ccip_read_allow_http = False ++ ++ ++def test_offchain_lookup_custom_url_validator_rejects( ++ offchain_lookup_contract, ++ monkeypatch, ++): ++ """Custom url_validator on provider that rejects should skip URLs.""" ++ from web3.utils.exception_handling import ( ++ handle_offchain_lookup, ++ ) ++ ++ # Patch getaddrinfo so host validation passes ++ def _mock_getaddrinfo(host, port, *args, **kwargs): ++ return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("1.2.3.4", 0))] ++ ++ monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo) ++ ++ def reject_all(url): ++ raise Web3ValidationError(f"Rejected by policy: {url}") ++ ++ payload = { ++ "sender": offchain_lookup_contract.address, ++ "urls": [ ++ "https://web3.py/gateway/{sender}/{data}.json", ++ ], ++ "callData": OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA, ++ "callbackFunction": b"\x00\x00\x00\x00", ++ "extraData": b"", ++ } ++ transaction = {"to": offchain_lookup_contract.address} ++ ++ with pytest.raises(MultipleFailedRequests): ++ handle_offchain_lookup(payload, transaction, url_validator=reject_all) ++ ++ ++def test_offchain_lookup_custom_url_validator_on_provider( ++ w3, ++ offchain_lookup_contract, ++ monkeypatch, ++): ++ """Custom url_validator set on provider is honored via _durin_call.""" ++ ++ # Patch getaddrinfo so host validation passes ++ def _mock_getaddrinfo(host, port, *args, **kwargs): ++ return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("1.2.3.4", 0))] ++ ++ monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo) ++ ++ validator_calls = [] ++ ++ def tracking_validator(url): ++ validator_calls.append(url) ++ raise Web3ValidationError(f"Rejected by policy: {url}") ++ ++ w3.provider.ccip_read_url_validator = tracking_validator ++ try: ++ with pytest.raises(MultipleFailedRequests): ++ offchain_lookup_contract.caller.testOffchainLookup( ++ OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA ++ ) ++ assert len(validator_calls) > 0 ++ finally: ++ w3.provider.ccip_read_url_validator = None ++ ++ ++def test_offchain_lookup_rejects_private_ip( ++ offchain_lookup_contract, ++ monkeypatch, ++): ++ """URLs resolving to private IPs should be rejected.""" ++ from web3.utils.exception_handling import ( ++ handle_offchain_lookup, ++ ) ++ ++ def _mock_getaddrinfo(host, port, *args, **kwargs): ++ return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))] ++ ++ monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo) ++ ++ payload = { ++ "sender": offchain_lookup_contract.address, ++ "urls": [ ++ "https://web3.py/gateway/{sender}/{data}.json", ++ ], ++ "callData": OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA, ++ "callbackFunction": b"\x00\x00\x00\x00", ++ "extraData": b"", ++ } ++ transaction = {"to": offchain_lookup_contract.address} ++ ++ with pytest.raises(MultipleFailedRequests): ++ handle_offchain_lookup(payload, transaction) ++ ++ ++def test_offchain_lookup_redirect_not_followed( ++ offchain_lookup_contract, ++ monkeypatch, ++): ++ """302 redirects should not be followed (treated as non-2xx, try next URL).""" ++ from web3.utils.exception_handling import ( ++ handle_offchain_lookup, ++ ) ++ ++ # Patch getaddrinfo so host validation passes ++ def _mock_getaddrinfo(host, port, *args, **kwargs): ++ return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("1.2.3.4", 0))] ++ ++ monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo) ++ ++ class Mock302Response: ++ status_code = 302 ++ ++ @staticmethod ++ def raise_for_status(): ++ raise Exception("called raise_for_status()") ++ ++ def _mock_get(*args, **kwargs): ++ assert kwargs.get("allow_redirects") is False ++ return Mock302Response() ++ ++ def _mock_post(*args, **kwargs): ++ assert kwargs.get("allow_redirects") is False ++ return Mock302Response() ++ ++ monkeypatch.setattr("requests.Session.get", _mock_get) ++ monkeypatch.setattr("requests.Session.post", _mock_post) ++ ++ payload = { ++ "sender": offchain_lookup_contract.address, ++ "urls": [ ++ "https://web3.py/gateway/{sender}/{data}.json", ++ "https://web3.py/gateway", ++ ], ++ "callData": OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA, ++ "callbackFunction": b"\x00\x00\x00\x00", ++ "extraData": b"", ++ } ++ transaction = {"to": offchain_lookup_contract.address} ++ ++ with pytest.raises(MultipleFailedRequests): ++ handle_offchain_lookup(payload, transaction) +diff --git a/tests/core/utilities/test_ccip_url_validation.py b/tests/core/utilities/test_ccip_url_validation.py +new file mode 100644 +index 00000000..3bb75f96 +--- /dev/null ++++ b/tests/core/utilities/test_ccip_url_validation.py +@@ -0,0 +1,122 @@ ++import pytest ++import socket ++ ++from web3.exceptions import ( ++ Web3ValidationError, ++) ++from web3.utils.ccip_url_validation import ( ++ validate_ccip_url_host, ++ validate_ccip_url_scheme, ++) ++ ++# -- validate_ccip_url_scheme tests -- # ++ ++ ++class TestValidateCcipUrlScheme: ++ def test_https_passes(self): ++ validate_ccip_url_scheme("https://example.com/api", allow_http=False) ++ ++ def test_http_fails_by_default(self): ++ with pytest.raises(Web3ValidationError, match="non-HTTPS"): ++ validate_ccip_url_scheme("http://example.com/api") ++ ++ def test_http_passes_with_allow_http(self): ++ validate_ccip_url_scheme("http://example.com/api", allow_http=True) ++ ++ def test_ftp_always_fails(self): ++ with pytest.raises(Web3ValidationError, match="not allowed"): ++ validate_ccip_url_scheme("ftp://example.com/file") ++ ++ def test_ftp_fails_even_with_allow_http(self): ++ with pytest.raises(Web3ValidationError, match="not allowed"): ++ validate_ccip_url_scheme("ftp://example.com/file", allow_http=True) ++ ++ def test_file_scheme_fails(self): ++ with pytest.raises(Web3ValidationError, match="not allowed"): ++ validate_ccip_url_scheme("file:///etc/passwd") ++ ++ def test_file_scheme_fails_with_allow_http(self): ++ with pytest.raises(Web3ValidationError, match="not allowed"): ++ validate_ccip_url_scheme("file:///etc/passwd", allow_http=True) ++ ++ ++# -- validate_ccip_url_host tests -- # ++ ++ ++class TestValidateCcipUrlHost: ++ def _patch_getaddrinfo(self, monkeypatch, ip): ++ def _mock_getaddrinfo(host, port, *args, **kwargs): ++ return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (ip, 0))] ++ ++ monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo) ++ ++ def test_public_ip_passes(self, monkeypatch): ++ self._patch_getaddrinfo(monkeypatch, "8.8.8.8") ++ validate_ccip_url_host("https://example.com/api") ++ ++ @pytest.mark.parametrize( ++ "blocked_ip", ++ [ ++ "127.0.0.1", ++ "127.0.0.2", ++ "10.0.0.1", ++ "10.255.255.255", ++ "172.16.0.1", ++ "172.31.255.255", ++ "192.168.0.1", ++ "192.168.1.100", ++ "169.254.0.1", ++ "0.0.0.0", ++ ], ++ ) ++ def test_blocked_ipv4(self, monkeypatch, blocked_ip): ++ self._patch_getaddrinfo(monkeypatch, blocked_ip) ++ with pytest.raises(Web3ValidationError, match="blocked private/reserved"): ++ validate_ccip_url_host("https://example.com/api") ++ ++ def test_blocked_ipv6_loopback(self, monkeypatch): ++ def _mock_getaddrinfo(host, port, *args, **kwargs): ++ return [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("::1", 0, 0, 0))] ++ ++ monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo) ++ with pytest.raises(Web3ValidationError, match="blocked private/reserved"): ++ validate_ccip_url_host("https://example.com/api") ++ ++ def test_unresolvable_hostname(self, monkeypatch): ++ def _mock_getaddrinfo(host, port, *args, **kwargs): ++ raise socket.gaierror("Name or service not known") ++ ++ monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo) ++ with pytest.raises(Web3ValidationError, match="could not be resolved"): ++ validate_ccip_url_host("https://nonexistent.invalid/api") ++ ++ def test_no_hostname(self): ++ with pytest.raises(Web3ValidationError, match="no hostname"): ++ validate_ccip_url_host("https:///path") ++ ++ ++# -- custom validator tests -- # ++ ++ ++class TestCustomUrlValidator: ++ def test_custom_validator_called_and_can_reject(self): ++ calls = [] ++ ++ def reject_validator(url): ++ calls.append(url) ++ raise Web3ValidationError(f"Rejected: {url}") ++ ++ with pytest.raises(Web3ValidationError, match="Rejected"): ++ reject_validator("https://example.com/api") ++ ++ assert len(calls) == 1 ++ assert calls[0] == "https://example.com/api" ++ ++ def test_custom_validator_can_allow(self): ++ calls = [] ++ ++ def allow_validator(url): ++ calls.append(url) ++ ++ allow_validator("https://example.com/api") ++ assert len(calls) == 1 +diff --git a/tests/ens/test_offchain_resolution.py b/tests/ens/test_offchain_resolution.py +index 9e4f3b16..fc479d36 100644 +--- a/tests/ens/test_offchain_resolution.py ++++ b/tests/ens/test_offchain_resolution.py +@@ -1,4 +1,5 @@ + import pytest ++import socket + + from aiohttp import ( + ClientSession, +@@ -13,6 +14,19 @@ from web3.exceptions import ( + Web3ValidationError, + ) + ++ ++def _mock_getaddrinfo_public(monkeypatch): ++ """Patch socket.getaddrinfo to return a public IP for CCIP test domains.""" ++ _original = socket.getaddrinfo ++ ++ def _patched(host, port, *args, **kwargs): ++ if host == "web3.py": ++ return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("1.2.3.4", 0))] ++ return _original(host, port, *args, **kwargs) ++ ++ monkeypatch.setattr("socket.getaddrinfo", _patched) ++ ++ + # the encoded calldata for the initiating ``addr(namehash(name))`` call + ENCODED_ADDR_CALLDATA = "0x3b3b57de42041b0018edd29d7c17154b0c671acc0502ea0b3693cafbeadf58e6beaaa16c" # noqa: E501 + +@@ -124,6 +138,8 @@ class AsyncMockHttpBadFormatResponse: + + + def test_offchain_resolution_with_get_request(ens, monkeypatch): ++ _mock_getaddrinfo_public(monkeypatch) ++ + # mock GET response with real return data from 'offchainexample.eth' resolver + def mock_get(*args, **kwargs): + return MockHttpSuccessResponse("get", *args, **kwargs) +@@ -134,6 +150,8 @@ def test_offchain_resolution_with_get_request(ens, monkeypatch): + + + def test_offchain_resolution_with_post_request(ens, monkeypatch): ++ _mock_getaddrinfo_public(monkeypatch) ++ + # mock POST response with real return data from 'offchainexample.eth' resolver + def mock_post(*args, **kwargs): + return MockHttpSuccessResponse("post", *args, **kwargs) +@@ -150,6 +168,8 @@ def test_offchain_resolution_raises_when_all_supplied_urls_fail(ens): + + + def test_offchain_resolution_with_improperly_formatted_http_response(ens, monkeypatch): ++ _mock_getaddrinfo_public(monkeypatch) ++ + def mock_get(*args, **_): + return MockHttpBadFormatResponse(*args) + +@@ -189,6 +209,8 @@ def test_offchain_resolver_function_call_raises_with_ccip_read_disabled( + + @pytest.mark.asyncio + async def test_async_offchain_resolution_with_get_request(async_ens, monkeypatch): ++ _mock_getaddrinfo_public(monkeypatch) ++ + # mock GET response with real return data from 'offchainexample.eth' resolver + async def mock_get(*args, **kwargs): + return AsyncMockHttpSuccessResponse("get", *args, **kwargs) +@@ -200,6 +222,8 @@ async def test_async_offchain_resolution_with_get_request(async_ens, monkeypatch + + @pytest.mark.asyncio + async def test_async_offchain_resolution_with_post_request(async_ens, monkeypatch): ++ _mock_getaddrinfo_public(monkeypatch) ++ + # mock POST response with real return data from 'offchainexample.eth' resolver + async def mock_post(*args, **kwargs): + return AsyncMockHttpSuccessResponse("post", *args, **kwargs) +@@ -220,6 +244,8 @@ async def test_async_offchain_resolution_raises_when_all_supplied_urls_fail(asyn + async def test_async_offchain_resolution_with_improperly_formatted_http_response( + async_ens, monkeypatch + ): ++ _mock_getaddrinfo_public(monkeypatch) ++ + async def mock_get(*args, **_): + return AsyncMockHttpBadFormatResponse(*args) + +diff --git a/web3/_utils/module_testing/module_testing_utils.py b/web3/_utils/module_testing/module_testing_utils.py +index f369d078..96623833 100644 +--- a/web3/_utils/module_testing/module_testing_utils.py ++++ b/web3/_utils/module_testing/module_testing_utils.py +@@ -62,6 +62,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", +@@ -73,6 +91,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 + +@@ -92,6 +112,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"] == DEFAULT_HTTP_TIMEOUT ++ assert kwargs.get("allow_redirects") is False + if http_method.upper() == "POST": + assert kwargs["json"] == {"data": calldata, "sender": sender} + return MockedResponse() +@@ -119,6 +140,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 + +@@ -142,6 +165,7 @@ 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(DEFAULT_HTTP_TIMEOUT) ++ assert kwargs.get("allow_redirects") is False + if http_method.upper() == "POST": + assert kwargs["json"] == {"data": calldata, "sender": sender} + return AsyncMockedResponse() +diff --git a/web3/eth/async_eth.py b/web3/eth/async_eth.py +index 2339ac3b..43a17666 100644 +--- a/web3/eth/async_eth.py ++++ b/web3/eth/async_eth.py +@@ -289,6 +289,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 d459f964..3b6709fa 100644 +--- a/web3/eth/eth.py ++++ b/web3/eth/eth.py +@@ -268,6 +268,8 @@ class Eth(BaseEth): + durin_calldata = 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/providers/async_base.py b/web3/providers/async_base.py +index b1f6314f..2abed58b 100644 +--- a/web3/providers/async_base.py ++++ b/web3/providers/async_base.py +@@ -68,6 +68,9 @@ if TYPE_CHECKING: + from web3.providers.persistent import ( # noqa: F401 + RequestProcessor, + ) ++ from web3.utils.ccip_url_validation import ( ++ AsyncCcipUrlValidator, ++ ) + + + class AsyncBaseProvider: +@@ -83,6 +86,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 + + def __init__( + self, +diff --git a/web3/providers/base.py b/web3/providers/base.py +index 1d8072f3..7be74c28 100644 +--- a/web3/providers/base.py ++++ b/web3/providers/base.py +@@ -54,6 +54,9 @@ if TYPE_CHECKING: + from web3._utils.batching import ( + RequestBatcher, + ) ++ from web3.utils.ccip_url_validation import ( ++ CcipUrlValidator, ++ ) + + + class BaseProvider: +@@ -69,6 +72,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 + + def __init__( + self, +diff --git a/web3/utils/__init__.py b/web3/utils/__init__.py +index f603daea..248e88ed 100644 +--- a/web3/utils/__init__.py ++++ b/web3/utils/__init__.py +@@ -39,6 +39,10 @@ from .caching import ( + RequestCacheValidationThreshold, + SimpleCache, + ) ++from .ccip_url_validation import ( ++ AsyncCcipUrlValidator, ++ CcipUrlValidator, ++) + from .exception_handling import ( + handle_offchain_lookup, + ) +@@ -73,6 +77,8 @@ __all__ = [ + "async_handle_offchain_lookup", + "RequestCacheValidationThreshold", + "SimpleCache", ++ "AsyncCcipUrlValidator", ++ "CcipUrlValidator", + "EthSubscription", + "handle_offchain_lookup", + ] +diff --git a/web3/utils/async_exception_handling.py b/web3/utils/async_exception_handling.py +index ea42d4d5..6a874201 100644 +--- a/web3/utils/async_exception_handling.py ++++ b/web3/utils/async_exception_handling.py +@@ -28,11 +28,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() +@@ -51,16 +58,27 @@ 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 session.get( +- formatted_url, timeout=ClientTimeout(DEFAULT_HTTP_TIMEOUT) ++ formatted_url, ++ timeout=ClientTimeout(DEFAULT_HTTP_TIMEOUT), ++ allow_redirects=False, + ) + else: + response = await session.post( + formatted_url, + json={"data": formatted_data, "sender": formatted_sender}, + timeout=ClientTimeout(DEFAULT_HTTP_TIMEOUT), ++ allow_redirects=False, + ) + except Exception: + continue # try next url if timeout or issues making the request +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 7aa89910..9fc2cf6e 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() +@@ -48,14 +55,27 @@ 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 = session.get(formatted_url, timeout=DEFAULT_HTTP_TIMEOUT) ++ response = session.get( ++ formatted_url, ++ timeout=DEFAULT_HTTP_TIMEOUT, ++ allow_redirects=False, ++ ) + else: + response = session.post( + formatted_url, + json={"data": formatted_data, "sender": formatted_sender}, + timeout=DEFAULT_HTTP_TIMEOUT, ++ allow_redirects=False, + ) + except Exception: + continue # try next url if timeout or issues making the request diff --git a/meta-python/recipes-devtools/python/python3-web3_7.12.1.bb b/meta-python/recipes-devtools/python/python3-web3_7.12.1.bb index fe5886692d..09a1140681 100644 --- a/meta-python/recipes-devtools/python/python3-web3_7.12.1.bb +++ b/meta-python/recipes-devtools/python/python3-web3_7.12.1.bb @@ -4,6 +4,7 @@ SECTION = "devel/python" LICENSE = "MIT" LIC_FILES_CHKSUM = "file://LICENSE;md5=1d34d9701a1461e4bd71a904ac4cf7be" +SRC_URI += "file://CVE-2026-40072.patch" SRC_URI[sha256sum] = "97f6a116ccaeb5907bb4cb6c771cc23bc942bf09528a840189e9b509b7b8347c" inherit pypi setuptools3 -- 2.35.6