python3-web3: Fix CVE-2026-40072

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>
Signed-off-by: Anuj Mittal <anuj.mittal@oss.qualcomm.com>
This commit is contained in:
Hetvi Thakar
2026-08-19 22:42:39 -07:00
committed by Anuj Mittal
parent 34800488de
commit 039e0dea33
2 changed files with 862 additions and 0 deletions
@@ -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
@@ -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