From abbfe4a7d3662094b8ba65bd4d1f9656d4120721 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 8 Nov 2025 10:50:56 +0300 Subject: [PATCH] Handle Cloudflare forwarded IPs for YooKassa webhooks --- app/external/yookassa_webhook.py | 36 ++++++++++++++++++++++--- tests/external/test_yookassa_webhook.py | 32 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 8ddd1d53..953595f1 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -40,6 +40,34 @@ YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = ( ) +_DEFAULT_TRUSTED_PROXY_NETWORKS: tuple[IPNetwork, ...] = ( + # Cloudflare IPv4 ranges + ip_network("173.245.48.0/20"), + ip_network("103.21.244.0/22"), + ip_network("103.22.200.0/22"), + ip_network("103.31.4.0/22"), + ip_network("141.101.64.0/18"), + ip_network("108.162.192.0/18"), + ip_network("190.93.240.0/20"), + ip_network("188.114.96.0/20"), + ip_network("197.234.240.0/22"), + ip_network("198.41.128.0/17"), + ip_network("162.158.0.0/15"), + ip_network("172.64.0.0/13"), + ip_network("131.0.72.0/22"), + ip_network("104.16.0.0/13"), + ip_network("104.24.0.0/14"), + # Cloudflare IPv6 ranges + ip_network("2400:cb00::/32"), + ip_network("2606:4700::/32"), + ip_network("2803:f800::/32"), + ip_network("2405:b500::/32"), + ip_network("2405:8100::/32"), + ip_network("2a06:98c0::/29"), + ip_network("2c0f:f248::/32"), +) + + def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]: candidates: List[str] = [] for value in values: @@ -87,7 +115,7 @@ def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool: ) -_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ()) +_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", _DEFAULT_TRUSTED_PROXY_NETWORKS) def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]: @@ -96,10 +124,10 @@ def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]: raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or "" cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE - if raw_value == cached_raw: + if raw_value == cached_raw and cached_networks: return cached_networks - networks: List[IPNetwork] = [] + networks: List[IPNetwork] = list(_DEFAULT_TRUSTED_PROXY_NETWORKS) for part in raw_value.split(","): candidate = part.strip() if not candidate: @@ -178,6 +206,8 @@ class YooKassaWebhookHandler: logger.info(f"📋 Headers: {dict(request.headers)}") header_ip_candidates = collect_yookassa_ip_candidates( + request.headers.get("CF-Connecting-IP"), + request.headers.get("True-Client-IP"), request.headers.get("X-Forwarded-For"), request.headers.get("X-Real-IP"), ) diff --git a/tests/external/test_yookassa_webhook.py b/tests/external/test_yookassa_webhook.py index 6a210968..04a3309a 100644 --- a/tests/external/test_yookassa_webhook.py +++ b/tests/external/test_yookassa_webhook.py @@ -32,6 +32,7 @@ def _build_headers(**overrides: str) -> dict[str, str]: headers = { "Content-Type": "application/json", "X-Forwarded-For": ALLOWED_IP, + "CF-Connecting-IP": ALLOWED_IP, } headers.update(overrides) return headers @@ -72,6 +73,15 @@ def test_resolve_yookassa_ip_accepts_allowed_last_forwarded_candidate() -> None: assert str(ip_object) == ALLOWED_IP +def test_resolve_yookassa_ip_handles_cloudflare_proxy() -> None: + candidates = [ALLOWED_IP] + + ip_object = resolve_yookassa_ip(candidates, remote="172.64.223.133") + + assert ip_object is not None + assert str(ip_object) == ALLOWED_IP + + def test_resolve_yookassa_ip_skips_trusted_proxy_hops(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "203.0.113.0/24", raising=False) @@ -182,3 +192,25 @@ async def test_handle_webhook_accepts_canceled_event(monkeypatch: pytest.MonkeyP assert status == 200 process_mock.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_handle_webhook_allows_cloudflare_forwarding(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_get_db(monkeypatch) + + process_mock = AsyncMock(return_value=True) + service = SimpleNamespace(process_yookassa_webhook=process_mock) + + app = create_yookassa_webhook_app(service) + async with TestClient(TestServer(app)) as client: + payload = {"event": "payment.succeeded"} + response = await client.post( + settings.YOOKASSA_WEBHOOK_PATH, + data=json.dumps(payload).encode("utf-8"), + headers=_build_headers(**{"X-Forwarded-For": "172.64.223.133"}), + ) + + status = response.status + + assert status == 200 + process_mock.assert_awaited_once()