mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-08-30 00:33:19 +00:00
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:
committed by
Anuj Mittal
parent
34800488de
commit
039e0dea33
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user