From patchwork Thu Aug 20 05:42:39 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: "Hetvi Thakar -X (hthakar - E INFOCHIPS PRIVATE LIMITED at Cisco)" X-Patchwork-Id: 95863 Return-Path: X-Spam-Checker-Version: SpamAssassin 3.4.0 (2014-02-07) on aws-us-west-2-korg-lkml-1.web.codeaurora.org Received: from aws-us-west-2-korg-lkml-1.web.codeaurora.org (localhost.localdomain [127.0.0.1]) by smtp.lore.kernel.org (Postfix) with ESMTP id CA69CC5DF81 for ; Thu, 20 Aug 2026 05:42:55 +0000 (UTC) Received: from alln-iport-6.cisco.com (alln-iport-6.cisco.com [173.37.142.93]) by mx.groups.io with SMTP id smtpd.msgproc01-g2.818.1787204567413775421 for ; Wed, 19 Aug 2026 22:42:47 -0700 Authentication-Results: mx.groups.io; dkim=fail reason="dkim: message contains an insecure body length tag" header.i=@cisco.com header.s=iport01 header.b=LQ1AMrsz; spf=pass (domain: cisco.com, ip: 173.37.142.93, mailfrom: hthakar@cisco.com) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=cisco.com; i=@cisco.com; l=31031; q=dns/txt; s=iport01; t=1787204567; x=1788414167; h=from:to:cc:subject:date:message-id:mime-version: content-transfer-encoding; bh=W8DhV0cBpLYwQu737b8VaUYGcMz9lazFGTyCYKfqeT8=; b=LQ1AMrszN5e1CouF0U8XtVwHBfENK8tN1w9VdG7a3wfWMwR8IeN+ZZvL XUDjdwBKZ91dS3VWusqAC6geE1tOw7MJdYCAEKNr/Pro8j3J0mntm74e1 FeOlt8Lw4M5Sd9KLQBLfP9ktw/4YXNOgf6prBSbkci5fwShQuT1O6qE96 hZb8hXCqB/t8BS8/Bq4U/LTYPIgoWshUlrwOHi59z5kbzReXeVmc6TZK2 9qkuCnxxpaooosnOCRQTim6buOLdvb2RP29Csi4gXwcGEG8njvHtgZjEx 5A5CBcdAeB/fQb5B6xfDf+U94eYhsCxTGDiN9nEAQkSK7nNlOpQEYe6Ag g==; X-CSE-ConnectionGUID: y1PQ+AXsRbWNqE66DLmzwQ== X-CSE-MsgGUID: dxf/B9WUSBieHvJiesY1BA== X-IPAS-Result: A0BLAgBXk4Zq/4sQJK1aAQoTAQELEgyCBQuCV3ReQysehFeRB2yLZ5I3gX4PAQEBD0QNBAEBhQWNbQImNAkOAQIEAwIDAQEBAQEBAQEBAQELAQEFAQEBAgEHBYEOE4ZPDYZaAQIBHQkECwEYAS0sAwECAwImAiILIxgJgwIBgjoDNwMICcIxen8zgQGDKAGBVNhLDYJYAQsUAQWBBS6FP4J/IAGFAl0YAUSEOCcbG4FygRWDaYEFgRo3CwKFO4JqBIIigQyBWAKCHgFijm9IgQIcA1ksAVUTDQoLBwWBZgM1EioVbjIdgSM+FzVYGwYFgR2BKIQ3Ixk2fIEJXoErKmEBEheBCYIKAoJwggYCAUlFDgkXCxgNSBEsNxQZBD0BbgeOURULgVlgEi1OEwEqARcLfhSBBg4JkxISJ49lgiGBNZ5pcQoog3aMIY8+hXwaM4QEgVeSQJJRC5h9ix4JD4JUhAmCBoFNjV9FUIRpgWg8gVlwFYMiCRY0GQ+OKgQKC4NghU4WxlUnMgIJMgEBBwIHDgMLgWiQAi2BTwEB IronPort-Data: A9a23:IXn9P6kUQ9ou68VE4k0CloXo5gzQJ0RdPkR7XQ2eYbSJt1+Wr1Gzt xJJD2GOMqyLZWGgKN0nPIu090wAucfSmtcwQFY5/HszH1tH+JHPbTi7wugcHM8zwunrFh8PA xA2M4GYRCwMZiaC4E/raf658SUUOZigHtLUEPTDNj16WThqQSIgjQMLs+Mii+aEu/Dha++2k Y20+ZC31GONgWYubDpEsvPb8nuDgdyr0N8mlg1mDRx0lAe2e0k9VPo3Oay3Jn3kdYhYdsbSb /rD1ryw4lTC9B4rDN6/+p6jGqHdauePVeQmoiM+t5mK2nCulARrukoIHKZ0hXNsttm8t4sZJ OOhGnCHYVxB0qXkwIzxWvTDes10FfUuFLTveRBTvSEPpqHLWyOE/hlgMK05FZxBwt80KFFVz /YnDikgME6vo+ek34vuH4GAhux7RCXqFIobvnclyXTSCuwrBMiZBa7L/tRfmjw3g6iiH96HO JFfMmQpNUqGOkYeUrsUIMpWcOOAhmX/ej5RsnqepLE85C7YywkZPL3FYIOPJILUGZ4E9qqej m7W8SPTUy4nD/aexBqb0HOhnd6WtwquDer+E5X9rJaGmma7wXQeDhATX1a3rfS1z0KzRd9bA 0gV4TY1668q+UqmS9PwUxG1rDiDpBF0ZjZLO+Q+7AfIzu/f5ByUQzBeCDVAc9ch8sQxQFTGy 2O0oj8gPhQ32JX9dJ5X3u7Nxd9uEUD59VM/WBI= IronPort-HdrOrdr: A9a23:l/i4fa1UwXVGTg6YjoXB9wqjBJAkLtp133Aq2lEZdPWaSKOlfq eV7ZAmPH7P+VMssR4b+OxoVJPsfZq+z+8W3WBuB9eftWDd0QPCRr2KhrGN/9SPIUHDH8dmpM BdmtBFeb7NJGk/q9rm6w+lFNtl6tyG/Ke0wdr69R5WPGdXg2UK1XYANu5deXcGPTV7OQ== X-Talos-CUID: 9a23:QBgDPWyUZnsNGiDm5SFPBgUFQ/gcMVTt103aHGmhJDg3ZJmQW0CprfY= X-Talos-MUID: 9a23:5gLqGwbL9rkmCOBTuy/iwxszC/pTyaWjKF4vkJgdipCuHHkl X-IronPort-Anti-Spam-Filtered: true X-IronPort-AV: E=Sophos;i="6.25,232,1779148800"; d="scan'208";a="812666439" Received: from alln-l-core-02.cisco.com ([173.36.16.139]) by alln-iport-6.cisco.com with ESMTP/TLS/TLS_AES_256_GCM_SHA384; 20 Aug 2026 05:42:41 +0000 Received: from sjc-ads-5245.cisco.com (sjc-ads-5245.cisco.com [10.28.23.9]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by alln-l-core-02.cisco.com (Postfix) with ESMTPS id AE64518000199; Thu, 20 Aug 2026 05:42:41 +0000 (GMT) Received: by sjc-ads-5245.cisco.com (Postfix, from userid 1887505) id 4E6CFCCA79B; Wed, 19 Aug 2026 22:42:41 -0700 (PDT) From: "Hetvi Thakar -X (hthakar - E INFOCHIPS PRIVATE LIMITED at Cisco)" To: openembedded-devel@lists.openembedded.org Cc: xe-linux-external@cisco.com, Hetvi Thakar Subject: [meta-python][wrynose][PATCH] python3-web3: Fix CVE-2026-40072 Date: Wed, 19 Aug 2026 22:42:39 -0700 Message-Id: <20260820054239.1363898-1-hthakar@cisco.com> X-Mailer: git-send-email 2.35.6 MIME-Version: 1.0 X-Auto-Response-Suppress: DR, OOF, AutoReply X-Outbound-Client-TLS: ANONYMOUS;sjc-ads-5245.cisco.com [10.28.23.9];TLSv1.3;TLS_AES_256_GCM_SHA384;256 X-Outbound-SMTP-Client: 10.28.23.9, sjc-ads-5245.cisco.com X-Outbound-Node: alln-l-core-02.cisco.com List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by aws-us-west-2-korg-lkml-1.web.codeaurora.org with HTTPS for ; Thu, 20 Aug 2026 05:42:55 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/openembedded-devel/message/129378 From: Hetvi Thakar 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 --- .../python/files/CVE-2026-40072.patch | 861 ++++++++++++++++++ .../python/python3-web3_7.12.1.bb | 1 + 2 files changed, 862 insertions(+) create mode 100644 meta-python/recipes-devtools/python/files/CVE-2026-40072.patch diff --git a/meta-python/recipes-devtools/python/files/CVE-2026-40072.patch b/meta-python/recipes-devtools/python/files/CVE-2026-40072.patch new file mode 100644 index 0000000000..d32438290b --- /dev/null +++ b/meta-python/recipes-devtools/python/files/CVE-2026-40072.patch @@ -0,0 +1,861 @@ +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 diff --git a/meta-python/recipes-devtools/python/python3-web3_7.12.1.bb b/meta-python/recipes-devtools/python/python3-web3_7.12.1.bb index fe5886692d..09a1140681 100644 --- a/meta-python/recipes-devtools/python/python3-web3_7.12.1.bb +++ b/meta-python/recipes-devtools/python/python3-web3_7.12.1.bb @@ -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