From 45bbca051521f55178c2c33a38a8a12c67fdf862 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 15 Jul 2026 02:25:53 +0330 Subject: [PATCH] fix(settings): harden persisted settings and update checks --- src-tauri/src/lib.rs | 35 ++--- src-tauri/src/settings.rs | 156 ++++++++++++++++++++- src/components/KeychainPermissionModal.tsx | 13 +- src/components/SettingsView.tsx | 54 +++++-- src/store/useSettingsStore.ts | 140 ++++++++++++++++-- src/utils/releaseUrls.test.ts | 17 +++ src/utils/releaseUrls.ts | 27 ++++ 7 files changed, 388 insertions(+), 54 deletions(-) create mode 100644 src/utils/releaseUrls.test.ts create mode 100644 src/utils/releaseUrls.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 89c6f4d..5072f01 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1688,14 +1688,9 @@ async fn fetch_media_metadata( (Err(error), Some(browser)) if !browser.trim().is_empty() && is_browser_cookie_extraction_error(&error) => { - log::warn!( - "yt-dlp could not read browser cookies from {}; retrying media metadata without browser cookies", - browser - ); - fetch_media_metadata_uncached( - app_handle, url, None, user_agent, username, password, headers, cookies, proxy, - ) - .await + Err(format!( + "Browser cookie extraction failed for {browser}: {error}" + )) } (result, _) => result, }; @@ -3025,8 +3020,7 @@ pub(crate) async fn start_media_download_internal( let max_retries = max_tries.unwrap_or(0).max(0) as usize; let mut strike = 0_usize; - let mut effective_cookie_source = cookie_source.clone(); - let mut browser_cookie_fallback_used = false; + let effective_cookie_source = cookie_source.clone(); while strike <= max_retries { let mut processing_started = false; @@ -3309,20 +3303,15 @@ pub(crate) async fn start_media_download_internal( if should_cleanup_media_artifacts_after_failure(&failure_reason, strike, max_retries) { cleanup_media_artifacts(&out_path, false).await; } - if !browser_cookie_fallback_used - && effective_cookie_source - .as_deref() - .is_some_and(|source| !source.trim().is_empty() && source != "none") + if effective_cookie_source + .as_deref() + .is_some_and(|source| !source.trim().is_empty() && source != "none") && is_browser_cookie_extraction_error(&failure_reason) { let source = effective_cookie_source.clone().unwrap_or_default(); - log::warn!( - "yt-dlp could not read browser cookies from {}; retrying media download without browser cookies", - source - ); - effective_cookie_source = None; - browser_cookie_fallback_used = true; - continue; + return Err(format!( + "Browser cookie extraction failed for {source}: {failure_reason}" + )); } if !(transient && strikes_left) { return Err(failure_reason); @@ -4583,7 +4572,7 @@ async fn set_concurrent_limit( state: tauri::State<'_, AppState>, limit: usize, ) -> Result<(), String> { - state.queue_manager.set_capacity(limit); + state.queue_manager.set_capacity(limit.clamp(1, 12)); Ok(()) } @@ -5843,7 +5832,7 @@ mod tests { } #[test] - fn classifies_browser_cookie_database_errors_for_fallback() { + fn classifies_browser_cookie_database_errors() { assert!(is_browser_cookie_extraction_error( "ERROR: Could not copy Chrome cookie database. See https://github.com/yt-dlp/yt-dlp/issues/7271" )); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index d3fbd9f..cc688c5 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -20,6 +20,7 @@ pub fn decode_stored_settings(stored: &Value) -> Result { for (key, value) in source { if let Some(target_value) = target.get_mut(key) { - merge_json(target_value, value); + if json_value_types_match(target_value, value) { + merge_json(target_value, value); + } } else { target.insert(key.clone(), value.clone()); } } } - (target, source) => *target = source.clone(), + (target, source) if json_value_types_match(target, source) => *target = source.clone(), + _ => {} + } +} + +fn json_value_types_match(left: &Value, right: &Value) -> bool { + matches!( + (left, right), + (Value::Null, Value::Null) + | (Value::Bool(_), Value::Bool(_)) + | (Value::Number(_), Value::Number(_)) + | (Value::String(_), Value::String(_)) + | (Value::Array(_), Value::Array(_)) + | (Value::Object(_), Value::Object(_)) + ) +} + +fn sanitize_persisted_setting_values(state: &mut Value) { + let Some(state) = state.as_object_mut() else { + return; + }; + + sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some()); + sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some()); + sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some()); + sanitize_allowed_string( + state, + "theme", + &["system", "light", "dark", "dracula", "nord"], + ); + sanitize_allowed_string(state, "appFontSize", &["small", "standard", "large"]); + sanitize_allowed_string(state, "listRowDensity", &["compact", "standard", "relaxed"]); + sanitize_allowed_string(state, "activeSettingsTab", &[ + "downloads", + "lookandfeel", + "network", + "locations", + "sitelogins", + "power", + "engine", + "integrations", + "about", + ]); + sanitize_allowed_string(state, "proxyMode", &["none", "system", "custom"]); + sanitize_allowed_string( + state, + "mediaCookieSource", + &[ + "none", "safari", "chrome", "chromium", "firefox", "edge", "brave", "opera", + "vivaldi", "whale", + ], + ); + + if let Some(scheduler) = state.get_mut("scheduler").and_then(Value::as_object_mut) { + sanitize_allowed_string( + scheduler, + "postQueueAction", + &["none", "sleep", "restart", "shutdown"], + ); + } + + if let Some(logins) = state.get_mut("siteLogins").and_then(Value::as_array_mut) { + logins.retain(|login| { + let Some(login) = login.as_object() else { + return false; + }; + ["id", "urlPattern", "username"] + .into_iter() + .all(|key| login.get(key).and_then(Value::as_str).is_some()) + }); + } +} + +fn sanitize_integer_setting( + state: &mut serde_json::Map, + key: &str, + is_valid: impl Fn(&Value) -> bool, +) { + if state.get(key).is_some_and(|value| !is_valid(value)) { + state.remove(key); + } +} + +fn sanitize_allowed_string( + state: &mut serde_json::Map, + key: &str, + allowed: &[&str], +) { + let valid = state + .get(key) + .and_then(Value::as_str) + .is_some_and(|value| allowed.contains(&value)); + if state.contains_key(key) && !valid { + state.remove(key); } } @@ -167,6 +263,9 @@ fn validate_settings(settings: &mut PersistedSettings) { if settings.max_concurrent_downloads == 0 { settings.max_concurrent_downloads = default_settings().max_concurrent_downloads; } + settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12); + settings.per_server_connections = settings.per_server_connections.clamp(1, 16); + settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10); } fn default_category_subfolders() -> HashMap { @@ -334,7 +433,7 @@ fn default_settings() -> PersistedSettings { per_server_connections: 16, max_automatic_retries: 3, show_notifications: true, - play_completion_sound: true, + play_completion_sound: false, app_font_size: AppFontSize::Standard, list_row_density: ListRowDensity::Standard, show_dock_badge: true, @@ -356,7 +455,8 @@ fn default_settings() -> PersistedSettings { #[cfg(test)] mod tests { use super::{ - decode_stored_settings, preserve_portable_pairing_token, preserve_scheduler_runtime_keys, + decode_stored_settings, default_settings, preserve_portable_pairing_token, + preserve_scheduler_runtime_keys, }; use serde_json::{json, Value}; @@ -545,6 +645,54 @@ mod tests { assert_eq!(settings.max_concurrent_downloads, 3); } + #[test] + fn clamps_out_of_range_download_settings() { + let stored = json!({ + "state": { + "maxConcurrentDownloads": 99, + "perServerConnections": -4, + "maxAutomaticRetries": 99 + }, + "version": 3 + }); + + let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); + + assert_eq!(settings.max_concurrent_downloads, 12); + assert_eq!(settings.per_server_connections, 1); + assert_eq!(settings.max_automatic_retries, 10); + } + + #[test] + fn ignores_malformed_setting_types_without_dropping_valid_values() { + let stored = json!({ + "state": { + "baseDownloadFolder": "/Users/test/Downloads", + "maxConcurrentDownloads": "not-a-number", + "perServerConnections": 5, + "showNotifications": "yes", + "theme": "not-a-theme", + "siteLogins": [{"id": "valid", "urlPattern": "example.com", "username": "user"}, {"id": 3}] + }, + "version": 3 + }); + + let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); + + assert_eq!(settings.base_download_folder, "/Users/test/Downloads"); + assert_eq!(settings.max_concurrent_downloads, 3); + assert_eq!(settings.per_server_connections, 5); + assert!(settings.show_notifications); + assert!(matches!(settings.theme, crate::ipc::Theme::System)); + assert_eq!(settings.site_logins.len(), 1); + assert_eq!(settings.site_logins[0].id, "valid"); + } + + #[test] + fn completion_sound_default_matches_the_frontend_default() { + assert!(!default_settings().play_completion_sound); + } + #[test] fn ignores_legacy_extension_pairing_token_field() { // Older standard installs persisted `extensionPairingToken` as diff --git a/src/components/KeychainPermissionModal.tsx b/src/components/KeychainPermissionModal.tsx index 2aac463..ca6242f 100644 --- a/src/components/KeychainPermissionModal.tsx +++ b/src/components/KeychainPermissionModal.tsx @@ -6,7 +6,7 @@ import { usePlatformInfo } from '../utils/platform'; export const KeychainPermissionModal: React.FC = () => { const showKeychainModal = useSettingsStore(state => state.showKeychainModal); - const setShowKeychainModal = useSettingsStore(state => state.setShowKeychainModal); + const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt); const platform = usePlatformInfo(); const [isGranting, setIsGranting] = useState(false); const [error, setError] = useState(null); @@ -14,11 +14,11 @@ export const KeychainPermissionModal: React.FC = () => { useEffect(() => { if (!showKeychainModal || isGranting) return; const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') setShowKeychainModal(false); + if (event.key === 'Escape') dismissKeychainPrompt(); }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); - }, [isGranting, setShowKeychainModal, showKeychainModal]); + }, [dismissKeychainPrompt, isGranting, showKeychainModal]); if (!showKeychainModal) { return null; @@ -53,9 +53,10 @@ export const KeychainPermissionModal: React.FC = () => { useSettingsStore.setState({ keychainAccessGranted: true, extensionPairingToken: result.token, - isPairingTokenPersistent: true + isPairingTokenPersistent: true, + keychainPromptDismissed: false, + showKeychainModal: false }); - setShowKeychainModal(false); } else { setError(result.error || `${storeName} is unavailable.`); } @@ -67,7 +68,7 @@ export const KeychainPermissionModal: React.FC = () => { }; const handleLater = () => { - setShowKeychainModal(false); + dismissKeychainPrompt(); }; return ( diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 7fcee54..190fe1c 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -28,6 +28,7 @@ import { subfolderFromDerivedCategoryPath } from '../utils/downloadLocations'; import { usePlatformInfo } from '../utils/platform'; +import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls'; const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [ { type: 'downloads', label: 'Downloads', icon: Download }, @@ -275,7 +276,7 @@ const [engineStatus, setEngineStatus] = useState(null const [expandedEngine, setExpandedEngine] = useState(null); const [isRecheckingEngines, setIsRecheckingEngines] = useState(false); const engineRunId = useRef(0); -const [appVersion, setAppVersion] = useState('1.0.1'); +const [appVersion, setAppVersion] = useState('Unknown'); const [extensionServerPort, setExtensionServerPort] = useState(null); // Local state for adding site login @@ -283,6 +284,11 @@ const [extensionServerPort, setExtensionServerPort] = useState(nu const [loginUser, setLoginUser] = useState(''); const [loginPass, setLoginPass] = useState(''); const [loginError, setLoginError] = useState(''); + const [loginFieldErrors, setLoginFieldErrors] = useState<{ + pattern?: string; + username?: string; + password?: string; + }>({}); // Toast notifications const { addToast } = useToast(); @@ -431,6 +437,9 @@ runEngineChecks(false); localVersion: result.local_version }); } else if (result.type === 'UpdateAvailable') { + if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) { + throw new Error('The update check returned an untrusted release URL.'); + } setManualUpdateStatus({ type: 'update-available', version: result.update.version, @@ -512,10 +521,24 @@ runEngineChecks(false); }; const handleAddLogin = async () => { - if (!loginPattern.trim() || !loginUser.trim()) { - setLoginError("Please enter a URL pattern and a username."); + const fieldErrors: typeof loginFieldErrors = {}; + if (!loginPattern.trim()) { + fieldErrors.pattern = 'URL pattern is required.'; + } else if (/\s/.test(loginPattern.trim())) { + fieldErrors.pattern = 'URL pattern cannot contain whitespace.'; + } + if (!loginUser.trim()) { + fieldErrors.username = 'Username is required.'; + } + if (!loginPass) { + fieldErrors.password = 'Password is required.'; + } + if (Object.keys(fieldErrors).length > 0) { + setLoginFieldErrors(fieldErrors); + setLoginError(''); return; } + setLoginFieldErrors({}); const id = crypto.randomUUID(); if (loginPass) { @@ -1057,10 +1080,15 @@ runEngineChecks(false); setLoginPattern(e.target.value)} + onChange={(e) => { + setLoginPattern(e.target.value); + setLoginFieldErrors(current => ({ ...current, pattern: undefined })); + }} placeholder="e.g. *.example.com or example.com/downloads" + aria-invalid={Boolean(loginFieldErrors.pattern)} className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none" /> + {loginFieldErrors.pattern &&

{loginFieldErrors.pattern}

}
@@ -1068,10 +1096,15 @@ runEngineChecks(false); setLoginUser(e.target.value)} + onChange={(e) => { + setLoginUser(e.target.value); + setLoginFieldErrors(current => ({ ...current, username: undefined })); + }} placeholder="Username" + aria-invalid={Boolean(loginFieldErrors.username)} className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none" /> + {loginFieldErrors.username &&

{loginFieldErrors.username}

}
@@ -1079,10 +1112,15 @@ runEngineChecks(false); setLoginPass(e.target.value)} + onChange={(e) => { + setLoginPass(e.target.value); + setLoginFieldErrors(current => ({ ...current, password: undefined })); + }} placeholder="Password" + aria-invalid={Boolean(loginFieldErrors.password)} className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none" /> + {loginFieldErrors.password &&

{loginFieldErrors.password}

}
@@ -1215,12 +1253,12 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled

- {platform.portable ? 'Portable Pairing Enabled' : 'Credential Storage Available'} + {platform.portable ? 'Portable Pairing Enabled' : 'Pairing Token Persisted'}

{platform.portable ? 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.' - : "Your pairing token is securely saved in this system's credential store and will persist across restarts."} + : 'Your pairing token is persisted in Firelink settings and will persist across restarts.'}

diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index ca2d0d5..2d33431 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -22,6 +22,50 @@ let settingsSave = Promise.resolve(); const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10]; +const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const; +const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const; +const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const; +const PROXY_MODE_VALUES = ['none', 'system', 'custom'] as const; +const MEDIA_COOKIE_SOURCE_VALUES = [ + 'none', 'safari', 'chrome', 'chromium', 'firefox', 'edge', 'brave', 'opera', 'vivaldi', 'whale' +] as const; +const SETTINGS_TAB_VALUES = [ + 'downloads', 'lookandfeel', 'network', 'locations', 'sitelogins', 'power', 'engine', 'integrations', 'about' +] as const; + +type PersistedSettingsSnapshot = PersistedSettings & { + keychainPromptDismissed: boolean; +}; + +const clampSettingInteger = ( + value: unknown, + minimum: number, + maximum: number, + fallback: number +) => { + const numeric = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(maximum, Math.max(minimum, Math.trunc(numeric))); +}; + +const isAllowedSetting = (values: readonly T[], value: unknown): value is T => + typeof value === 'string' && values.includes(value as T); + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const sanitizeSiteLogins = (value: unknown): SiteLogin[] => { + if (!Array.isArray(value)) return []; + return value.filter(isRecord).filter((login): login is SiteLogin => + typeof login.id === 'string' + && typeof login.urlPattern === 'string' + && typeof login.username === 'string' + ); +}; + +const persistedBoolean = (value: unknown, fallback: boolean) => + typeof value === 'boolean' ? value : fallback; + const tauriStorage: StateStorage = { getItem: async (name: string): Promise => { if (name === 'firelink-settings') { @@ -113,6 +157,7 @@ export interface SettingsState { extensionPairingToken: string; isPairingTokenPersistent: boolean; keychainAccessGranted: boolean; + keychainPromptDismissed: boolean; autoCheckUpdates: boolean; showKeychainModal: boolean; @@ -158,6 +203,7 @@ export interface SettingsState { setAutoCheckUpdates: (autoCheckUpdates: boolean) => void; hydratePairingToken: () => Promise; setShowKeychainModal: (show: boolean) => void; + dismissKeychainPrompt: () => void; } const generateSecureToken = () => { @@ -188,7 +234,7 @@ const generateSecureToken = () => { export const useSettingsStore = create()( persist( - (set, _get) => ({ + (set, get) => ({ theme: 'system', baseDownloadFolder: '~/Downloads', categorySubfoldersEnabled: true, @@ -236,8 +282,9 @@ export const useSettingsStore = create()( mediaCookieSource: 'none', siteLogins: [], extensionPairingToken: '', - isPairingTokenPersistent: true, + isPairingTokenPersistent: false, keychainAccessGranted: false, + keychainPromptDismissed: false, autoCheckUpdates: true, showKeychainModal: false, @@ -257,7 +304,9 @@ export const useSettingsStore = create()( }, setMaxConcurrentDownloads: (max) => { info('Settings updated: maxConcurrentDownloads'); - set({ maxConcurrentDownloads: max }); + set({ + maxConcurrentDownloads: clampSettingInteger(max, 1, 12, 3) + }); }, setGlobalSpeedLimit: (limit) => { info('Settings updated: globalSpeedLimit'); @@ -275,8 +324,12 @@ export const useSettingsStore = create()( setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }), toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })), - setPerServerConnections: (perServerConnections) => set({ perServerConnections }), - setMaxAutomaticRetries: (maxAutomaticRetries) => set({ maxAutomaticRetries }), + setPerServerConnections: (perServerConnections) => set({ + perServerConnections: clampSettingInteger(perServerConnections, 1, 16, 16) + }), + setMaxAutomaticRetries: (maxAutomaticRetries) => set({ + maxAutomaticRetries: clampSettingInteger(maxAutomaticRetries, 0, 10, 3) + }), setShowNotifications: (showNotifications) => set({ showNotifications }), setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }), setAppFontSize: (appFontSize) => set({ appFontSize }), @@ -344,15 +397,17 @@ export const useSettingsStore = create()( const result = await invoke('hydrate_extension_pairing_token'); set({ extensionPairingToken: result.token, - isPairingTokenPersistent: result.persistent + isPairingTokenPersistent: result.persistent, + showKeychainModal: !result.persistent && !get().keychainPromptDismissed }); - if (!result.persistent) { - set({ showKeychainModal: true }); - } return result.tokenChanged; }, setAutoCheckUpdates: (autoCheckUpdates: boolean) => set({ autoCheckUpdates }), setShowKeychainModal: (show: boolean) => set({ showKeychainModal: show }), + dismissKeychainPrompt: () => set({ + keychainPromptDismissed: true, + showKeychainModal: false + }), }), { name: 'firelink-settings', @@ -391,7 +446,7 @@ export const useSettingsStore = create()( logsEnabled: persisted.logsEnabled === true } as SettingsState; }, - partialize: (state): PersistedSettings => ({ + partialize: (state): PersistedSettingsSnapshot => ({ theme: state.theme, baseDownloadFolder: state.baseDownloadFolder, categorySubfoldersEnabled: state.categorySubfoldersEnabled, @@ -429,6 +484,7 @@ export const useSettingsStore = create()( siteLogins: state.siteLogins, extensionPairingToken: state.extensionPairingToken, keychainAccessGranted: state.keychainAccessGranted, + keychainPromptDismissed: state.keychainPromptDismissed, autoCheckUpdates: state.autoCheckUpdates }), merge: (persistedState: unknown, currentState) => { @@ -440,6 +496,66 @@ export const useSettingsStore = create()( ...currentState, ...persisted, ...locations, + theme: isAllowedSetting(THEME_VALUES, persisted.theme) + ? persisted.theme + : currentState.theme, + appFontSize: isAllowedSetting(APP_FONT_SIZE_VALUES, persisted.appFontSize) + ? persisted.appFontSize + : currentState.appFontSize, + listRowDensity: isAllowedSetting(LIST_ROW_DENSITY_VALUES, persisted.listRowDensity) + ? persisted.listRowDensity + : currentState.listRowDensity, + proxyMode: isAllowedSetting(PROXY_MODE_VALUES, persisted.proxyMode) + ? persisted.proxyMode + : currentState.proxyMode, + mediaCookieSource: isAllowedSetting(MEDIA_COOKIE_SOURCE_VALUES, persisted.mediaCookieSource) + ? persisted.mediaCookieSource + : 'none', + activeSettingsTab: isAllowedSetting(SETTINGS_TAB_VALUES, persisted.activeSettingsTab) + ? persisted.activeSettingsTab + : currentState.activeSettingsTab, + extensionPairingToken: typeof persisted.extensionPairingToken === 'string' + ? persisted.extensionPairingToken + : currentState.extensionPairingToken, + showNotifications: persistedBoolean(persisted.showNotifications, currentState.showNotifications), + playCompletionSound: persistedBoolean(persisted.playCompletionSound, currentState.playCompletionSound), + showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge), + showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon), + askWhereToSaveEachFile: persistedBoolean( + persisted.askWhereToSaveEachFile, + currentState.askWhereToSaveEachFile + ), + preventsSleepWhileDownloading: persistedBoolean( + persisted.preventsSleepWhileDownloading, + currentState.preventsSleepWhileDownloading + ), + keychainAccessGranted: persistedBoolean( + persisted.keychainAccessGranted, + currentState.keychainAccessGranted + ), + keychainPromptDismissed: persistedBoolean( + persisted.keychainPromptDismissed, + currentState.keychainPromptDismissed + ), + autoCheckUpdates: persistedBoolean(persisted.autoCheckUpdates, currentState.autoCheckUpdates), + maxConcurrentDownloads: clampSettingInteger( + persisted.maxConcurrentDownloads, + 1, + 12, + currentState.maxConcurrentDownloads + ), + perServerConnections: clampSettingInteger( + persisted.perServerConnections, + 1, + 16, + currentState.perServerConnections + ), + maxAutomaticRetries: clampSettingInteger( + persisted.maxAutomaticRetries, + 0, + 10, + currentState.maxAutomaticRetries + ), speedLimitPresetValues: Array.isArray(persisted.speedLimitPresetValues) ? persisted.speedLimitPresetValues : currentState.speedLimitPresetValues, @@ -455,10 +571,8 @@ export const useSettingsStore = create()( ? persisted.scheduler.selectedQueueIds : currentState.scheduler.selectedQueueIds }, - appFontSize: persisted.appFontSize || currentState.appFontSize, - listRowDensity: persisted.listRowDensity || currentState.listRowDensity, siteLogins: Array.isArray(persisted.siteLogins) - ? persisted.siteLogins + ? sanitizeSiteLogins(persisted.siteLogins) : currentState.siteLogins }); } diff --git a/src/utils/releaseUrls.test.ts b/src/utils/releaseUrls.test.ts new file mode 100644 index 0000000..ec3eebc --- /dev/null +++ b/src/utils/releaseUrls.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { isTrustedFirelinkReleaseUrl } from './releaseUrls'; + +describe('Firelink release URLs', () => { + it('accepts the repository release page and exact release tags', () => { + expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases')).toBe(true); + expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5')).toBe(true); + }); + + it('rejects lookalike paths and URLs with authority or path tricks', () => { + expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases-evil')).toBe(false); + expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases%2F..%2Fother')).toBe(false); + expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5%5C..%5Cuser%5Crepo')).toBe(false); + expect(isTrustedFirelinkReleaseUrl('https://github.com.evil.example/nimbold/Firelink/releases')).toBe(false); + expect(isTrustedFirelinkReleaseUrl('https://github.com/nimbold/Firelink/releases/tag/v1.0.5?redirect=evil')).toBe(false); + }); +}); diff --git a/src/utils/releaseUrls.ts b/src/utils/releaseUrls.ts new file mode 100644 index 0000000..8be086c --- /dev/null +++ b/src/utils/releaseUrls.ts @@ -0,0 +1,27 @@ +const FIRELINK_RELEASES_PATH = '/nimbold/Firelink/releases'; +const FIRELINK_RELEASE_TAG_PATTERN = new RegExp( + `^${FIRELINK_RELEASES_PATH}/tag/[A-Za-z0-9._-]+$` +); + +export const isTrustedFirelinkReleaseUrl = (value: string) => { + try { + const parsed = new URL(value); + if ( + parsed.protocol !== 'https:' + || parsed.hostname !== 'github.com' + || parsed.port + || parsed.username + || parsed.password + || parsed.search + || parsed.hash + ) { + return false; + } + + const pathname = decodeURIComponent(parsed.pathname); + return pathname === FIRELINK_RELEASES_PATH + || FIRELINK_RELEASE_TAG_PATTERN.test(pathname); + } catch { + return false; + } +};