mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: settings dialog and settings API correctness bugs
Seven correctness fixes in the admin settings dialog and settings API: AdminSecuritySettings save echoing read-only/redacted keys; the server persisting the ******** mask over real OIDC/SIEM secrets on a settings round trip; the Tools panel missing its settings:write gate; three swallowed errors (ToolsSection save, ApiKeys generate and delete); and generatePassword omitting a special char under passwordRequireSpecial. Adds an integration regression test for the secret-mask no-op.
This commit is contained in:
@@ -104,6 +104,15 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A redacted secret comes back from GET as the literal mask, so a client that
|
||||||
|
// reads settings, edits one field, and saves the whole object echoes the mask
|
||||||
|
// back. Treat the mask as "leave this secret unchanged" instead of encrypting
|
||||||
|
// and persisting "********", which would destroy the real secret (e.g. the OIDC
|
||||||
|
// client secret or SIEM webhook auth, neither of which is read-only).
|
||||||
|
if (REDACTED_KEYS.has(key) && strValue === "********") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (READONLY_KEYS.has(key)) {
|
if (READONLY_KEYS.has(key)) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: `Setting "${key}" cannot be modified via the API`,
|
error: `Setting "${key}" cannot be modified via the API`,
|
||||||
|
|||||||
@@ -122,7 +122,12 @@ function useNavItems() {
|
|||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
requiredPermission: "settings:write",
|
requiredPermission: "settings:write",
|
||||||
},
|
},
|
||||||
{ id: "tools", label: t.settings.nav.tools, icon: Wrench },
|
{
|
||||||
|
id: "tools",
|
||||||
|
label: t.settings.nav.tools,
|
||||||
|
icon: Wrench,
|
||||||
|
requiredPermission: "settings:write",
|
||||||
|
},
|
||||||
{ id: "about", label: t.settings.nav.about, icon: Info },
|
{ id: "about", label: t.settings.nav.about, icon: Info },
|
||||||
],
|
],
|
||||||
[t],
|
[t],
|
||||||
@@ -521,6 +526,19 @@ function GeneralSection() {
|
|||||||
|
|
||||||
/* ────────────────────── System ────────────────────── */
|
/* ────────────────────── System ────────────────────── */
|
||||||
|
|
||||||
|
// PUT /v1/settings rejects server-managed read-only keys (instance_id, cookie_secret)
|
||||||
|
// with 400 READONLY_SETTING, and GET returns redacted secrets as the literal "********"
|
||||||
|
// (cookie_secret, oidc_client_secret, siem_webhook_auth). Echoing either back breaks the
|
||||||
|
// save or overwrites a real secret with the mask, so strip both before any bulk save.
|
||||||
|
const READONLY_SETTING_KEYS = new Set(["instance_id", "cookie_secret"]);
|
||||||
|
function writableSettings(settings: Record<string, string>): Record<string, string> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(settings).filter(
|
||||||
|
([key, value]) => !READONLY_SETTING_KEYS.has(key) && value !== "********",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SystemSection() {
|
function SystemSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settings, setSettings] = useState<Record<string, string>>({});
|
const [settings, setSettings] = useState<Record<string, string>>({});
|
||||||
@@ -553,17 +571,7 @@ function SystemSection() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveMsg(null);
|
setSaveMsg(null);
|
||||||
try {
|
try {
|
||||||
// GET returns read-only keys (instance_id, cookie_secret) and shows redacted
|
await apiPut("/v1/settings", writableSettings(settings));
|
||||||
// secrets as the literal "********". Echoing those back fails with 400
|
|
||||||
// READONLY_SETTING or would overwrite a real secret with the mask, so send
|
|
||||||
// only the keys this section can actually change.
|
|
||||||
const READONLY_KEYS = new Set(["instance_id", "cookie_secret"]);
|
|
||||||
const writable = Object.fromEntries(
|
|
||||||
Object.entries(settings).filter(
|
|
||||||
([key, value]) => !READONLY_KEYS.has(key) && value !== "********",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await apiPut("/v1/settings", writable);
|
|
||||||
if (settings.analyticsEnabled === "false") {
|
if (settings.analyticsEnabled === "false") {
|
||||||
const { optOut } = await import("@/lib/analytics");
|
const { optOut } = await import("@/lib/analytics");
|
||||||
optOut();
|
optOut();
|
||||||
@@ -1032,7 +1040,7 @@ function AdminSecuritySettings() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveMsg(null);
|
setSaveMsg(null);
|
||||||
try {
|
try {
|
||||||
await apiPut("/v1/settings", settings);
|
await apiPut("/v1/settings", writableSettings(settings));
|
||||||
setSaveMsg(t.settings.security.securitySettingsSaved);
|
setSaveMsg(t.settings.security.securitySettingsSaved);
|
||||||
} catch {
|
} catch {
|
||||||
setSaveMsg(t.settings.security.securitySettingsFailed);
|
setSaveMsg(t.settings.security.securitySettingsFailed);
|
||||||
@@ -1292,13 +1300,18 @@ function generatePassword(): string {
|
|||||||
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
const lower = "abcdefghijklmnopqrstuvwxyz";
|
const lower = "abcdefghijklmnopqrstuvwxyz";
|
||||||
const digits = "0123456789";
|
const digits = "0123456789";
|
||||||
const all = upper + lower + digits;
|
// The server policy can require a special character (passwordRequireSpecial), so
|
||||||
|
// always include one alongside an upper, lower, and digit; otherwise Generate can
|
||||||
|
// produce a password the server rejects. These specials all satisfy its check.
|
||||||
|
const special = "!@#$%^&*()-_=+";
|
||||||
|
const all = upper + lower + digits + special;
|
||||||
const required = [
|
const required = [
|
||||||
upper[secureRandom(upper.length)],
|
upper[secureRandom(upper.length)],
|
||||||
lower[secureRandom(lower.length)],
|
lower[secureRandom(lower.length)],
|
||||||
digits[secureRandom(digits.length)],
|
digits[secureRandom(digits.length)],
|
||||||
|
special[secureRandom(special.length)],
|
||||||
];
|
];
|
||||||
const rest = Array.from({ length: 13 }, () => all[secureRandom(all.length)]);
|
const rest = Array.from({ length: 12 }, () => all[secureRandom(all.length)]);
|
||||||
const chars = [...required, ...rest];
|
const chars = [...required, ...rest];
|
||||||
for (let i = chars.length - 1; i > 0; i--) {
|
for (let i = chars.length - 1; i > 0; i--) {
|
||||||
const j = secureRandom(i + 1);
|
const j = secureRandom(i + 1);
|
||||||
@@ -1993,6 +2006,7 @@ function ApiKeysSection() {
|
|||||||
const [showScoping, setShowScoping] = useState(false);
|
const [showScoping, setShowScoping] = useState(false);
|
||||||
const [scopedPerms, setScopedPerms] = useState<string[]>([]);
|
const [scopedPerms, setScopedPerms] = useState<string[]>([]);
|
||||||
const [expiresAt, setExpiresAt] = useState("");
|
const [expiresAt, setExpiresAt] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
|
|
||||||
const loadKeys = useCallback(async () => {
|
const loadKeys = useCallback(async () => {
|
||||||
@@ -2013,6 +2027,7 @@ function ApiKeysSection() {
|
|||||||
const generateKey = useCallback(async () => {
|
const generateKey = useCallback(async () => {
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
setNewKey(null);
|
setNewKey(null);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = { name: keyName || "default" };
|
const payload: Record<string, unknown> = { name: keyName || "default" };
|
||||||
if (showScoping && scopedPerms.length > 0) {
|
if (showScoping && scopedPerms.length > 0) {
|
||||||
@@ -2029,11 +2044,11 @@ function ApiKeysSection() {
|
|||||||
setExpiresAt("");
|
setExpiresAt("");
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail
|
setError(t.common.somethingWentWrong);
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
}, [keyName, showScoping, scopedPerms, expiresAt, loadKeys]);
|
}, [keyName, showScoping, scopedPerms, expiresAt, loadKeys, t]);
|
||||||
|
|
||||||
const copyKey = useCallback(async (key: string) => {
|
const copyKey = useCallback(async (key: string) => {
|
||||||
const ok = await copyToClipboard(key);
|
const ok = await copyToClipboard(key);
|
||||||
@@ -2046,14 +2061,15 @@ function ApiKeysSection() {
|
|||||||
const deleteKey = useCallback(
|
const deleteKey = useCallback(
|
||||||
async (id: number) => {
|
async (id: number) => {
|
||||||
if (!confirm(t.settings.apiKeys.deleteConfirm)) return;
|
if (!confirm(t.settings.apiKeys.deleteConfirm)) return;
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await apiDelete(`/v1/api-keys/${id}`);
|
await apiDelete(`/v1/api-keys/${id}`);
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail
|
setError(t.common.somethingWentWrong);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadKeys],
|
[loadKeys, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -2096,6 +2112,12 @@ function ApiKeysSection() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-4 py-3 rounded-lg border border-red-500/30 bg-red-500/10 text-sm text-red-700 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Permission scoping */}
|
{/* Permission scoping */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
@@ -3283,6 +3305,7 @@ function ToolsSection() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [showRestartBanner, setShowRestartBanner] = useState(false);
|
const [showRestartBanner, setShowRestartBanner] = useState(false);
|
||||||
|
const [saveFailed, setSaveFailed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||||
@@ -3322,11 +3345,12 @@ function ToolsSection() {
|
|||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setSaveFailed(false);
|
||||||
try {
|
try {
|
||||||
await apiPut("/v1/settings", { disabledTools: JSON.stringify(disabledTools) });
|
await apiPut("/v1/settings", { disabledTools: JSON.stringify(disabledTools) });
|
||||||
setShowRestartBanner(true);
|
setShowRestartBanner(true);
|
||||||
} catch {
|
} catch {
|
||||||
/* handle error */
|
setSaveFailed(true);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -3424,6 +3448,12 @@ function ToolsSection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{saveFailed && (
|
||||||
|
<div className="px-4 py-3 rounded-lg border border-red-500/30 bg-red-500/10 text-sm text-red-700 dark:text-red-400">
|
||||||
|
{t.common.somethingWentWrong}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1394,6 +1394,33 @@ describe("Settings", () => {
|
|||||||
expect(body.value).toBe("testValue");
|
expect(body.value).toBe("testValue");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats a redacted secret mask as a no-op instead of overwriting the secret", async () => {
|
||||||
|
// Store a real secret; GET then redacts it to the literal mask.
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
|
payload: { oidc_client_secret: "real-oidc-secret-xyz" },
|
||||||
|
});
|
||||||
|
const getRes = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/settings/oidc_client_secret",
|
||||||
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(JSON.parse(getRes.body).value).toBe("********");
|
||||||
|
|
||||||
|
// A client echoing the mask back must not overwrite the stored secret, so only
|
||||||
|
// the other key is written (updatedCount counts actual writes).
|
||||||
|
const putRes = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
|
payload: { oidc_client_secret: "********", theme: "dark" },
|
||||||
|
});
|
||||||
|
expect(putRes.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(putRes.body).updatedCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("non-admin cannot save settings", async () => {
|
it("non-admin cannot save settings", async () => {
|
||||||
// Create a regular user
|
// Create a regular user
|
||||||
await app.inject({
|
await app.inject({
|
||||||
|
|||||||
Reference in New Issue
Block a user