diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 8ddd1d53..e3f162c0 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -33,6 +33,32 @@ YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = ( ) +CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = ( + 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("104.16.0.0/13"), + ip_network("104.24.0.0/14"), + ip_network("172.64.0.0/13"), + ip_network("131.0.72.0/22"), + 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"), +) + + YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = ( "payment.succeeded", "payment.waiting_for_capture", @@ -122,6 +148,9 @@ def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool: ): return True + if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS): + return True + return any(ip_object in network for network in _get_trusted_proxy_networks()) @@ -180,6 +209,7 @@ class YooKassaWebhookHandler: header_ip_candidates = collect_yookassa_ip_candidates( request.headers.get("X-Forwarded-For"), request.headers.get("X-Real-IP"), + request.headers.get("Cf-Connecting-Ip"), ) client_ip = resolve_yookassa_ip( header_ip_candidates, diff --git a/app/webserver/payments.py b/app/webserver/payments.py index e78a5514..f915eed2 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -349,6 +349,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute header_ip_candidates = yookassa_webhook_module.collect_yookassa_ip_candidates( request.headers.get("X-Forwarded-For"), request.headers.get("X-Real-IP"), + request.headers.get("Cf-Connecting-Ip"), ) remote_ip = request.client.host if request.client else None client_ip = yookassa_webhook_module.resolve_yookassa_ip( diff --git a/tests/external/test_yookassa_webhook.py b/tests/external/test_yookassa_webhook.py index 6a210968..03b98927 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 @@ -138,6 +139,32 @@ async def test_handle_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: process_mock.assert_awaited_once() +@pytest.mark.asyncio +async def test_handle_webhook_trusts_cf_connecting_ip(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"} + body = json.dumps(payload, ensure_ascii=False) + headers = _build_headers() + headers.pop("X-Forwarded-For") + response = await client.post( + settings.YOOKASSA_WEBHOOK_PATH, + data=body.encode("utf-8"), + headers=headers, + ) + status = response.status + text = await response.text() + + assert status == 200 + assert text == "OK" + process_mock.assert_awaited_once() + + @pytest.mark.asyncio async def test_handle_webhook_with_optional_signature(monkeypatch: pytest.MonkeyPatch) -> None: _patch_get_db(monkeypatch) diff --git a/tests/webserver/test_payments.py b/tests/webserver/test_payments.py index 22fe3b2f..fb66fcff 100644 --- a/tests/webserver/test_payments.py +++ b/tests/webserver/test_payments.py @@ -263,6 +263,37 @@ async def test_yookassa_allowed_via_forwarded_header_when_proxy(monkeypatch: pyt process_mock.assert_awaited_once() +@pytest.mark.anyio +async def test_yookassa_allowed_via_cf_connecting_ip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + + async def fake_get_db(): + yield SimpleNamespace() + + monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + + process_mock = AsyncMock(return_value=True) + service = SimpleNamespace(process_yookassa_webhook=process_mock) + + router = create_payment_router(DummyBot(), service) + assert router is not None + + route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) + request = _build_request( + settings.YOOKASSA_WEBHOOK_PATH, + body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + headers={"Cf-Connecting-Ip": "185.71.76.10"}, + client_ip="172.64.223.133", + ) + + response = await route.endpoint(request) + + assert response.status_code == 200 + payload = json.loads(response.body.decode("utf-8")) + assert payload["status"] == "ok" + process_mock.assert_awaited_once() + + @pytest.mark.anyio async def test_yookassa_allowed_via_trusted_forwarded_chain(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)