mirror of
https://github.com/openembedded/meta-openembedded.git
synced 2026-09-05 17:30:17 +00:00
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>
862 lines
31 KiB
Diff
862 lines
31 KiB
Diff
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
|