fix: resolve P0 and P1 audit findings

- Fix state corruption on deleted queues in frontend
- Prevent concurrent dispatch of duplicate downloads
- Fix file paths when deleting native downloads
- Ensure pausing and resuming items persist state to database
- Remove duplicate IPC invocations for speed limits
- Stop PropertiesModal from resetting inputs during download progress
- Switch aria2 secret to use secure v4 UUID
- Fix SSRF vulnerability in fetch_metadata via DNS rebinding block
- Resolve race condition when reading media download logs
- Gracefully handle panics when acquiring prevent sleep lock
- Rate limit native downloads respecting global settings
- Enforce TLS certificate validation in backend requests
This commit is contained in:
NimBold
2026-06-15 14:10:45 +03:30
parent 22d59e3c11
commit 285ec0a81d
6 changed files with 132 additions and 93 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ serde_json = "1"
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] } tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
regex = "1.10" regex = "1.10"
reqwest = { version = "0.12", features = ["json", "stream"] } reqwest = { version = "0.12", features = ["json", "stream"] }
uuid = "1" uuid = { version = "1", features = ["v4"] }
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] } ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
tauri-plugin-notification = "2.3.3" tauri-plugin-notification = "2.3.3"
sysinfo = "0.39.3" sysinfo = "0.39.3"
+25 -18
View File
@@ -42,8 +42,7 @@ async fn fetch_metadata(url: String, user_agent: Option<String>, username: Optio
builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
} }
let client = builder.build().map_err(|e| e.to_string())?; let mut resolved_addr = None;
if let Ok(parsed) = reqwest::Url::parse(&url) { if let Ok(parsed) = reqwest::Url::parse(&url) {
if let Some(host) = parsed.host_str() { if let Some(host) = parsed.host_str() {
let port = parsed.port_or_known_default().unwrap_or(80); let port = parsed.port_or_known_default().unwrap_or(80);
@@ -57,13 +56,22 @@ async fn fetch_metadata(url: String, user_agent: Option<String>, username: Optio
std::net::IpAddr::V4(ipv4) if ipv4.is_private() || ipv4.is_link_local() => { std::net::IpAddr::V4(ipv4) if ipv4.is_private() || ipv4.is_link_local() => {
return Err("SSRF blocked: Private/local IP not allowed".to_string()); return Err("SSRF blocked: Private/local IP not allowed".to_string());
} }
_ => {} _ => {
resolved_addr = Some((host.to_string(), addr));
break;
}
} }
} }
} }
} }
} }
if let Some((host, addr)) = resolved_addr {
builder = builder.resolve(&host, addr);
}
let client = builder.build().map_err(|e| e.to_string())?;
let mut head_req = client.head(&url); let mut head_req = client.head(&url);
if let Some(ref user) = username { if let Some(ref user) = username {
if !user.is_empty() { if !user.is_empty() {
@@ -744,7 +752,7 @@ pub(crate) async fn start_media_download_internal(
cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting
let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?; let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?;
let stdout = child.stdout.take().unwrap(); let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
// yt-dlp parsing regex // yt-dlp parsing regex
let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap(); let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap();
@@ -807,17 +815,16 @@ pub(crate) async fn start_media_download_internal(
_ => break, _ => break,
} }
} }
status = child.wait() => { }
println!("child exit status: {:?}", status); }
if let Ok(exit_status) = status {
if exit_status.success() { let status = child.wait().await;
let _ = app_handle.emit("download-complete", id.to_string()); println!("child exit status: {:?}", status);
} else { if let Ok(exit_status) = status {
let _ = app_handle.emit("download-failed", id.to_string()); if exit_status.success() {
} let _ = app_handle.emit("download-complete", id.to_string());
} } else {
break; let _ = app_handle.emit("download-failed", id.to_string());
}
} }
} }
@@ -886,7 +893,7 @@ fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) {
#[tauri::command] #[tauri::command]
fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) { fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) {
let mut current_preventer = state.sleep_preventer.lock().unwrap(); let mut current_preventer = state.sleep_preventer.lock().unwrap_or_else(|e| e.into_inner());
if prevent { if prevent {
if current_preventer.is_none() { if current_preventer.is_none() {
if let Ok(keepawake) = keepawake::Builder::default().display(true).reason("Downloading files").create() { if let Ok(keepawake) = keepawake::Builder::default().display(true).reason("Downloading files").create() {
@@ -1231,7 +1238,7 @@ pub fn run() {
.and_then(|listener| listener.local_addr()) .and_then(|listener| listener.local_addr())
.map(|addr| addr.port()) .map(|addr| addr.port())
.unwrap_or(6800); .unwrap_or(6800);
let aria2_secret = format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()); let aria2_secret = uuid::Uuid::new_v4().to_string();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
restore_main_window(app); restore_main_window(app);
@@ -1280,7 +1287,7 @@ pub fn run() {
.arg("--summary-interval=1") .arg("--summary-interval=1")
.arg("--console-log-level=warn") .arg("--console-log-level=warn")
.arg("--download-result=hide") .arg("--download-result=hide")
.arg("--check-certificate=false") .arg("--check-certificate=true")
.spawn(); .spawn();
match aria2_process { match aria2_process {
+1 -3
View File
@@ -98,9 +98,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
}; };
const handleResume = (item: DownloadItem) => { const handleResume = (item: DownloadItem) => {
useDownloadStore.setState((state) => ({ updateDownload(item.id, { status: 'queued', _dispatched: false, speed: '-', eta: '-' });
downloads: state.downloads.map(d => d.id === item.id ? { ...d, status: 'queued', speed: '-', eta: '-' } : d)
}));
useDownloadStore.getState().processQueue(); useDownloadStore.getState().processQueue();
}; };
+2 -3
View File
@@ -10,7 +10,6 @@ export const PropertiesModal = () => {
const { const {
selectedPropertiesDownloadId, selectedPropertiesDownloadId,
setSelectedPropertiesDownloadId, setSelectedPropertiesDownloadId,
downloads,
updateDownload updateDownload
} = useDownloadStore(); } = useDownloadStore();
@@ -43,7 +42,7 @@ export const PropertiesModal = () => {
useEffect(() => { useEffect(() => {
if (selectedPropertiesDownloadId) { if (selectedPropertiesDownloadId) {
const activeItem = downloads.find(d => d.id === selectedPropertiesDownloadId); const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
if (activeItem) { if (activeItem) {
setItem(activeItem); setItem(activeItem);
setUrl(activeItem.url); setUrl(activeItem.url);
@@ -89,7 +88,7 @@ export const PropertiesModal = () => {
} else { } else {
setItem(null); setItem(null);
} }
}, [selectedPropertiesDownloadId, downloads, defaultDownloadPath]); }, [selectedPropertiesDownloadId, defaultDownloadPath]);
if (!selectedPropertiesDownloadId || !item) return null; if (!selectedPropertiesDownloadId || !item) return null;
+103 -66
View File
@@ -87,6 +87,8 @@ interface DownloadState {
initDB: () => Promise<void>; initDB: () => Promise<void>;
} }
let isProcessingQueue = false;
export const useDownloadStore = create<DownloadState>((set, get) => ({ export const useDownloadStore = create<DownloadState>((set, get) => ({
downloads: [], downloads: [],
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }], queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
@@ -167,13 +169,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const item = get().downloads.find(d => d.id === id); const item = get().downloads.find(d => d.id === id);
if (item && item.status === 'downloading') { if (item && item.status === 'downloading') {
try { try {
await invoke('remove_download', { id, filepath: item.destination || null }); const filepath = item.destination ? `${item.destination}/${item.fileName}` : null;
await invoke('remove_download', { id, filepath });
} catch (e) { } catch (e) {
console.error("Failed to terminate download on deletion:", e); console.error("Failed to terminate download on deletion:", e);
} }
} else if (item && deleteFile) { } else if (item && deleteFile) {
try { try {
await invoke('remove_download', { id, filepath: item.destination || null }); const filepath = item.destination ? `${item.destination}/${item.fileName}` : null;
await invoke('remove_download', { id, filepath });
} catch (e) { } catch (e) {
console.error("Failed to delete file from disk:", e); console.error("Failed to delete file from disk:", e);
} }
@@ -218,6 +222,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
: item : item
) )
})); }));
const downloadsToSave = get().downloads.filter(item => runnableIds.includes(item.id));
downloadsToSave.forEach(d => {
const toSave = { ...d };
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
});
await get().processQueue(); await get().processQueue();
return runnableIds.length; return runnableIds.length;
}, },
@@ -235,6 +247,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
: item : item
) )
})); }));
const downloadsToSave = get().downloads.filter(item => activeIds.includes(item.id));
downloadsToSave.forEach(d => {
const toSave = { ...d };
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
});
await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {}))); await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {})));
syncSystemIntegrations(); syncSystemIntegrations();
return activeIds.length; return activeIds.length;
@@ -265,6 +285,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}, },
removeQueue: (id) => { removeQueue: (id) => {
if (id === MAIN_QUEUE_ID) return; if (id === MAIN_QUEUE_ID) return;
const affectedDownloads = get().downloads.filter(d => d.queueId === id);
set((state) => ({ set((state) => ({
queues: state.queues.filter(q => q.id !== id), queues: state.queues.filter(q => q.id !== id),
downloads: state.downloads.map(d => downloads: state.downloads.map(d =>
@@ -274,8 +297,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
invoke('db_delete_queue', { id }).catch(console.error); invoke('db_delete_queue', { id }).catch(console.error);
// Also we need to save the updated downloads to DB // Also we need to save the updated downloads to DB
const downloads = get().downloads.filter(d => d.queueId === id); affectedDownloads.forEach(d => {
downloads.forEach(d => {
const toSave = { ...d, queueId: MAIN_QUEUE_ID }; const toSave = { ...d, queueId: MAIN_QUEUE_ID };
delete toSave.fraction; delete toSave.speed; delete toSave.eta; delete toSave.fraction; delete toSave.speed; delete toSave.eta;
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: MAIN_QUEUE_ID, data: JSON.stringify(toSave) }).catch(console.error); invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: MAIN_QUEUE_ID, data: JSON.stringify(toSave) }).catch(console.error);
@@ -296,6 +318,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
// Auto resume downloads that were active // Auto resume downloads that were active
const active = get().downloads.filter(d => d.status === 'downloading'); const active = get().downloads.filter(d => d.status === 'downloading');
const settings = useSettingsStore.getState();
active.forEach(item => { active.forEach(item => {
if (item.isMedia) { if (item.isMedia) {
invoke('start_media_download', { invoke('start_media_download', {
@@ -305,7 +328,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
filename: item.fileName, filename: item.fileName,
formatSelector: item.mediaFormatSelector || null, formatSelector: item.mediaFormatSelector || null,
cookieSource: null, cookieSource: null,
speedLimit: item.speedLimit || null, speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || null, username: item.username || null,
password: item.password || null, password: item.password || null,
headers: item.headers || null, headers: item.headers || null,
@@ -320,7 +343,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
destination: item.destination || '~/Downloads', destination: item.destination || '~/Downloads',
filename: item.fileName, filename: item.fileName,
connections: item.connections ?? null, connections: item.connections ?? null,
speedLimit: item.speedLimit || null, speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || null, username: item.username || null,
password: item.password || null, password: item.password || null,
headers: item.headers || null, headers: item.headers || null,
@@ -340,70 +363,84 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
} }
}, },
processQueue: async () => { processQueue: async () => {
const { downloads, updateDownload } = get(); if (isProcessingQueue) return;
isProcessingQueue = true;
// Find all queued items that haven't been dispatched to the backend yet try {
const itemsToStart = downloads.filter(d => d.status === 'queued' && !d._dispatched); const { downloads, updateDownload } = get();
const settings = useSettingsStore.getState();
for (const item of itemsToStart) { const concurrentLimit = settings.maxConcurrentDownloads || 3;
// Mark as dispatched so we don't send it again on the next pass const activeCount = downloads.filter(d => d.status === 'downloading').length;
updateDownload(item.id, { _dispatched: true }); let availableSlots = concurrentLimit - activeCount;
try {
const settings = useSettingsStore.getState();
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
const destPath = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
if (item.isMedia) { if (availableSlots <= 0) return;
await invoke('start_media_download', {
id: item.id, const itemsToStart = downloads.filter(d => d.status === 'queued' && !d._dispatched);
url: item.url,
destination: destPath, for (const item of itemsToStart) {
filename: item.fileName, if (availableSlots <= 0) break;
formatSelector: item.mediaFormatSelector || null, availableSlots--;
cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
speedLimit: item.speedLimit || null, // Mark as dispatched so we don't send it again on the next pass
username: item.username || (login ? login.username : null), updateDownload(item.id, { _dispatched: true });
password: item.password || keychainPassword, try {
headers: item.headers || null, const login = getSiteLogin(item.url, settings);
proxy: getProxyArgs(settings), let keychainPassword = null;
userAgent: settings.customUserAgent || null, if (login) {
maxTries: settings.maxAutomaticRetries try {
}); keychainPassword = await invoke('get_keychain_password', { id: login.id });
} else { } catch (e) {
await invoke('start_download', { console.warn("Could not fetch keychain password for login:", e);
id: item.id, }
url: item.url, }
destination: destPath,
filename: item.fileName, const destPath = item.destination ||
connections: item.connections || settings.perServerConnections || null, (settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
speedLimit: item.speedLimit || null, settings.defaultDownloadPath ||
username: item.username || (login ? login.username : null), '~/Downloads';
password: item.password || keychainPassword,
headers: item.headers || null, if (item.isMedia) {
checksum: item.checksum || null, await invoke('start_media_download', {
cookies: item.cookies || null, id: item.id,
mirrors: item.mirrors || null, url: item.url,
userAgent: settings.customUserAgent || null, destination: destPath,
maxTries: settings.maxAutomaticRetries, filename: item.fileName,
proxy: getProxyArgs(settings) formatSelector: item.mediaFormatSelector || null,
}); cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
proxy: getProxyArgs(settings),
userAgent: settings.customUserAgent || null,
maxTries: settings.maxAutomaticRetries
});
} else {
await invoke('start_download', {
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
userAgent: settings.customUserAgent || null,
maxTries: settings.maxAutomaticRetries,
proxy: getProxyArgs(settings)
});
}
} catch (e) {
console.error("Failed to start queued download:", e);
updateDownload(item.id, { status: 'failed' });
} }
} catch (e) {
console.error("Failed to start queued download:", e);
updateDownload(item.id, { status: 'failed' });
} }
} finally {
isProcessingQueue = false;
} }
} }
})); }));
-2
View File
@@ -233,11 +233,9 @@ export const useSettingsStore = create<SettingsState>()(
setDefaultDownloadPath: (path) => set({ defaultDownloadPath: path }), setDefaultDownloadPath: (path) => set({ defaultDownloadPath: path }),
setMaxConcurrentDownloads: (max) => { setMaxConcurrentDownloads: (max) => {
set({ maxConcurrentDownloads: max }); set({ maxConcurrentDownloads: max });
invoke('set_concurrent_limit', { limit: max }).catch(console.error);
}, },
setGlobalSpeedLimit: (limit) => { setGlobalSpeedLimit: (limit) => {
set({ globalSpeedLimit: limit }); set({ globalSpeedLimit: limit });
invoke('set_global_speed_limit', { limit: limit === '' || limit === '0' ? null : limit }).catch(console.error);
}, },
setActiveView: (view) => set({ activeView: view }), setActiveView: (view) => set({ activeView: view }),
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }), setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),