security: keep provider credentials server-side

This commit is contained in:
mARTin-B78 2026-09-04 07:50:30 +02:00
parent d19e52fe28
commit c6ce3e4803
9 changed files with 118 additions and 46 deletions

View File

@ -20,6 +20,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
### Changed ### Changed
- **API Boundary Hardening.** External API authentication is enabled by default, request and mutation limits are - **API Boundary Hardening.** External API authentication is enabled by default, request and mutation limits are
enforced, and settings responses redact provider credentials for non-browser callers. enforced, and settings responses redact provider credentials for non-browser callers.
- **Server-side Provider Credentials.** Provider keys are now masked even for same-origin Settings responses, are
preserved when masked/empty fields are saved, and model/health probes resolve configured keys inside the server.
Browser localStorage is no longer used as a credential store.
- **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL
rejection and oversized JSON request rejection. rejection and oversized JSON request rejection.
- **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary.

View File

@ -17,8 +17,9 @@ the first block before investing in architecture or polish.
### P0 — do first ### P0 — do first
- [ ] **S — Stop API keys from reaching frontend JavaScript or logs.** Audit responses, - [ ] **S — Stop API keys from reaching frontend JavaScript or logs.** Provider keys are now masked in settings
browser storage, error paths and debug output. responses and probes use server-side credentials; finish the external-client key handoff and audit remaining
transient input/error paths.
- [ ] **S — Validate uploaded filenames and paths.** Reject traversal, unsafe names and - [ ] **S — Validate uploaded filenames and paths.** Reject traversal, unsafe names and
writes outside the intended data directories. writes outside the intended data directories.
- [ ] **M — Add upload, request-size, temporary-file and concurrent-job limits.** Apply - [ ] **M — Add upload, request-size, temporary-file and concurrent-job limits.** Apply
@ -124,7 +125,8 @@ the first block before investing in architecture or polish.
## P0 — Security ## P0 — Security
- [ ] Never expose API keys to frontend JavaScript or logs. - [ ] Never expose API keys to frontend JavaScript or logs. Provider keys are masked on read and absent from browser
storage; the deliberate external-client key display and transient entry path still need a final design review.
- [ ] Validate uploaded filenames and paths. - [ ] Validate uploaded filenames and paths.
- [ ] Strictly validate custom URLs to prevent SSRF. - [ ] Strictly validate custom URLs to prevent SSRF.
- [ ] Add request-size limits, rate limits, and authentication for destructive/API endpoints. - [ ] Add request-size limits, rate limits, and authentication for destructive/API endpoints.

View File

@ -10,6 +10,7 @@ import requests
from fastapi import APIRouter, Header, HTTPException from fastapi import APIRouter, Header, HTTPException
from core.docker_client import _docker_get_json, _docker_post from core.docker_client import _docker_get_json, _docker_post
from core.config import _load_settings
from core.validation import _normalize_service_url from core.validation import _normalize_service_url
router = APIRouter() router = APIRouter()
@ -127,7 +128,14 @@ async def probe_url(url: str, type: str = "", provider_api_key: str = Header("",
if base.endswith(_suffix): if base.endswith(_suffix):
base = base[: -len(_suffix)] base = base[: -len(_suffix)]
break break
key_to_use = provider_api_key if provider_api_key else "sk-dummy-key" settings = _load_settings()
key_to_use = {
"anthropic": settings.get("llm_api_key"),
"llm": settings.get("llm_api_key"),
"openai": settings.get("llm_api_key"),
"stt": settings.get("groq_api_key") or settings.get("whisper_api_key"),
"tts": settings.get("tts_api_key"),
}.get(type) or provider_api_key or "sk-dummy-key"
# Anthropic doesn't speak the OpenAI-style Bearer auth every other card # Anthropic doesn't speak the OpenAI-style Bearer auth every other card
# here uses — it needs x-api-key + an anthropic-version header, so the # here uses — it needs x-api-key + an anthropic-version header, so the
# generic Bearer probe below would 401 even with a perfectly valid key. # generic Bearer probe below would 401 even with a perfectly valid key.
@ -192,7 +200,8 @@ async def engine_models(url: str, type: str = "", provider_api_key: str = Header
last_err = "No response" last_err = "No response"
seen: set[str] = set() seen: set[str] = set()
models: list[str] = [] models: list[str] = []
key_to_use = provider_api_key if provider_api_key else "sk-dummy-key" settings = _load_settings()
key_to_use = settings.get("llm_api_key") or provider_api_key or "sk-dummy-key"
for endpoint in candidates: for endpoint in candidates:
try: try:
r = requests.get(endpoint, timeout=6, headers={"Authorization": f"Bearer {key_to_use}", "User-Agent": "TTS-Voice-Creator/models"}) r = requests.get(endpoint, timeout=6, headers={"Authorization": f"Bearer {key_to_use}", "User-Agent": "TTS-Voice-Creator/models"})

View File

@ -15,6 +15,7 @@ _SECRET_SETTING_KEYS = {
"groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key", "groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key",
"elevenlabs_api_key", "llm_api_key", "engine_api_keys", "external_api_key", "elevenlabs_api_key", "llm_api_key", "engine_api_keys", "external_api_key",
} }
_SECRET_MASK = "__configured__"
@router.get("/api/settings") @router.get("/api/settings")
@ -34,8 +35,17 @@ async def get_settings(request: Request):
) )
except Exception: except Exception:
same_origin = False same_origin = False
if not same_origin: # Never return provider credentials to browser JavaScript. The mask lets
s = {key: value for key, value in s.items() if key not in _SECRET_SETTING_KEYS} # the Settings UI indicate that a value exists without making it usable in
# client-side requests or browser storage.
for key in _SECRET_SETTING_KEYS:
value = s.get(key)
if key == "engine_api_keys" and isinstance(value, dict):
s[key] = {name: _SECRET_MASK for name, secret in value.items() if secret}
elif value:
s[key] = _SECRET_MASK
else:
s.pop(key, None)
return s return s
@ -52,7 +62,26 @@ async def regenerate_api_key():
async def post_settings(request: Request): async def post_settings(request: Request):
data = await request.json() data = await request.json()
s = _load_settings() s = _load_settings()
s.update({k: v for k, v in data.items() if k in _SETTINGS_KEYS}) clear = {str(k) for k in (data.get("clear_secrets") or [])} if isinstance(data.get("clear_secrets"), list) else set()
for key, value in data.items():
if key not in _SETTINGS_KEYS:
continue
if key in _SECRET_SETTING_KEYS:
if value == _SECRET_MASK:
continue
if key == "engine_api_keys" and isinstance(value, dict):
old = s.get(key) if isinstance(s.get(key), dict) else {}
merged = dict(old)
for name, secret in value.items():
if secret and secret != _SECRET_MASK:
merged[name] = secret
for name in clear:
merged.pop(name, None)
s[key] = merged
continue
if value in (None, "") and key not in clear:
continue
s[key] = value
s = _normalize_settings(s) s = _normalize_settings(s)
_save_settings(s) _save_settings(s)
return {"ok": True} return {"ok": True}

View File

@ -19,13 +19,11 @@
}); });
}); });
// API key inputs — persist to localStorage, eye toggle, saved badge // API key inputs — save only to the server, eye toggle, saved badge
document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => { document.querySelectorAll('.llm-input[data-llm-key]').forEach(inp => {
const key = inp.dataset.llmKey; const key = inp.dataset.llmKey;
const storageKey = 'llm-key-' + key; const storageKey = 'llm-key-' + key;
const saved = typeof window.engineApiKeyValue === 'function' const saved = typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(key) : '';
? window.engineApiKeyValue(key, storageKey)
: localStorage.getItem(storageKey);
if (saved) inp.value = saved; if (saved) inp.value = saved;
const eye = document.createElement('button'); const eye = document.createElement('button');
@ -47,8 +45,8 @@
clearTimeout(t); clearTimeout(t);
t = setTimeout(() => { t = setTimeout(() => {
if (typeof window.saveEngineApiKey === 'function') window.saveEngineApiKey(key, inp.value, storageKey); if (typeof window.saveEngineApiKey === 'function') window.saveEngineApiKey(key, inp.value, storageKey);
else if (inp.value) localStorage.setItem(storageKey, inp.value); // No browser-storage fallback: provider credentials belong on the
else localStorage.removeItem(storageKey); // server and must not survive in localStorage.
badge.hidden = false; badge.hidden = false;
setTimeout(() => { badge.hidden = true; }, 1800); setTimeout(() => { badge.hidden = true; }, 1800);
}, 600); }, 600);
@ -142,8 +140,7 @@
const url = normalizeProbeUrl(rawUrl); const url = normalizeProbeUrl(rawUrl);
const params = { url }; const params = { url };
if (type) params.type = type; if (type) params.type = type;
const headers = apiKey ? { 'X-Provider-API-Key': apiKey } : {}; const r = await fetch('/api/probe-url?' + new URLSearchParams(params));
const r = await fetch('/api/probe-url?' + new URLSearchParams(params), { headers });
return r.json(); return r.json();
} }
@ -207,7 +204,11 @@
const resp = await fetch('/api/settings').then(r => r.json()); const resp = await fetch('/api/settings').then(r => r.json());
const updated = { ...resp, ...patch }; const updated = { ...resp, ...patch };
await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }); await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
Object.assign(_appSettings || {}, patch); if (_appSettings) {
const visiblePatch = { ...patch };
['groq_api_key','whisper_api_key','tts_api_key','voice_design_api_key','elevenlabs_api_key','llm_api_key','engine_api_keys','external_api_key'].forEach(k => delete visiblePatch[k]);
Object.assign(_appSettings, visiblePatch);
}
// refresh visible inputs in Settings if open // refresh visible inputs in Settings if open
for (const [id, val] of Object.entries(patch)) { for (const [id, val] of Object.entries(patch)) {
const inp = $('s-' + id.replace(/_/g, '-')); const inp = $('s-' + id.replace(/_/g, '-'));
@ -310,8 +311,7 @@
if (refreshBtn) refreshBtn.disabled = true; if (refreshBtn) refreshBtn.disabled = true;
try { try {
const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url); const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
const headers = apiKey ? { 'X-Provider-API-Key': apiKey } : {}; const d = await fetch(fetchUrl).then(r => r.json());
const d = await fetch(fetchUrl, { headers }).then(r => r.json());
if (d.error) { if (d.error) {
let msg = 'Failed'; let msg = 'Failed';
try { try {

View File

@ -522,7 +522,7 @@ function renderLocalContainers(containers) {
// API key row — only for LLMs // API key row — only for LLMs
const savedApiKey = isCustom const savedApiKey = isCustom
? ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name, 'dc-apikey-' + c.name) : '') || c.apiKey || '') ? ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name, 'dc-apikey-' + c.name) : '') || c.apiKey || '')
: ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name, 'dc-apikey-' + c.name) : '') || (_appSettings?.engine_api_keys?.[c.name]) || localStorage.getItem('dc-apikey-' + n) || ''); : ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name) : '') || '');
const apiKeyRowHtml = (c.role === 'llm' || c.name === 'litellm') ? ` const apiKeyRowHtml = (c.role === 'llm' || c.name === 'litellm') ? `
<div class="llm-local-url"> <div class="llm-local-url">
<span class="llm-local-url-label" title="API Key"><span class="mdi mdi-key-outline"></span></span> <span class="llm-local-url-label" title="API Key"><span class="mdi mdi-key-outline"></span></span>
@ -673,21 +673,18 @@ function renderLocalContainers(containers) {
} }
}); });
// API key input — save to server-backed engine_api_keys and localStorage fallback // API key input — save only to server-backed engine_api_keys
grid.querySelectorAll('.dc-apikey-inp[data-dc-apikey-key]').forEach(inp => { grid.querySelectorAll('.dc-apikey-inp[data-dc-apikey-key]').forEach(inp => {
const key = inp.dataset.dcApikeyKey; const key = inp.dataset.dcApikeyKey;
const storageKey = 'dc-apikey-' + key; const storageKey = 'dc-apikey-' + key;
const saved = typeof window.engineApiKeyValue === 'function' const saved = typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(key) : '';
? window.engineApiKeyValue(key, storageKey)
: localStorage.getItem(storageKey);
if (saved && inp.value !== saved) inp.value = saved; if (saved && inp.value !== saved) inp.value = saved;
let keyTimer; let keyTimer;
inp.addEventListener('input', () => { inp.addEventListener('input', () => {
clearTimeout(keyTimer); clearTimeout(keyTimer);
keyTimer = setTimeout(() => { keyTimer = setTimeout(() => {
if (typeof window.saveEngineApiKey === 'function') window.saveEngineApiKey(key, inp.value, storageKey); if (typeof window.saveEngineApiKey === 'function') window.saveEngineApiKey(key, inp.value, storageKey);
else if (inp.value) localStorage.setItem(storageKey, inp.value); // No browser-storage fallback for provider credentials.
else localStorage.removeItem(storageKey);
}, 300); }, 300);
}); });
}); });
@ -727,7 +724,6 @@ function renderLocalContainers(containers) {
const url = inp?.value.trim() || inp?.placeholder || ''; const url = inp?.value.trim() || inp?.placeholder || '';
if (settingKey && url) { if (settingKey && url) {
const patch = { [settingKey]: url }; const patch = { [settingKey]: url };
if (apiKey && settingKey === 'llm_url') patch.llm_api_key = apiKey;
await window.applyAndSaveSettings(patch); await window.applyAndSaveSettings(patch);
toast(`✓ Connected and set as active ${useBtn.textContent.replace('Use as ', '').trim()}`, 'success'); toast(`✓ Connected and set as active ${useBtn.textContent.replace('Use as ', '').trim()}`, 'success');
} else { } else {

View File

@ -239,7 +239,11 @@ async function refreshTtsBackendAvailability(selected = '') {
async function _patchSettings(patch) { async function _patchSettings(patch) {
try { try {
await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }); await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
if (_appSettings) Object.assign(_appSettings, patch || {}); if (_appSettings) {
const visiblePatch = { ...(patch || {}) };
['groq_api_key','whisper_api_key','tts_api_key','voice_design_api_key','elevenlabs_api_key','llm_api_key','engine_api_keys','external_api_key'].forEach(k => delete visiblePatch[k]);
Object.assign(_appSettings, visiblePatch);
}
if (typeof window.refreshStatusBarEngines === 'function') window.refreshStatusBarEngines({ force: true }); if (typeof window.refreshStatusBarEngines === 'function') window.refreshStatusBarEngines({ force: true });
} catch (e) { console.warn('[settings] patch failed', e); } } catch (e) { console.warn('[settings] patch failed', e); }
} }
@ -282,24 +286,24 @@ const ENGINE_API_KEY_SETTING_MAP = {
const _engineApiKeySaveTimers = {}; const _engineApiKeySaveTimers = {};
function engineApiKeyValue(key, localStorageKey = '') { function engineApiKeyValue(key, localStorageKey = '') {
const settingKey = ENGINE_API_KEY_SETTING_MAP[key]; const settingKey = ENGINE_API_KEY_SETTING_MAP[key];
const fromSetting = settingKey && _appSettings ? (_appSettings[settingKey] || '') : ''; const fromSetting = settingKey && _appSettings && _appSettings[settingKey] !== '__configured__' ? (_appSettings[settingKey] || '') : '';
const fromMap = _appSettings?.engine_api_keys?.[key] || ''; const fromMap = _appSettings?.engine_api_keys?.[key] && _appSettings.engine_api_keys[key] !== '__configured__' ? _appSettings.engine_api_keys[key] : '';
const fromLocal = localStorageKey ? localStorage.getItem(localStorageKey) : ''; return fromSetting || fromMap || '';
return fromSetting || fromMap || fromLocal || localStorage.getItem('llm-key-' + key) || localStorage.getItem('dc-apikey-' + key) || '';
} }
function saveEngineApiKey(key, value, localStorageKey = '') { function saveEngineApiKey(key, value, localStorageKey = '') {
const val = String(value || '').trim(); const val = String(value || '').trim();
if (localStorageKey) {
if (val) localStorage.setItem(localStorageKey, val);
else localStorage.removeItem(localStorageKey);
}
const apiKeys = { ...(_appSettings?.engine_api_keys || {}) }; const apiKeys = { ...(_appSettings?.engine_api_keys || {}) };
if (val) apiKeys[key] = val; if (val) apiKeys[key] = val;
else delete apiKeys[key]; else delete apiKeys[key];
const patch = { engine_api_keys: apiKeys }; const patch = { engine_api_keys: apiKeys };
const settingKey = ENGINE_API_KEY_SETTING_MAP[key]; const settingKey = ENGINE_API_KEY_SETTING_MAP[key];
if (settingKey) patch[settingKey] = val; if (settingKey) patch[settingKey] = val;
if (_appSettings) Object.assign(_appSettings, patch); if (_appSettings) {
if (settingKey) _appSettings[settingKey] = val ? '__configured__' : undefined;
const safeMap = { ...(_appSettings.engine_api_keys || {}) };
if (val) safeMap[key] = '__configured__'; else delete safeMap[key];
_appSettings.engine_api_keys = safeMap;
}
clearTimeout(_engineApiKeySaveTimers[key]); clearTimeout(_engineApiKeySaveTimers[key]);
_engineApiKeySaveTimers[key] = setTimeout(() => _patchSettings(patch), 600); _engineApiKeySaveTimers[key] = setTimeout(() => _patchSettings(patch), 600);
} }
@ -495,12 +499,13 @@ function _populateImageGenModelSelect(selectEl, provider, savedModel) {
async function loadSettings() { async function loadSettings() {
const s = await fetch('/api/settings').then(r => r.json()); const s = await fetch('/api/settings').then(r => r.json());
const sv = (id, val) => { const el = $(id); if (el) el.value = val; }; const sv = (id, val) => { const el = $(id); if (el) el.value = val; };
const svSecret = (id, val) => { const el = $(id); if (el) { el.value = ''; el.placeholder = val === '__configured__' ? 'Configured — enter a new key to replace' : ''; } };
sv('s-whisper-url', s.whisper_url || ''); sv('s-whisper-url', s.whisper_url || '');
sv('s-whisper-key', s.whisper_api_key || ''); svSecret('s-whisper-key', s.whisper_api_key);
sv('s-tts-url', s.tts_url || ''); sv('s-tts-url', s.tts_url || '');
sv('s-faster-whisper-url', s.faster_whisper_url || ''); sv('s-faster-whisper-url', s.faster_whisper_url || '');
sv('s-whisper-cpp-url', s.whisper_cpp_url || ''); sv('s-whisper-cpp-url', s.whisper_cpp_url || '');
sv('s-groq-api-key', s.groq_api_key || ''); svSecret('s-groq-api-key', s.groq_api_key);
sv('s-kokoro-url', s.kokoro_url || ''); sv('s-kokoro-url', s.kokoro_url || '');
sv('s-vibevoice-url', s.vibevoice_url || ''); sv('s-vibevoice-url', s.vibevoice_url || '');
sv('s-xtts-url', s.xtts_url || ''); sv('s-xtts-url', s.xtts_url || '');
@ -542,7 +547,7 @@ async function loadSettings() {
sv('s-nvidia-zeroshot-url', s.nvidia_zeroshot_url || s.nvidia_clone_url || 'http://host.docker.internal:8093'); sv('s-nvidia-zeroshot-url', s.nvidia_zeroshot_url || s.nvidia_clone_url || 'http://host.docker.internal:8093');
sv('s-nvidia-flow-url', s.nvidia_flow_url || 'http://host.docker.internal:8094'); sv('s-nvidia-flow-url', s.nvidia_flow_url || 'http://host.docker.internal:8094');
sv('s-tts-stream-mode', s.tts_stream_mode || 'auto'); sv('s-tts-stream-mode', s.tts_stream_mode || 'auto');
sv('s-tts-key', s.tts_api_key || ''); svSecret('s-tts-key', s.tts_api_key);
sv('s-tts-backend', s.tts_backend || 'openai'); sv('s-tts-backend', s.tts_backend || 'openai');
const defaultTtsParams = {temperature:0.1, top_p:0.8, seed:0}; const defaultTtsParams = {temperature:0.1, top_p:0.8, seed:0};
const byBackend = s.tts_extra_params_by_backend || {}; const byBackend = s.tts_extra_params_by_backend || {};
@ -557,12 +562,12 @@ async function loadSettings() {
sv('s-tts-extra-kokoro', JSON.stringify(byBackend.kokoro || {}, null, 2)); sv('s-tts-extra-kokoro', JSON.stringify(byBackend.kokoro || {}, null, 2));
sv('s-tts-extra-vibevoice', JSON.stringify(byBackend.vibevoice || {}, null, 2)); sv('s-tts-extra-vibevoice', JSON.stringify(byBackend.vibevoice || {}, null, 2));
sv('s-voice-design-url', s.voice_design_url || 'http://host.docker.internal:8021'); sv('s-voice-design-url', s.voice_design_url || 'http://host.docker.internal:8021');
sv('s-vd-key', s.voice_design_api_key || ''); svSecret('s-vd-key', s.voice_design_api_key);
sv('s-voices-scan-dir', s.voices_scan_dir || ''); sv('s-voices-scan-dir', s.voices_scan_dir || '');
sv('s-output-dir', s.output_dir || ''); sv('s-output-dir', s.output_dir || '');
restoreEngineApiKeyInputs(); restoreEngineApiKeyInputs();
const extKeyInp = $('s-external-api-key'); const extKeyInp = $('s-external-api-key');
if (extKeyInp) extKeyInp.value = s.external_api_key || ''; if (extKeyInp) { extKeyInp.value = ''; extKeyInp.placeholder = s.external_api_key === '__configured__' ? 'Configured — regenerate to replace' : ''; }
const extKeyReq = $('s-external-api-key-required'); const extKeyReq = $('s-external-api-key-required');
if (extKeyReq) extKeyReq.checked = !!s.external_api_key_required; if (extKeyReq) extKeyReq.checked = !!s.external_api_key_required;
if (extKeyReq && !extKeyReq.dataset.wired) { if (extKeyReq && !extKeyReq.dataset.wired) {

View File

@ -249,8 +249,7 @@ async function _statusOpenLlmFlyup(anchor) {
let models = []; let models = [];
try { try {
const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url); const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
const headers = apiKey ? { 'X-Provider-API-Key': apiKey } : {}; const data = await fetch(fetchUrl).then(r => r.json());
const data = await fetch(fetchUrl, { headers }).then(r => r.json());
models = Array.isArray(data.models) ? data.models : []; models = Array.isArray(data.models) ? data.models : [];
} catch (_) {} } catch (_) {}
if (!_statusFlyup || _statusFlyup.dataset.forKind !== 'llm') return; // closed/switched while awaiting if (!_statusFlyup || _statusFlyup.dataset.forKind !== 'llm') return; // closed/switched while awaiting
@ -319,8 +318,7 @@ async function refreshStatusBarEngines({ force = false } = {}) {
STATUS_LLM_CACHE.pending = true; STATUS_LLM_CACHE.pending = true;
try { try {
const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url); const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
const headers = apiKey ? { 'X-Provider-API-Key': apiKey } : {}; const data = await fetch(fetchUrl).then(r => r.json());
const data = await fetch(fetchUrl, { headers }).then(r => r.json());
const models = Array.isArray(data.models) ? data.models : []; const models = Array.isArray(data.models) ? data.models : [];
STATUS_LLM_CACHE.url = url; STATUS_LLM_CACHE.url = url;
STATUS_LLM_CACHE.model = model; STATUS_LLM_CACHE.model = model;

View File

@ -11,6 +11,7 @@ os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-test-config-")
from fastapi import HTTPException from fastapi import HTTPException
from core.validation import _validate_character_record, _validate_http_url from core.validation import _validate_character_record, _validate_http_url
from core.config import _load_settings, _save_settings
from server import RequestSizeLimitMiddleware from server import RequestSizeLimitMiddleware
@ -48,6 +49,35 @@ class SecurityValidationTests(unittest.TestCase):
self.assertEqual(sent[0]["status"], 413) self.assertEqual(sent[0]["status"], 413)
self.assertNotIn({"type": "downstream"}, sent) self.assertNotIn({"type": "downstream"}, sent)
def test_settings_never_returns_provider_credentials(self):
from fastapi.testclient import TestClient
from server import app
settings = _load_settings()
settings.update({
"llm_api_key": "provider-secret",
"engine_api_keys": {"local": "engine-secret"},
})
_save_settings(settings)
with TestClient(app) as client:
response = client.get("/api/settings", headers={"origin": "http://testserver"})
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload.get("llm_api_key"), "__configured__")
self.assertEqual(payload.get("engine_api_keys", {}).get("local"), "__configured__")
self.assertNotIn("provider-secret", response.text)
self.assertNotIn("engine-secret", response.text)
def test_empty_masked_secret_does_not_overwrite_saved_value(self):
from fastapi.testclient import TestClient
from server import app
settings = _load_settings()
settings["llm_api_key"] = "keep-this-secret"
_save_settings(settings)
with TestClient(app) as client:
response = client.post("/api/settings", json={"llm_api_key": ""}, headers={"origin": "http://testserver"})
self.assertEqual(response.status_code, 200)
self.assertEqual(_load_settings().get("llm_api_key"), "keep-this-secret")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()