From 5cc27b9c12bcfd047b016a4eb67b360256597b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=91=E6=9E=97=20KELIN?= <167663109+HuangYuChuh@users.noreply.github.com> Date: Mon, 13 Apr 2026 07:41:08 +0800 Subject: [PATCH] fix: mask API keys in provider endpoint responses (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- nodes/api_routes.py | 44 ++++++++++++++++++++++++++++++++++---- web/js/provider_manager.js | 26 +++++++++++++++++----- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/nodes/api_routes.py b/nodes/api_routes.py index c795922..fe9dab3 100644 --- a/nodes/api_routes.py +++ b/nodes/api_routes.py @@ -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: diff --git a/web/js/provider_manager.js b/web/js/provider_manager.js index 7e477fc..feafc60 100644 --- a/web/js/provider_manager.js +++ b/web/js/provider_manager.js @@ -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 = ` ` + 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: ` 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",