mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: enforce settings authority boundaries (#618)
Close generic settings authorization bypasses and enforce per-setting authority, validation, redaction, transactional config import, and route-local write rate limiting.
This commit is contained in:
@@ -531,7 +531,7 @@ function GeneralSection() {
|
||||
|
||||
function SystemSection() {
|
||||
const { t } = useTranslation();
|
||||
const { role } = useAuth();
|
||||
const { role, hasPermission } = useAuth();
|
||||
const analyticsConfig = useAnalyticsStore((s) => s.config);
|
||||
const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded);
|
||||
const [settings, setSettings] = useState<Record<string, string>>({});
|
||||
@@ -686,20 +686,22 @@ function SystemSection() {
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t.settings.system.loginAttemptLimitLabel}
|
||||
description={t.settings.system.loginAttemptLimitDescription}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.loginAttemptLimit || "5"}
|
||||
onChange={(e) => updateSetting("loginAttemptLimit", e.target.value)}
|
||||
aria-label={t.settings.system.loginAttemptLimitLabel}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
</SettingRow>
|
||||
{hasPermission("security:manage") && (
|
||||
<SettingRow
|
||||
label={t.settings.system.loginAttemptLimitLabel}
|
||||
description={t.settings.system.loginAttemptLimitDescription}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.loginAttemptLimit || "5"}
|
||||
onChange={(e) => updateSetting("loginAttemptLimit", e.target.value)}
|
||||
aria-label={t.settings.system.loginAttemptLimitLabel}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
@@ -807,19 +809,21 @@ function SystemSection() {
|
||||
min={0}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t.settings.dataRetention.auditRetentionDays}
|
||||
description={t.settings.dataRetention.auditRetentionDaysDesc}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.auditRetentionDays || "0"}
|
||||
onChange={(e) => updateSetting("auditRetentionDays", e.target.value)}
|
||||
aria-label={t.settings.dataRetention.auditRetentionDays}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
|
||||
min={0}
|
||||
/>
|
||||
</SettingRow>
|
||||
{hasPermission("compliance:manage") && (
|
||||
<SettingRow
|
||||
label={t.settings.dataRetention.auditRetentionDays}
|
||||
description={t.settings.dataRetention.auditRetentionDaysDesc}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.auditRetentionDays || "0"}
|
||||
onChange={(e) => updateSetting("auditRetentionDays", e.target.value)}
|
||||
aria-label={t.settings.dataRetention.auditRetentionDays}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
|
||||
min={0}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
@@ -1065,7 +1069,9 @@ function SecuritySection() {
|
||||
|
||||
<TwoFactorSettings />
|
||||
|
||||
{hasPermission("settings:write") && <AdminSecuritySettings />}
|
||||
{hasPermission("settings:write") && hasPermission("security:manage") && (
|
||||
<AdminSecuritySettings />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1235,7 +1241,7 @@ export function AdminSecuritySettings() {
|
||||
onChange={(e) => updateSetting("passwordMinLength", e.target.value)}
|
||||
aria-label={t.settings.security.passwordMinLength}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
|
||||
min={1}
|
||||
min={8}
|
||||
max={128}
|
||||
/>
|
||||
</SettingRow>
|
||||
@@ -1276,23 +1282,23 @@ export function AdminSecuritySettings() {
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={settings.passwordRequireNumber !== "false"}
|
||||
aria-checked={settings.passwordRequireDigit !== "false"}
|
||||
aria-label={t.settings.security.passwordRequireNumber}
|
||||
onClick={() =>
|
||||
updateSetting(
|
||||
"passwordRequireNumber",
|
||||
settings.passwordRequireNumber === "false" ? "true" : "false",
|
||||
"passwordRequireDigit",
|
||||
settings.passwordRequireDigit === "false" ? "true" : "false",
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"w-11 h-6 rounded-full transition-colors relative",
|
||||
settings.passwordRequireNumber !== "false" ? "bg-primary" : "bg-muted-foreground/30",
|
||||
settings.passwordRequireDigit !== "false" ? "bg-primary" : "bg-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block w-4 h-4 rounded-full bg-white absolute top-1 transition-transform",
|
||||
settings.passwordRequireNumber !== "false" ? "translate-x-6" : "translate-x-1",
|
||||
settings.passwordRequireDigit !== "false" ? "translate-x-6" : "translate-x-1",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
// 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 "********".
|
||||
// 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"]);
|
||||
// PUT /v1/settings rejects server-managed and dedicated-endpoint keys. GET can still
|
||||
// return some of them to a full administrator for status display, so strip them from
|
||||
// every generic bulk save along with redacted secret masks.
|
||||
const READONLY_SETTING_KEYS = new Set([
|
||||
"instance_id",
|
||||
"cookie_secret",
|
||||
"sqlite_import",
|
||||
"onboarding.firstProcessedAt",
|
||||
"scim_token_hash",
|
||||
"siem_config",
|
||||
"webhook_destinations",
|
||||
"ipAllowlist",
|
||||
"backup_last_completed",
|
||||
"audit_archival_state",
|
||||
"siem_last_forwarded_at",
|
||||
"siem_consecutive_failures",
|
||||
]);
|
||||
|
||||
export function writableSettings(settings: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
|
||||
Reference in New Issue
Block a user