fix: mask API keys in provider endpoint responses (#14)

* fix: mask API keys in provider endpoint responses

The GET /llm_toolkit/providers endpoint now returns masked keys
(e.g. "sk-••••••xxxx") instead of full API keys. The POST save
handler preserves existing keys when it receives a masked value,
and the check-connectivity endpoint accepts a providerId to look
up the real key server-side instead of requiring it from the frontend.

* fix: prioritize user-typed key over stored key in check endpoint
This commit is contained in:
科林 KELIN
2026-04-13 07:41:08 +08:00
committed by GitHub
parent cfe681db46
commit 5cc27b9c12
2 changed files with 61 additions and 9 deletions
+40 -4
View File
@@ -103,10 +103,23 @@ def _save_providers(data: dict):
# ─── API Route Handlers ─────────────────────────────────────────────────────
def _mask_key(key: str) -> str:
"""Mask an API key for safe transmission to the frontend."""
if not key:
return ""
if len(key) <= 8:
return "••••"
return key[:3] + "••••••" + key[-4:]
async def get_providers(request: web.Request) -> web.Response:
"""GET /llm_toolkit/providers — Return all provider configurations."""
import copy
data = _ensure_providers_file()
return web.json_response(data)
safe_data = copy.deepcopy(data)
for p in safe_data.get("providers", []):
p["apiKey"] = _mask_key(p.get("apiKey", ""))
return web.json_response(safe_data)
async def save_provider(request: web.Request) -> web.Response:
@@ -138,6 +151,10 @@ async def save_provider(request: web.Request) -> web.Response:
found = False
for i, p in enumerate(providers):
if p.get("id") == provider_id:
# Preserve existing key if frontend sent back a masked value
incoming_key = body.get("apiKey", "")
if not incoming_key or "\u2022\u2022\u2022\u2022" in incoming_key:
body["apiKey"] = p.get("apiKey", "")
# Preserve isSystem flag from existing record
body["isSystem"] = p.get("isSystem", False)
providers[i] = body
@@ -151,7 +168,11 @@ async def save_provider(request: web.Request) -> web.Response:
_save_providers(data)
logger.info(f"{'Updated' if found else 'Created'} provider: {body.get('name')} ({provider_id})")
return web.json_response({"status": "ok", "provider": body})
# Return masked key in the response to avoid leaking it
import copy
safe_body = copy.deepcopy(body)
safe_body["apiKey"] = _mask_key(safe_body.get("apiKey", ""))
return web.json_response({"status": "ok", "provider": safe_body})
async def delete_provider(request: web.Request) -> web.Response:
@@ -191,10 +212,24 @@ async def check_provider(request: web.Request) -> web.Response:
except json.JSONDecodeError:
return web.json_response({"error": "Invalid JSON body"}, status=400)
api_key = body.get("apiKey", "").strip()
provider_id = body.get("providerId", "").strip()
api_host = body.get("apiHost", "").strip()
model = body.get("model", "").strip()
# Priority: explicit non-masked apiKey from frontend > stored key by providerId
raw_key = body.get("apiKey", "").strip()
if raw_key and "••••" not in raw_key:
api_key = raw_key
elif provider_id:
api_key = ""
data = _load_providers()
for p in data.get("providers", []):
if p.get("id") == provider_id:
api_key = p.get("apiKey", "")
break
else:
api_key = raw_key
if not api_key or not api_host:
return web.json_response({"error": "apiKey and apiHost are required"}, status=400)
@@ -202,7 +237,8 @@ async def check_provider(request: web.Request) -> web.Response:
try:
import api_client
import asyncio
client = api_client.LLMClient(base_url=api_host, api_key=api_key)
skip_ssl = body.get("skipSSLVerify", False)
client = api_client.LLMClient(base_url=api_host, api_key=api_key, skip_ssl_verify=skip_ssl)
# 1. Try fetching models (doesn't consume tokens)
try:
+21 -5
View File
@@ -348,7 +348,7 @@ class ProviderManager {
);
}
async checkConnectivity(apiHost, apiKey, model) {
async checkConnectivity(draft, model) {
const btn = document.getElementById("pm-check-btn");
if (!btn) return;
@@ -360,10 +360,25 @@ class ProviderManager {
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="llm-pm-spin" style="margin-right:6px"><line x1="12" y1="2" x2="12" y2="6"></line><line x1="12" y1="18" x2="12" y2="22"></line><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line><line x1="2" y1="12" x2="6" y2="12"></line><line x1="18" y1="12" x2="22" y2="12"></line><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line></svg> ` + t("checking");
btn.disabled = true;
// For existing (saved) providers, send providerId so the backend
// looks up the real key from disk. For new providers, send the
// actual apiKey since it hasn't been persisted yet.
const isNewProvider = draft._isNew || (draft.id && draft.id.startsWith("temp-"));
const checkBody = { apiHost: draft.apiHost, model };
if (isNewProvider) {
checkBody.apiKey = draft.apiKey;
} else {
checkBody.providerId = draft.id;
// If user entered a new key (not masked), send it directly
if (draft.apiKey && !draft.apiKey.includes("••••")) {
checkBody.apiKey = draft.apiKey;
}
}
try {
const res = await api.fetchApi("/llm_toolkit/providers/check", {
method: "POST",
body: JSON.stringify({ apiHost, apiKey, model })
body: JSON.stringify(checkBody)
});
const data = await res.json();
@@ -774,10 +789,11 @@ class ProviderManager {
]);
// -- API Key
const isMaskedKey = draft.apiKey && draft.apiKey.includes("••••");
const keyInput = $el("input", {
type: "password",
value: draft.apiKey,
placeholder: "sk-...",
value: isMaskedKey ? "" : draft.apiKey,
placeholder: isMaskedKey ? draft.apiKey : "sk-...",
style: { paddingRight: "40px" }, // Make room for the absolute eye icon
oninput: (e) => draft.apiKey = e.target.value
});
@@ -815,7 +831,7 @@ class ProviderManager {
const checkBtn = $el("button", {
id: "pm-check-btn",
innerHTML: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg> Check API`,
onclick: () => this.checkConnectivity(draft.apiHost, draft.apiKey, draft.models[0] || ""),
onclick: () => this.checkConnectivity(draft, draft.models[0] || ""),
style: {
padding: "8px 16px", fontSize: "0.88em", borderRadius: "10px", minHeight: "unset",
display: "inline-flex", alignItems: "center", cursor: "pointer",