From 694a1217581e39036e0c174ab85616ee6656139d Mon Sep 17 00:00:00 2001 From: fselmo 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 --- 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