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:
+40
-4
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user