fix: prevent WebDAV password from being silently cleared by unrelated saves

Two components (ProviderList, UsageScriptModal) directly spread the full
settings object from the query into settingsApi.save(), which includes
webdavSync with an empty password (cleared by get_settings_for_frontend
for security). The backend merge_settings_for_save only preserved
existing WebDAV config when the incoming field was None, not when it was
present with an empty password.

Frontend fix: strip webdavSync before saving in both components, matching
the pattern already used by useSettings hook.

Backend defense-in-depth: merge_settings_for_save now backfills the
existing password when the incoming one is empty, preventing future
regressions from similar oversights.
This commit is contained in:
Jason
2026-03-23 09:54:05 +08:00
parent 8aa6ec784b
commit 0a301a497c
3 changed files with 78 additions and 4 deletions
+74 -2
View File
@@ -6,8 +6,20 @@ fn merge_settings_for_save(
mut incoming: crate::settings::AppSettings,
existing: &crate::settings::AppSettings,
) -> crate::settings::AppSettings {
if incoming.webdav_sync.is_none() {
incoming.webdav_sync = existing.webdav_sync.clone();
match (&mut incoming.webdav_sync, &existing.webdav_sync) {
// incoming 没有 webdav → 保留现有
(None, _) => {
incoming.webdav_sync = existing.webdav_sync.clone();
}
// incoming 有 webdav 但密码为空,且现有有密码 → 填回现有密码
// get_settings_for_frontend 总是清空密码,所以通过 save_settings
// 传入的空密码意味着"保持现有"而非"用户主动清空"
(Some(incoming_sync), Some(existing_sync))
if incoming_sync.password.is_empty() && !existing_sync.password.is_empty() =>
{
incoming_sync.password = existing_sync.password.clone();
}
_ => {}
}
incoming
}
@@ -116,6 +128,66 @@ mod tests {
Some("https://dav.new.example.com")
);
}
/// Regression test: frontend always receives empty password from
/// get_settings_for_frontend(). If a component accidentally spreads
/// the full settings object into save_settings, the empty password
/// must NOT overwrite the existing one.
#[test]
fn save_settings_should_preserve_password_when_incoming_has_empty_password() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
});
// Simulate frontend sending settings with cleared password
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let merged = merge_settings_for_save(incoming, &existing);
assert_eq!(
merged.webdav_sync.as_ref().map(|v| v.password.as_str()),
Some("secret"),
"empty password from frontend must not overwrite existing password"
);
}
/// When both incoming and existing have no password, merge should
/// work without panicking and keep the empty state.
#[test]
fn save_settings_should_handle_both_empty_passwords() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let merged = merge_settings_for_save(incoming, &existing);
assert_eq!(
merged.webdav_sync.as_ref().map(|v| v.password.as_str()),
Some("")
);
}
}
/// 获取开机自启状态
+2 -1
View File
@@ -267,7 +267,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
setShowUsageConfirm(false);
try {
if (settingsData) {
await settingsApi.save({ ...settingsData, usageConfirmed: true });
const { webdavSync: _, ...rest } = settingsData;
await settingsApi.save({ ...rest, usageConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
}
} catch (error) {
+2 -1
View File
@@ -204,7 +204,8 @@ export function ProviderList({
setShowStreamCheckConfirm(false);
try {
if (settings) {
await settingsApi.save({ ...settings, streamCheckConfirmed: true });
const { webdavSync: _, ...rest } = settings;
await settingsApi.save({ ...rest, streamCheckConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
}
} catch (error) {