fix(security): keep Gemini key out of request URLs
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 15m5s
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 15m5s
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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}},
|
||||
)
|
||||
|
||||
@@ -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}]}],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
52
apps/api/tests/test_gemini_provider_security.py
Normal file
52
apps/api/tests/test_gemini_provider_security.py
Normal file
@@ -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
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user