From e040c9a234d9540f05b7520240693c9613b8ef77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 15 Apr 2026 08:15:08 +0200 Subject: [PATCH] settings: mask API keys in GET /api/settings, add eye-icon reveal GET /api/settings now returns jellyfin_api_key, radarr_api_key, sonarr_api_key, mqtt_password as "***" when set (empty string when unset). Real values only reach the client via an explicit GET /api/settings/reveal?key= call, wired to an eye icon on each secret input in the Settings page. Save endpoints treat an incoming "***" as a sentinel meaning "user didn't touch this field, keep stored value", so saving without revealing preserves the existing secret. Addresses audit finding #3 (settings endpoint leaks secrets). Co-Authored-By: Claude Opus 4.6 (1M context) --- server/api/execute.ts | 3 +- server/api/settings.ts | 34 +++++++++-- src/features/settings/SettingsPage.tsx | 78 +++++++++++++++++++++++++- 3 files changed, 105 insertions(+), 10 deletions(-) diff --git a/server/api/execute.ts b/server/api/execute.ts index a622bd5..e937bee 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -41,8 +41,7 @@ async function runSequential(initial: Job[]): Promise { const seen = new Set(queue.map((j) => j.id)); while (queue.length > 0) { - // biome-ignore lint/style/noNonNullAssertion: length checked above - const job = queue.shift()!; + const job = queue.shift() as Job; // Pause outside the processing window if (!isInProcessWindow()) { diff --git a/server/api/settings.ts b/server/api/settings.ts index 5424ca4..8fca3c0 100644 --- a/server/api/settings.ts +++ b/server/api/settings.ts @@ -8,16 +8,37 @@ import { testConnection as testSonarr } from "../services/sonarr"; const app = new Hono(); +// Config keys that hold credentials. `GET /` returns these as "***" when set, +// "" when unset. Real values only reach the client via the explicit +// GET /reveal?key= endpoint (eye-icon toggle in the settings UI). +const SECRET_KEYS = new Set(["jellyfin_api_key", "radarr_api_key", "sonarr_api_key", "mqtt_password"]); + app.get("/", (c) => { const config = getAllConfig(); + for (const key of SECRET_KEYS) { + if (config[key]) config[key] = "***"; + } const envLocked = Array.from(getEnvLockedKeys()); return c.json({ config, envLocked }); }); +app.get("/reveal", (c) => { + const key = c.req.query("key") ?? ""; + if (!SECRET_KEYS.has(key)) return c.json({ error: "not a secret key" }, 400); + return c.json({ value: getConfig(key) ?? "" }); +}); + +// The UI sends "***" as a sentinel meaning "user didn't touch this field, +// keep the stored value". Save endpoints call this before writing a secret. +function resolveSecret(incoming: string | undefined, storedKey: string): string { + if (incoming === "***") return getConfig(storedKey) ?? ""; + return incoming ?? ""; +} + app.post("/jellyfin", async (c) => { const body = await c.req.json<{ url: string; api_key: string }>(); const url = body.url?.replace(/\/$/, ""); - const apiKey = body.api_key; + const apiKey = resolveSecret(body.api_key, "jellyfin_api_key"); if (!url || !apiKey) return c.json({ ok: false, error: "URL and API key are required" }, 400); @@ -54,7 +75,7 @@ app.post("/jellyfin", async (c) => { app.post("/radarr", async (c) => { const body = await c.req.json<{ url?: string; api_key?: string }>(); const url = body.url?.replace(/\/$/, ""); - const apiKey = body.api_key; + const apiKey = resolveSecret(body.api_key, "radarr_api_key"); if (!url || !apiKey) { setConfig("radarr_enabled", "0"); @@ -72,7 +93,7 @@ app.post("/radarr", async (c) => { app.post("/sonarr", async (c) => { const body = await c.req.json<{ url?: string; api_key?: string }>(); const url = body.url?.replace(/\/$/, ""); - const apiKey = body.api_key; + const apiKey = resolveSecret(body.api_key, "sonarr_api_key"); if (!url || !apiKey) { setConfig("sonarr_enabled", "0"); @@ -127,9 +148,10 @@ app.post("/mqtt", async (c) => { setConfig("mqtt_url", url); setConfig("mqtt_topic", topic || "jellyfin/events"); setConfig("mqtt_username", username); - // Only overwrite password when a non-empty value is sent, so the UI can - // leave the field blank to indicate "keep the existing one". - if (password) setConfig("mqtt_password", password); + // Only overwrite password when a real value is sent. The UI leaves the + // field blank or sends "***" (masked placeholder) when the user didn't + // touch it — both mean "keep the existing one". + if (password && password !== "***") setConfig("mqtt_password", password); // Reconnect with the new config. Best-effort; failures surface in status. startMqttClient().catch(() => {}); diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index 76b2645..c789f2a 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -58,6 +58,79 @@ function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTML // (LockedInput) already signals when a value is env-controlled, the badge // was duplicate noise. +// ─── Secret input (password-masked with eye-icon reveal) ────────────────────── + +/** + * Input for API keys / passwords. Shows "***" masked when the server returns + * a secret value (the raw key never reaches this component by default). Eye + * icon fetches the real value via /api/settings/reveal and shows it. Users + * can also type a new value directly — any edit clears the masked state. + */ +function SecretInput({ + configKey, + locked, + value, + onChange, + placeholder, + className, +}: { + configKey: string; + locked: boolean; + value: string; + onChange: (next: string) => void; + placeholder?: string; + className?: string; +}) { + const [revealed, setRevealed] = useState(false); + const isMasked = value === "***"; + + const toggle = async () => { + if (revealed) { + setRevealed(false); + return; + } + if (isMasked) { + try { + const res = await api.get<{ value: string }>(`/api/settings/reveal?key=${encodeURIComponent(configKey)}`); + onChange(res.value); + } catch { + /* ignore — keep masked */ + } + } + setRevealed(true); + }; + + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + className={`pr-16 ${className ?? ""}`} + /> + + {locked && ( + + 🔒 + + )} +
+ ); +} + // ─── Section card ────────────────────────────────────────────────────────────── function SectionCard({ @@ -232,10 +305,11 @@ function ConnSection({