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=<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) <noreply@anthropic.com>
This commit is contained in:
@@ -41,8 +41,7 @@ async function runSequential(initial: Job[]): Promise<void> {
|
|||||||
const seen = new Set<number>(queue.map((j) => j.id));
|
const seen = new Set<number>(queue.map((j) => j.id));
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
// biome-ignore lint/style/noNonNullAssertion: length checked above
|
const job = queue.shift() as Job;
|
||||||
const job = queue.shift()!;
|
|
||||||
|
|
||||||
// Pause outside the processing window
|
// Pause outside the processing window
|
||||||
if (!isInProcessWindow()) {
|
if (!isInProcessWindow()) {
|
||||||
|
|||||||
@@ -8,16 +8,37 @@ import { testConnection as testSonarr } from "../services/sonarr";
|
|||||||
|
|
||||||
const app = new Hono();
|
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=<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) => {
|
app.get("/", (c) => {
|
||||||
const config = getAllConfig();
|
const config = getAllConfig();
|
||||||
|
for (const key of SECRET_KEYS) {
|
||||||
|
if (config[key]) config[key] = "***";
|
||||||
|
}
|
||||||
const envLocked = Array.from(getEnvLockedKeys());
|
const envLocked = Array.from(getEnvLockedKeys());
|
||||||
return c.json({ config, envLocked });
|
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) => {
|
app.post("/jellyfin", async (c) => {
|
||||||
const body = await c.req.json<{ url: string; api_key: string }>();
|
const body = await c.req.json<{ url: string; api_key: string }>();
|
||||||
const url = body.url?.replace(/\/$/, "");
|
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);
|
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) => {
|
app.post("/radarr", async (c) => {
|
||||||
const body = await c.req.json<{ url?: string; api_key?: string }>();
|
const body = await c.req.json<{ url?: string; api_key?: string }>();
|
||||||
const url = body.url?.replace(/\/$/, "");
|
const url = body.url?.replace(/\/$/, "");
|
||||||
const apiKey = body.api_key;
|
const apiKey = resolveSecret(body.api_key, "radarr_api_key");
|
||||||
|
|
||||||
if (!url || !apiKey) {
|
if (!url || !apiKey) {
|
||||||
setConfig("radarr_enabled", "0");
|
setConfig("radarr_enabled", "0");
|
||||||
@@ -72,7 +93,7 @@ app.post("/radarr", async (c) => {
|
|||||||
app.post("/sonarr", async (c) => {
|
app.post("/sonarr", async (c) => {
|
||||||
const body = await c.req.json<{ url?: string; api_key?: string }>();
|
const body = await c.req.json<{ url?: string; api_key?: string }>();
|
||||||
const url = body.url?.replace(/\/$/, "");
|
const url = body.url?.replace(/\/$/, "");
|
||||||
const apiKey = body.api_key;
|
const apiKey = resolveSecret(body.api_key, "sonarr_api_key");
|
||||||
|
|
||||||
if (!url || !apiKey) {
|
if (!url || !apiKey) {
|
||||||
setConfig("sonarr_enabled", "0");
|
setConfig("sonarr_enabled", "0");
|
||||||
@@ -127,9 +148,10 @@ app.post("/mqtt", async (c) => {
|
|||||||
setConfig("mqtt_url", url);
|
setConfig("mqtt_url", url);
|
||||||
setConfig("mqtt_topic", topic || "jellyfin/events");
|
setConfig("mqtt_topic", topic || "jellyfin/events");
|
||||||
setConfig("mqtt_username", username);
|
setConfig("mqtt_username", username);
|
||||||
// Only overwrite password when a non-empty value is sent, so the UI can
|
// Only overwrite password when a real value is sent. The UI leaves the
|
||||||
// leave the field blank to indicate "keep the existing one".
|
// field blank or sends "***" (masked placeholder) when the user didn't
|
||||||
if (password) setConfig("mqtt_password", password);
|
// 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.
|
// Reconnect with the new config. Best-effort; failures surface in status.
|
||||||
startMqttClient().catch(() => {});
|
startMqttClient().catch(() => {});
|
||||||
|
|||||||
@@ -58,6 +58,79 @@ function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTML
|
|||||||
// (LockedInput) already signals when a value is env-controlled, the badge
|
// (LockedInput) already signals when a value is env-controlled, the badge
|
||||||
// was duplicate noise.
|
// 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 (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={revealed || !isMasked ? "text" : "password"}
|
||||||
|
value={value}
|
||||||
|
disabled={locked}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`pr-16 ${className ?? ""}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={locked}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-60 hover:opacity-100 disabled:opacity-30"
|
||||||
|
title={revealed ? "Hide" : "Reveal"}
|
||||||
|
>
|
||||||
|
{revealed ? "🙈" : "👁"}
|
||||||
|
</button>
|
||||||
|
{locked && (
|
||||||
|
<span
|
||||||
|
className="absolute right-9 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-40 pointer-events-none select-none"
|
||||||
|
title="Set via environment variable — edit your .env file to change this value"
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Section card ──────────────────────────────────────────────────────────────
|
// ─── Section card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SectionCard({
|
function SectionCard({
|
||||||
@@ -232,10 +305,11 @@ function ConnSection({
|
|||||||
</label>
|
</label>
|
||||||
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
||||||
API Key
|
API Key
|
||||||
<LockedInput
|
<SecretInput
|
||||||
|
configKey={apiKeyProp}
|
||||||
locked={locked.has(apiKeyProp)}
|
locked={locked.has(apiKeyProp)}
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => setKey(e.target.value)}
|
onChange={setKey}
|
||||||
placeholder="your-api-key"
|
placeholder="your-api-key"
|
||||||
className="mt-0.5 max-w-xs"
|
className="mt-0.5 max-w-xs"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user