[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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.