new file mode 100644
@@ -0,0 +1,434 @@
+From 21ee858ea75287d781eb0a878d9463346da648b3 Mon Sep 17 00:00:00 2001
+From: fselmo <fselmo2@gmail.com>
+Date: Fri, 13 Mar 2026 15:38:09 -0600
+Subject: [PATCH] feat: added restrictions on CCIP read durin calls
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+SSRF Mitigation for CCIP Read:
+
+- validate_ccip_url_scheme() — HTTPS-only by default; HTTP allowed via opt-in
+- validate_ccip_url_host() / async_validate_ccip_url_host() — resolves hostname and blocks private/reserved IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, etc.)
+- Type aliases: CcipUrlValidator, AsyncCcipUrlValidator
+- Provider config (base.py, async_base.py):
+- ccip_read_allow_http: bool = False
+- ccip_read_url_validator — optional user-supplied hook to reject/allow URLs
+- Handler changes (exception_handling.py, async_exception_handling.py):
+- Scheme + host validation before each HTTP request
+- allow_redirects=False on all requests
+- Validation failures continue to next URL (consistent with existing error handling)
+
+- Wiring (eth.py, async_eth.py):
+- _durin_call passes provider config to handlers
+
+Tests:
+- tests/core/utilities/test_ccip_url_validation.py — 23 unit tests for scheme/host validation
+- tests/core/contracts/test_offchain_lookup.py — 6 new integration tests (HTTP rejection, allow_http, custom validator, private IP blocking, redirect prevention)
+- Updated test mocks to patch socket.getaddrinfo and assert allow_redirects=False
+
+CVE: CVE-2026-40072
+Upstream-Status: Backport [https://github.com/ApeWorX/web3.py/commit/d62e67d3b636bd4c5a929696c0f5c4167c31625b]
+
+Backport Changes:
+- Retained the v6.17 request helper APIs and passed
+ `allow_redirects=False` through them instead of using the newer direct
+ session APIs.
+- Retained the v6.17 POST `data` payload and malformed-URL checks; the
+ newer source uses a `json` payload and a generic POST fallback.
+- Exported the validator aliases through the v6.17 utility imports; this
+ version has no utility `__all__` list to update.
+- Kept the v6.17 timeout and POST-payload assertions in the request mocks
+ while adding the upstream redirect assertions.
+- Reformatted the synchronous `_durin_call` arguments so the new provider
+ options can be passed on the v6.17 call site.
+- Omitted changes under `tests/core` and `tests/ens` because the PyPI
+ source archive used by this recipe does not ship those directories.
+ The omitted tests were validated separately against the v6.17 Git tag.
+
+(cherry picked from commit d62e67d3b636bd4c5a929696c0f5c4167c31625b)
+Signed-off-by: Hetvi Thakar <hthakar@cisco.com>
+---
+ .../module_testing/module_testing_utils.py | 26 ++++-
+ web3/eth/async_eth.py | 2 +
+ web3/eth/eth.py | 5 +-
+ web3/providers/async_base.py | 5 +
+ web3/providers/base.py | 5 +
+ web3/utils/__init__.py | 4 +
+ web3/utils/async_exception_handling.py | 21 +++-
+ web3/utils/ccip_url_validation.py | 105 ++++++++++++++++++
+ web3/utils/exception_handling.py | 21 +++-
+ 9 files changed, 190 insertions(+), 4 deletions(-)
+ create mode 100644 web3/utils/ccip_url_validation.py
+
+diff --git a/web3/_utils/module_testing/module_testing_utils.py b/web3/_utils/module_testing/module_testing_utils.py
+index 46c82c22..7b05702b 100644
+--- a/web3/_utils/module_testing/module_testing_utils.py
++++ b/web3/_utils/module_testing/module_testing_utils.py
+@@ -89,6 +89,24 @@ def assert_contains_log(
+ assert log_entry["transactionHash"] == HexBytes(txn_hash_with_log)
+
+
++def _mock_getaddrinfo_public(
++ monkeypatch: "MonkeyPatch",
++) -> None:
++ # Patch socket.getaddrinfo to return a public IP for CCIP test domains
++ # so that CCIP URL host validation passes during tests. Pass through
++ # to the real getaddrinfo for all other hosts (e.g. 127.0.0.1 for geth).
++ import socket as _socket
++
++ _original_getaddrinfo = _socket.getaddrinfo
++
++ def _patched_getaddrinfo(host: Any, port: Any, *args: Any, **kwargs: Any) -> Any:
++ if host == "web3.py":
++ return [(_socket.AF_INET, _socket.SOCK_STREAM, 0, "", ("1.2.3.4", 0))]
++ return _original_getaddrinfo(host, port, *args, **kwargs)
++
++ monkeypatch.setattr("socket.getaddrinfo", _patched_getaddrinfo)
++
++
+ def mock_offchain_lookup_request_response(
+ monkeypatch: "MonkeyPatch",
+ http_method: Literal["GET", "POST"] = "GET",
+@@ -100,6 +118,8 @@ def mock_offchain_lookup_request_response(
+ sender: str = None,
+ calldata: str = None,
+ ) -> None:
++ _mock_getaddrinfo_public(monkeypatch)
++
+ class MockedResponse:
+ status_code = mocked_status_code
+
+@@ -119,6 +139,7 @@ def mock_offchain_lookup_request_response(
+ # mock response only to specified url while validating appropriate fields
+ if url_from_args == mocked_request_url:
+ assert kwargs["timeout"] == 10
++ assert kwargs.get("allow_redirects") is False
+ if http_method.upper() == "POST":
+ assert kwargs["data"] == {"data": calldata, "sender": sender}
+ return MockedResponse()
+@@ -146,6 +167,8 @@ def async_mock_offchain_lookup_request_response(
+ sender: str = None,
+ calldata: str = None,
+ ) -> None:
++ _mock_getaddrinfo_public(monkeypatch)
++
+ class AsyncMockedResponse:
+ status = mocked_status_code
+
+@@ -169,7 +192,8 @@ def async_mock_offchain_lookup_request_response(
+ # mock response only to specified url while validating appropriate fields
+ if url_from_args == mocked_request_url:
+ assert kwargs["timeout"] == ClientTimeout(10)
+- if http_method.upper() == "post":
++ assert kwargs.get("allow_redirects") is False
++ if http_method.upper() == "POST":
+ assert kwargs["data"] == {"data": calldata, "sender": sender}
+ return AsyncMockedResponse()
+
+diff --git a/web3/eth/async_eth.py b/web3/eth/async_eth.py
+index b6412a59..14d5679a 100644
+--- a/web3/eth/async_eth.py
++++ b/web3/eth/async_eth.py
+@@ -293,6 +293,8 @@ class AsyncEth(BaseEth):
+ durin_calldata = await async_handle_offchain_lookup(
+ offchain_lookup.payload,
+ transaction,
++ allow_http=self.w3.provider.ccip_read_allow_http,
++ url_validator=self.w3.provider.ccip_read_url_validator,
+ )
+ transaction["data"] = durin_calldata
+
+diff --git a/web3/eth/eth.py b/web3/eth/eth.py
+index 6e1700ca..e11623a7 100644
+--- a/web3/eth/eth.py
++++ b/web3/eth/eth.py
+@@ -279,7 +279,10 @@ class Eth(BaseEth):
+ return self._call(transaction, block_identifier, state_override)
+ except OffchainLookup as offchain_lookup:
+ durin_calldata = handle_offchain_lookup(
+- offchain_lookup.payload, transaction
++ offchain_lookup.payload,
++ transaction,
++ allow_http=self.w3.provider.ccip_read_allow_http,
++ url_validator=self.w3.provider.ccip_read_url_validator,
+ )
+ transaction["data"] = durin_calldata
+
+diff --git a/web3/providers/async_base.py b/web3/providers/async_base.py
+index 30404b6b..64b9f7f8 100644
+--- a/web3/providers/async_base.py
++++ b/web3/providers/async_base.py
+@@ -38,6 +38,9 @@ if TYPE_CHECKING:
+ AsyncWeb3,
+ WebsocketProviderV2,
+ )
++ from web3.utils.ccip_url_validation import (
++ AsyncCcipUrlValidator,
++ )
+
+
+ class AsyncBaseProvider:
+@@ -54,6 +57,8 @@ class AsyncBaseProvider:
+ has_persistent_connection = False
+ global_ccip_read_enabled: bool = True
+ ccip_read_max_redirects: int = 4
++ ccip_read_allow_http: bool = False
++ ccip_read_url_validator: "AsyncCcipUrlValidator | None" = None
+
+ @property
+ def middlewares(self) -> Tuple[AsyncMiddleware, ...]:
+diff --git a/web3/providers/base.py b/web3/providers/base.py
+index d7877546..5d91f635 100644
+--- a/web3/providers/base.py
++++ b/web3/providers/base.py
+@@ -32,6 +32,9 @@ from web3.types import (
+
+ if TYPE_CHECKING:
+ from web3 import Web3 # noqa: F401
++ from web3.utils.ccip_url_validation import (
++ CcipUrlValidator,
++ )
+
+
+ class BaseProvider:
+@@ -46,6 +49,8 @@ class BaseProvider:
+ has_persistent_connection = False
+ global_ccip_read_enabled: bool = True
+ ccip_read_max_redirects: int = 4
++ ccip_read_allow_http: bool = False
++ ccip_read_url_validator: "CcipUrlValidator | None" = None
+
+ @property
+ def middlewares(self) -> Tuple[Middleware, ...]:
+diff --git a/web3/utils/__init__.py b/web3/utils/__init__.py
+index 13c24de6..2c4f1d94 100644
+--- a/web3/utils/__init__.py
++++ b/web3/utils/__init__.py
+@@ -14,6 +14,10 @@ from .async_exception_handling import ( # NOQA
+ from .caching import ( # NOQA
+ SimpleCache,
+ )
++from .ccip_url_validation import ( # NOQA
++ AsyncCcipUrlValidator,
++ CcipUrlValidator,
++)
+ from .exception_handling import ( # NOQA
+ handle_offchain_lookup,
+ )
+diff --git a/web3/utils/async_exception_handling.py b/web3/utils/async_exception_handling.py
+index 0619bd5b..e4fa933d 100644
+--- a/web3/utils/async_exception_handling.py
++++ b/web3/utils/async_exception_handling.py
+@@ -26,11 +26,18 @@ from web3.exceptions import (
+ from web3.types import (
+ TxParams,
+ )
++from web3.utils.ccip_url_validation import (
++ AsyncCcipUrlValidator,
++ async_validate_ccip_url_host,
++ validate_ccip_url_scheme,
++)
+
+
+ async def async_handle_offchain_lookup(
+ offchain_lookup_payload: Dict[str, Any],
+ transaction: TxParams,
++ allow_http: bool = False,
++ url_validator: AsyncCcipUrlValidator | None = None,
+ ) -> bytes:
+ formatted_sender = to_hex_if_bytes(offchain_lookup_payload["sender"]).lower()
+ formatted_data = to_hex_if_bytes(offchain_lookup_payload["callData"]).lower()
+@@ -48,13 +55,25 @@ async def async_handle_offchain_lookup(
+ .replace("{data}", str(formatted_data))
+ )
+
++ try:
++ validate_ccip_url_scheme(formatted_url, allow_http=allow_http)
++ await async_validate_ccip_url_host(formatted_url)
++ if url_validator is not None:
++ await url_validator(formatted_url)
++ except Web3ValidationError:
++ continue
++
+ try:
+ if "{data}" in url and "{sender}" in url:
+- response = await async_get_response_from_get_request(formatted_url)
++ response = await async_get_response_from_get_request(
++ formatted_url,
++ allow_redirects=False,
++ )
+ elif "{sender}" in url:
+ response = await async_get_response_from_post_request(
+ formatted_url,
+ data={"data": formatted_data, "sender": formatted_sender},
++ allow_redirects=False,
+ )
+ else:
+ raise Web3ValidationError("url not formatted properly.")
+diff --git a/web3/utils/ccip_url_validation.py b/web3/utils/ccip_url_validation.py
+new file mode 100644
+index 00000000..a86618d8
+--- /dev/null
++++ b/web3/utils/ccip_url_validation.py
+@@ -0,0 +1,105 @@
++import asyncio
++import ipaddress
++import socket
++from typing import (
++ Awaitable,
++ Callable,
++)
++from urllib.parse import (
++ urlparse,
++)
++
++from web3.exceptions import (
++ Web3ValidationError,
++)
++
++CcipUrlValidator = Callable[[str], None]
++AsyncCcipUrlValidator = Callable[[str], Awaitable[None]]
++
++BLOCKED_IP_NETWORKS = [
++ ipaddress.ip_network("127.0.0.0/8"),
++ ipaddress.ip_network("10.0.0.0/8"),
++ ipaddress.ip_network("172.16.0.0/12"),
++ ipaddress.ip_network("192.168.0.0/16"),
++ ipaddress.ip_network("169.254.0.0/16"),
++ ipaddress.ip_network("0.0.0.0/8"),
++ ipaddress.ip_network("::1/128"),
++ ipaddress.ip_network("fe80::/10"),
++ ipaddress.ip_network("fc00::/7"),
++ ipaddress.ip_network("::/128"),
++]
++
++
++def validate_ccip_url_scheme(url: str, allow_http: bool = False) -> None:
++ parsed = urlparse(url)
++ scheme = parsed.scheme.lower()
++
++ if scheme == "https":
++ return
++
++ if scheme == "http" and allow_http:
++ return
++
++ if scheme == "http":
++ raise Web3ValidationError(
++ f"CCIP Read request to non-HTTPS URL '{url}' is not allowed. "
++ "Set ``ccip_read_allow_http=True`` on the provider to allow HTTP URLs."
++ )
++
++ raise Web3ValidationError(
++ f"CCIP Read request with scheme '{scheme}' is not allowed. "
++ "Only HTTPS URLs are permitted."
++ )
++
++
++def _check_ip_blocked(ip_str: str) -> bool:
++ try:
++ addr = ipaddress.ip_address(ip_str)
++ except ValueError:
++ return False
++ return any(addr in network for network in BLOCKED_IP_NETWORKS)
++
++
++def validate_ccip_url_host(url: str) -> None:
++ parsed = urlparse(url)
++ hostname = parsed.hostname
++ if not hostname:
++ raise Web3ValidationError(f"CCIP Read URL '{url}' has no hostname.")
++
++ try:
++ addrinfos = socket.getaddrinfo(hostname, None)
++ except socket.gaierror:
++ raise Web3ValidationError(
++ f"CCIP Read URL hostname '{hostname}' could not be resolved."
++ )
++
++ for addrinfo in addrinfos:
++ ip_str = str(addrinfo[4][0])
++ if _check_ip_blocked(ip_str):
++ raise Web3ValidationError(
++ f"CCIP Read request to '{url}' is not allowed: "
++ f"resolved IP '{ip_str}' is in a blocked private/reserved range."
++ )
++
++
++async def async_validate_ccip_url_host(url: str) -> None:
++ parsed = urlparse(url)
++ hostname = parsed.hostname
++ if not hostname:
++ raise Web3ValidationError(f"CCIP Read URL '{url}' has no hostname.")
++
++ loop = asyncio.get_running_loop()
++ try:
++ addrinfos = await loop.run_in_executor(None, socket.getaddrinfo, hostname, None)
++ except socket.gaierror:
++ raise Web3ValidationError(
++ f"CCIP Read URL hostname '{hostname}' could not be resolved."
++ )
++
++ for addrinfo in addrinfos:
++ ip_str = str(addrinfo[4][0])
++ if _check_ip_blocked(ip_str):
++ raise Web3ValidationError(
++ f"CCIP Read request to '{url}' is not allowed: "
++ f"resolved IP '{ip_str}' is in a blocked private/reserved range."
++ )
+diff --git a/web3/utils/exception_handling.py b/web3/utils/exception_handling.py
+index 77a46fc6..1d5ee0bd 100644
+--- a/web3/utils/exception_handling.py
++++ b/web3/utils/exception_handling.py
+@@ -25,11 +25,18 @@ from web3.exceptions import (
+ from web3.types import (
+ TxParams,
+ )
++from web3.utils.ccip_url_validation import (
++ CcipUrlValidator,
++ validate_ccip_url_host,
++ validate_ccip_url_scheme,
++)
+
+
+ def handle_offchain_lookup(
+ offchain_lookup_payload: Dict[str, Any],
+ transaction: TxParams,
++ allow_http: bool = False,
++ url_validator: CcipUrlValidator | None = None,
+ ) -> bytes:
+ formatted_sender = to_hex_if_bytes(offchain_lookup_payload["sender"]).lower()
+ formatted_data = to_hex_if_bytes(offchain_lookup_payload["callData"]).lower()
+@@ -47,9 +54,20 @@ def handle_offchain_lookup(
+ .replace("{data}", str(formatted_data))
+ )
+
++ try:
++ validate_ccip_url_scheme(formatted_url, allow_http=allow_http)
++ validate_ccip_url_host(formatted_url)
++ if url_validator is not None:
++ url_validator(formatted_url)
++ except Web3ValidationError:
++ continue
++
+ try:
+ if "{data}" in url and "{sender}" in url:
+- response = get_response_from_get_request(formatted_url)
++ response = get_response_from_get_request(
++ formatted_url,
++ allow_redirects=False,
++ )
+ elif "{sender}" in url:
+ response = get_response_from_post_request(
+ formatted_url,
+@@ -57,6 +75,7 @@ def handle_offchain_lookup(
+ "data": formatted_data,
+ "sender": formatted_sender,
+ },
++ allow_redirects=False,
+ )
+ else:
+ raise Web3ValidationError("url not formatted properly.")
+--
+2.35.6
@@ -4,6 +4,7 @@ SECTION = "devel/python"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://LICENSE;md5=373fede350846fdffd23648fba504635"
+SRC_URI += "file://CVE-2026-40072.patch"
SRC_URI[sha256sum] = "1b535272a40da3d8d2b120856edb53b84b0c08bcc8fe1a5bbd5f816fd72f4ec6"
inherit pypi setuptools3