From d845d53257d41692037b4eca8ef5608e866874e4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 29 Apr 2026 22:56:12 +0800 Subject: [PATCH] fix(security): keep Gemini key out of request URLs --- apps/api/src/services/ai_providers/gemini.py | 7 +-- apps/api/src/services/alert_rule_engine.py | 2 +- apps/api/src/services/chat_manager.py | 3 +- .../src/services/heartbeat_report_service.py | 5 +- apps/api/src/services/model_version_probe.py | 3 +- apps/api/src/services/openclaw.py | 3 +- .../tests/test_gemini_provider_security.py | 52 +++++++++++++++++++ apps/api/tests/test_model_version_probe.py | 6 +++ 8 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 apps/api/tests/test_gemini_provider_security.py diff --git a/apps/api/src/services/ai_providers/gemini.py b/apps/api/src/services/ai_providers/gemini.py index 16954a73..1ced3003 100644 --- a/apps/api/src/services/ai_providers/gemini.py +++ b/apps/api/src/services/ai_providers/gemini.py @@ -10,7 +10,7 @@ Google Gemini Cloud API (gemini-2.0-flash) 2026-04-29 ogt + Claude Code: P0 SECRET LEAK 修復 發現 prod log 出現完整 API key 明碼(feedback_secret_debug_output_ban 鐵律) 根因:httpx HTTPStatusError str() 會包含完整 URL(含 ?key=... query string) - 修法:_sanitize_error 移除 URL query string + redact key + 修法:改用 x-goog-api-key header,並保留 _sanitize_error 當防線 """ from __future__ import annotations @@ -22,7 +22,7 @@ import httpx import structlog from src.core.config import get_settings -from src.services.ai_providers.interfaces import AIProvider, AIResult, is_provider_enabled_by_env +from src.services.ai_providers.interfaces import AIResult, is_provider_enabled_by_env from src.services.model_registry import get_model_registry logger = structlog.get_logger(__name__) @@ -91,7 +91,8 @@ class GeminiProvider: model_name = registry.get_model("gemini", "rca") response = await client.post( - f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent?key={settings.GEMINI_API_KEY}", + f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent", + headers={"x-goog-api-key": settings.GEMINI_API_KEY}, json={ "contents": [{"parts": [{"text": prompt}]}], "generationConfig": { diff --git a/apps/api/src/services/alert_rule_engine.py b/apps/api/src/services/alert_rule_engine.py index b3026dcf..5e3235be 100644 --- a/apps/api/src/services/alert_rule_engine.py +++ b/apps/api/src/services/alert_rule_engine.py @@ -715,7 +715,7 @@ async def _call_gemini(prompt: str, api_key: str) -> str | None: async with httpx.AsyncClient(timeout=30) as client: resp = await client.post( url, - params={"key": api_key}, + headers={"x-goog-api-key": api_key}, json={"contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"temperature": 0.1}}, ) diff --git a/apps/api/src/services/chat_manager.py b/apps/api/src/services/chat_manager.py index 5d993b50..22925f44 100644 --- a/apps/api/src/services/chat_manager.py +++ b/apps/api/src/services/chat_manager.py @@ -119,7 +119,8 @@ class ChatManager: try: async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post( - f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}", + f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent", + headers={"x-goog-api-key": api_key}, json={ "system_instruction": {"parts": [{"text": system_prompt}]}, "contents": [{"parts": [{"text": user_message}]}], diff --git a/apps/api/src/services/heartbeat_report_service.py b/apps/api/src/services/heartbeat_report_service.py index 1b6b1297..926953ed 100644 --- a/apps/api/src/services/heartbeat_report_service.py +++ b/apps/api/src/services/heartbeat_report_service.py @@ -244,7 +244,7 @@ class HeartbeatReportService: model_status[required] = ok return { - "probe": ProbeResult(True, f"✅ 正常", round(latency, 1)), + "probe": ProbeResult(True, "✅ 正常", round(latency, 1)), "models": model_status, } except Exception as e: @@ -279,7 +279,8 @@ class HeartbeatReportService: async with httpx.AsyncClient(timeout=_PROBE_TIMEOUT) as client: t0 = asyncio.get_event_loop().time() resp = await client.get( - f"https://generativelanguage.googleapis.com/v1beta/models?key={settings.GEMINI_API_KEY}", + "https://generativelanguage.googleapis.com/v1beta/models", + headers={"x-goog-api-key": settings.GEMINI_API_KEY}, ) latency = (asyncio.get_event_loop().time() - t0) * 1000 if resp.status_code == 200: diff --git a/apps/api/src/services/model_version_probe.py b/apps/api/src/services/model_version_probe.py index 3ab08427..46dff368 100644 --- a/apps/api/src/services/model_version_probe.py +++ b/apps/api/src/services/model_version_probe.py @@ -105,7 +105,8 @@ async def probe_gemini_version() -> ProviderVersionInfo: async with httpx.AsyncClient(timeout=8.0) as client: resp = await client.get( "https://generativelanguage.googleapis.com/v1beta/models", - params={"key": api_key, "pageSize": 50}, + params={"pageSize": 50}, + headers={"x-goog-api-key": api_key}, ) resp.raise_for_status() data = resp.json() diff --git a/apps/api/src/services/openclaw.py b/apps/api/src/services/openclaw.py index b26f9247..eddcf518 100644 --- a/apps/api/src/services/openclaw.py +++ b/apps/api/src/services/openclaw.py @@ -507,7 +507,8 @@ class OpenClawService: model_name = registry.get_model("gemini", "rca") response = await client.post( - f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent?key={settings.GEMINI_API_KEY}", + f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent", + headers={"x-goog-api-key": settings.GEMINI_API_KEY}, json={ "contents": [{"parts": [{"text": prompt}]}], "generationConfig": { diff --git a/apps/api/tests/test_gemini_provider_security.py b/apps/api/tests/test_gemini_provider_security.py new file mode 100644 index 00000000..e1d1f82c --- /dev/null +++ b/apps/api/tests/test_gemini_provider_security.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.services.ai_providers.gemini import GeminiProvider + + +class _FakeGeminiResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return { + "candidates": [{"content": {"parts": [{"text": "{\"ok\": true}"}]}}], + "usageMetadata": { + "promptTokenCount": 3, + "candidatesTokenCount": 2, + "totalTokenCount": 5, + }, + } + + +@pytest.mark.asyncio +async def test_gemini_provider_uses_header_auth_not_query_string(): + client = MagicMock() + client.is_closed = False + client.post = AsyncMock(return_value=_FakeGeminiResponse()) + + registry = MagicMock() + registry.get_model.return_value = "gemini-2.0-flash" + registry.get_provider_options.return_value = {"temperature": 0.1} + + provider = GeminiProvider() + provider._http_client = client + + with patch( + "src.services.ai_providers.gemini.settings", + SimpleNamespace(GEMINI_API_KEY="fake-key"), + ), patch("src.services.ai_providers.gemini.get_model_registry", return_value=registry): + result = await provider.analyze("hello") + + assert result.success is True + client.post.assert_awaited_once() + url = client.post.await_args.args[0] + kwargs = client.post.await_args.kwargs + assert url == "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent" + assert kwargs["headers"] == {"x-goog-api-key": "fake-key"} + assert "fake-key" not in url + assert "key=" not in url diff --git a/apps/api/tests/test_model_version_probe.py b/apps/api/tests/test_model_version_probe.py index 72f32c75..cbf0e60a 100644 --- a/apps/api/tests/test_model_version_probe.py +++ b/apps/api/tests/test_model_version_probe.py @@ -190,6 +190,12 @@ class TestProbeGeminiVersion: assert info.provider == "gemini" assert "gemini" in info.model assert info.digest is None + mock_client.get.assert_awaited_once() + _, kwargs = mock_client.get.await_args + assert kwargs["headers"] == {"x-goog-api-key": "fake-key"} + assert kwargs["params"] == {"pageSize": 50} + assert "fake-key" not in mock_client.get.await_args.args[0] + assert "fake-key" not in str(kwargs["params"]) @pytest.mark.asyncio async def test_missing_api_key_raises(self):