diff mbox series

[meta-python,wrynose] python3-web3: Fix CVE-2026-40072

Message ID 20260820054239.1363898-1-hthakar@cisco.com
State New
Headers show
Series [meta-python,wrynose] python3-web3: Fix CVE-2026-40072 | expand

Commit Message

From: Hetvi Thakar <hthakar@cisco.com>

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 <hthakar@cisco.com>
---
 .../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 mbox series

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 <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]
+
+(cherry picked from commit d62e67d3b636bd4c5a929696c0f5c4167c31625b)
+Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
+---
+ 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