Skip to content

Commit b1c57bb

Browse files
committed
feat: added restrictions on CCIP read durin calls
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
1 parent 5cdfa89 commit b1c57bb

12 files changed

Lines changed: 537 additions & 2 deletions

File tree

tests/core/contracts/test_offchain_lookup.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pytest
2+
import socket
23

34
from eth_abi import (
45
abi,
@@ -14,10 +15,14 @@
1415
to_hex_if_bytes,
1516
)
1617
from web3.exceptions import (
18+
MultipleFailedRequests,
1719
OffchainLookup,
1820
TooManyRequests,
1921
Web3ValidationError,
2022
)
23+
from web3.utils import (
24+
handle_offchain_lookup,
25+
)
2126

2227
# "test offchain lookup" as an abi-encoded string
2328
OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA = "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001474657374206f6666636861696e206c6f6f6b7570000000000000000000000000" # noqa: E501
@@ -208,3 +213,198 @@ def test_offchain_lookup_raises_on_continuous_redirect(
208213
)
209214
with pytest.raises(TooManyRequests, match="Too many CCIP read redirects"):
210215
offchain_lookup_contract.caller.continuousOffchainLookup()
216+
217+
218+
# -- SSRF mitigation tests -- #
219+
220+
221+
def test_offchain_lookup_rejects_http_urls_by_default(
222+
offchain_lookup_contract,
223+
monkeypatch,
224+
):
225+
"""HTTP URLs should be rejected by default (only HTTPS allowed)."""
226+
to_hex_if_bytes(offchain_lookup_contract.address).lower()
227+
228+
# Patch getaddrinfo so host validation passes
229+
def _mock_getaddrinfo(host, port, *args, **kwargs):
230+
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("1.2.3.4", 0))]
231+
232+
monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo)
233+
234+
payload = {
235+
"sender": offchain_lookup_contract.address,
236+
"urls": [
237+
"http://web3.py/gateway/{sender}/{data}.json",
238+
],
239+
"callData": OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA,
240+
"callbackFunction": b"\x00\x00\x00\x00",
241+
"extraData": b"",
242+
}
243+
transaction = {"to": offchain_lookup_contract.address}
244+
245+
with pytest.raises(MultipleFailedRequests):
246+
handle_offchain_lookup(payload, transaction)
247+
248+
249+
def test_offchain_lookup_allows_http_urls_when_configured(
250+
w3,
251+
offchain_lookup_contract,
252+
monkeypatch,
253+
):
254+
"""HTTP URLs should be allowed when ccip_read_allow_http=True on provider."""
255+
normalized_address = to_hex_if_bytes(offchain_lookup_contract.address)
256+
mock_offchain_lookup_request_response(
257+
monkeypatch,
258+
mocked_request_url=f"https://web3.py/gateway/{normalized_address}/{OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA}.json", # noqa: E501
259+
mocked_json_data=WEB3PY_AS_HEXBYTES,
260+
)
261+
262+
w3.provider.ccip_read_allow_http = True
263+
try:
264+
response = offchain_lookup_contract.caller.testOffchainLookup(
265+
OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA
266+
)
267+
assert abi.decode(["string"], response)[0] == "web3py"
268+
finally:
269+
w3.provider.ccip_read_allow_http = False
270+
271+
272+
def test_offchain_lookup_custom_url_validator_rejects(
273+
offchain_lookup_contract,
274+
monkeypatch,
275+
):
276+
"""Custom url_validator on provider that rejects should skip URLs."""
277+
from web3.utils.exception_handling import (
278+
handle_offchain_lookup,
279+
)
280+
281+
# Patch getaddrinfo so host validation passes
282+
def _mock_getaddrinfo(host, port, *args, **kwargs):
283+
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("1.2.3.4", 0))]
284+
285+
monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo)
286+
287+
def reject_all(url):
288+
raise Web3ValidationError(f"Rejected by policy: {url}")
289+
290+
payload = {
291+
"sender": offchain_lookup_contract.address,
292+
"urls": [
293+
"https://web3.py/gateway/{sender}/{data}.json",
294+
],
295+
"callData": OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA,
296+
"callbackFunction": b"\x00\x00\x00\x00",
297+
"extraData": b"",
298+
}
299+
transaction = {"to": offchain_lookup_contract.address}
300+
301+
with pytest.raises(MultipleFailedRequests):
302+
handle_offchain_lookup(payload, transaction, url_validator=reject_all)
303+
304+
305+
def test_offchain_lookup_custom_url_validator_on_provider(
306+
w3,
307+
offchain_lookup_contract,
308+
monkeypatch,
309+
):
310+
"""Custom url_validator set on provider is honored via _durin_call."""
311+
312+
# Patch getaddrinfo so host validation passes
313+
def _mock_getaddrinfo(host, port, *args, **kwargs):
314+
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("1.2.3.4", 0))]
315+
316+
monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo)
317+
318+
validator_calls = []
319+
320+
def tracking_validator(url):
321+
validator_calls.append(url)
322+
raise Web3ValidationError(f"Rejected by policy: {url}")
323+
324+
w3.provider.ccip_read_url_validator = tracking_validator
325+
try:
326+
with pytest.raises(MultipleFailedRequests):
327+
offchain_lookup_contract.caller.testOffchainLookup(
328+
OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA
329+
)
330+
assert len(validator_calls) > 0
331+
finally:
332+
w3.provider.ccip_read_url_validator = None
333+
334+
335+
def test_offchain_lookup_rejects_private_ip(
336+
offchain_lookup_contract,
337+
monkeypatch,
338+
):
339+
"""URLs resolving to private IPs should be rejected."""
340+
from web3.utils.exception_handling import (
341+
handle_offchain_lookup,
342+
)
343+
344+
def _mock_getaddrinfo(host, port, *args, **kwargs):
345+
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))]
346+
347+
monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo)
348+
349+
payload = {
350+
"sender": offchain_lookup_contract.address,
351+
"urls": [
352+
"https://web3.py/gateway/{sender}/{data}.json",
353+
],
354+
"callData": OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA,
355+
"callbackFunction": b"\x00\x00\x00\x00",
356+
"extraData": b"",
357+
}
358+
transaction = {"to": offchain_lookup_contract.address}
359+
360+
with pytest.raises(MultipleFailedRequests):
361+
handle_offchain_lookup(payload, transaction)
362+
363+
364+
def test_offchain_lookup_redirect_not_followed(
365+
offchain_lookup_contract,
366+
monkeypatch,
367+
):
368+
"""302 redirects should not be followed (treated as non-2xx, try next URL)."""
369+
from web3.utils.exception_handling import (
370+
handle_offchain_lookup,
371+
)
372+
373+
# Patch getaddrinfo so host validation passes
374+
def _mock_getaddrinfo(host, port, *args, **kwargs):
375+
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("1.2.3.4", 0))]
376+
377+
monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo)
378+
379+
class Mock302Response:
380+
status_code = 302
381+
382+
@staticmethod
383+
def raise_for_status():
384+
raise Exception("called raise_for_status()")
385+
386+
def _mock_get(*args, **kwargs):
387+
assert kwargs.get("allow_redirects") is False
388+
return Mock302Response()
389+
390+
def _mock_post(*args, **kwargs):
391+
assert kwargs.get("allow_redirects") is False
392+
return Mock302Response()
393+
394+
monkeypatch.setattr("requests.Session.get", _mock_get)
395+
monkeypatch.setattr("requests.Session.post", _mock_post)
396+
397+
payload = {
398+
"sender": offchain_lookup_contract.address,
399+
"urls": [
400+
"https://web3.py/gateway/{sender}/{data}.json",
401+
"https://web3.py/gateway",
402+
],
403+
"callData": OFFCHAIN_LOOKUP_CONTRACT_TEST_DATA,
404+
"callbackFunction": b"\x00\x00\x00\x00",
405+
"extraData": b"",
406+
}
407+
transaction = {"to": offchain_lookup_contract.address}
408+
409+
with pytest.raises(MultipleFailedRequests):
410+
handle_offchain_lookup(payload, transaction)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import pytest
2+
import socket
3+
4+
from web3.exceptions import (
5+
Web3ValidationError,
6+
)
7+
from web3.utils.ccip_url_validation import (
8+
validate_ccip_url_host,
9+
validate_ccip_url_scheme,
10+
)
11+
12+
# -- validate_ccip_url_scheme tests -- #
13+
14+
15+
class TestValidateCcipUrlScheme:
16+
def test_https_passes(self):
17+
validate_ccip_url_scheme("https://example.com/api", allow_http=False)
18+
19+
def test_http_fails_by_default(self):
20+
with pytest.raises(Web3ValidationError, match="non-HTTPS"):
21+
validate_ccip_url_scheme("http://example.com/api")
22+
23+
def test_http_passes_with_allow_http(self):
24+
validate_ccip_url_scheme("http://example.com/api", allow_http=True)
25+
26+
def test_ftp_always_fails(self):
27+
with pytest.raises(Web3ValidationError, match="not allowed"):
28+
validate_ccip_url_scheme("ftp://example.com/file")
29+
30+
def test_ftp_fails_even_with_allow_http(self):
31+
with pytest.raises(Web3ValidationError, match="not allowed"):
32+
validate_ccip_url_scheme("ftp://example.com/file", allow_http=True)
33+
34+
def test_file_scheme_fails(self):
35+
with pytest.raises(Web3ValidationError, match="not allowed"):
36+
validate_ccip_url_scheme("file:///etc/passwd")
37+
38+
def test_file_scheme_fails_with_allow_http(self):
39+
with pytest.raises(Web3ValidationError, match="not allowed"):
40+
validate_ccip_url_scheme("file:///etc/passwd", allow_http=True)
41+
42+
43+
# -- validate_ccip_url_host tests -- #
44+
45+
46+
class TestValidateCcipUrlHost:
47+
def _patch_getaddrinfo(self, monkeypatch, ip):
48+
def _mock_getaddrinfo(host, port, *args, **kwargs):
49+
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (ip, 0))]
50+
51+
monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo)
52+
53+
def test_public_ip_passes(self, monkeypatch):
54+
self._patch_getaddrinfo(monkeypatch, "8.8.8.8")
55+
validate_ccip_url_host("https://example.com/api")
56+
57+
@pytest.mark.parametrize(
58+
"blocked_ip",
59+
[
60+
"127.0.0.1",
61+
"127.0.0.2",
62+
"10.0.0.1",
63+
"10.255.255.255",
64+
"172.16.0.1",
65+
"172.31.255.255",
66+
"192.168.0.1",
67+
"192.168.1.100",
68+
"169.254.0.1",
69+
"0.0.0.0",
70+
],
71+
)
72+
def test_blocked_ipv4(self, monkeypatch, blocked_ip):
73+
self._patch_getaddrinfo(monkeypatch, blocked_ip)
74+
with pytest.raises(Web3ValidationError, match="blocked private/reserved"):
75+
validate_ccip_url_host("https://example.com/api")
76+
77+
def test_blocked_ipv6_loopback(self, monkeypatch):
78+
def _mock_getaddrinfo(host, port, *args, **kwargs):
79+
return [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("::1", 0, 0, 0))]
80+
81+
monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo)
82+
with pytest.raises(Web3ValidationError, match="blocked private/reserved"):
83+
validate_ccip_url_host("https://example.com/api")
84+
85+
def test_unresolvable_hostname(self, monkeypatch):
86+
def _mock_getaddrinfo(host, port, *args, **kwargs):
87+
raise socket.gaierror("Name or service not known")
88+
89+
monkeypatch.setattr("socket.getaddrinfo", _mock_getaddrinfo)
90+
with pytest.raises(Web3ValidationError, match="could not be resolved"):
91+
validate_ccip_url_host("https://nonexistent.invalid/api")
92+
93+
def test_no_hostname(self):
94+
with pytest.raises(Web3ValidationError, match="no hostname"):
95+
validate_ccip_url_host("https:///path")
96+
97+
98+
# -- custom validator tests -- #
99+
100+
101+
class TestCustomUrlValidator:
102+
def test_custom_validator_called_and_can_reject(self):
103+
calls = []
104+
105+
def reject_validator(url):
106+
calls.append(url)
107+
raise Web3ValidationError(f"Rejected: {url}")
108+
109+
with pytest.raises(Web3ValidationError, match="Rejected"):
110+
reject_validator("https://example.com/api")
111+
112+
assert len(calls) == 1
113+
assert calls[0] == "https://example.com/api"
114+
115+
def test_custom_validator_can_allow(self):
116+
calls = []
117+
118+
def allow_validator(url):
119+
calls.append(url)
120+
121+
allow_validator("https://example.com/api")
122+
assert len(calls) == 1

0 commit comments

Comments
 (0)