fix(security): keep Gemini key out of request URLs
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 15m5s

This commit is contained in:
Your Name
2026-04-29 22:56:12 +08:00
parent b857be0a64
commit d845d53257
8 changed files with 72 additions and 9 deletions

View File

@@ -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": {

View File

@@ -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}},
)

View File

@@ -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}]}],

View File

@@ -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:

View File

@@ -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()

View File

@@ -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": {

View 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

View File

@@ -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):