From b147ec5f787313e1ed95302c4afeb5b44a6bc400 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 24 Jun 2026 23:36:21 +0330 Subject: [PATCH] fix: resolve 7 critical security vulnerabilities from audit --- package-lock.json | 67 +++++------------------------- package.json | 1 + src-tauri/src/lib.rs | 82 ++++++++++++++++++++++++------------- src-tauri/src/queue.rs | 18 +++++++- src/App.tsx | 13 +++++- src/components/LogsView.tsx | 4 +- src/ipc.ts | 2 +- src/store/downloadStore.ts | 15 +++++++ 8 files changed, 110 insertions(+), 92 deletions(-) diff --git a/package-lock.json b/package-lock.json index cf57f98..8e515cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", @@ -227,9 +228,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -246,9 +244,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -265,9 +260,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -284,9 +276,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -303,9 +292,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -322,9 +308,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -538,9 +521,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -557,9 +537,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -576,9 +553,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -595,9 +569,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -781,9 +752,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -801,9 +769,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -821,9 +786,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -841,9 +803,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -861,9 +820,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -942,6 +898,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", + "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-log": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.8.0.tgz", @@ -1564,9 +1529,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1587,9 +1549,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1610,9 +1569,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1633,9 +1589,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index b3fbe5c..ab7e7c3 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 88ff710..ac9f13e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -717,6 +717,39 @@ async fn cleanup_media_artifacts(out_path: &std::path::Path, remove_primary: boo +async fn validate_url_ssrf(url: &str) -> Result, String> { + let parsed = reqwest::Url::parse(url).map_err(|_| "SSRF blocked: Invalid URL")?; + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return Err("SSRF blocked: Only HTTP/HTTPS schemes allowed".to_string()); + } + let host = parsed.host_str().ok_or("SSRF blocked: No host")?; + let port = parsed.port_or_known_default().unwrap_or(80); + + let mut addrs = tokio::net::lookup_host((host, port)) + .await + .map_err(|_| "SSRF blocked: DNS resolution failed")?; + + let addr = addrs.next().ok_or("SSRF blocked: No DNS records")?; + let ip = addr.ip(); + + if ip.is_loopback() || ip.is_multicast() || ip.is_unspecified() { + return Err("SSRF blocked: Private/local IP not allowed".to_string()); + } + match ip { + std::net::IpAddr::V4(ipv4) => { + if ipv4.is_private() || ipv4.is_link_local() { + return Err("SSRF blocked: Private/local IP not allowed".to_string()); + } + } + std::net::IpAddr::V6(ipv6) => { + if (ipv6.segments()[0] & 0xfe00) == 0xfc00 { // ULA check + return Err("SSRF blocked: Private/local IP not allowed".to_string()); + } + } + } + Ok(Some((host.to_string(), addr))) +} + #[tauri::command] async fn fetch_metadata(url: String, user_agent: Option, username: Option, password: Option) -> Result { let mut current_url = url.clone(); @@ -740,26 +773,7 @@ async fn fetch_metadata(url: String, user_agent: Option, 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"); } - let mut resolved_addr = None; - if let Ok(parsed) = reqwest::Url::parse(¤t_url) { - if let Some(host) = parsed.host_str() { - let port = parsed.port_or_known_default().unwrap_or(80); - if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&(host, port)) { - if let Some(addr) = addrs.into_iter().next() { - let ip = addr.ip(); - if ip.is_loopback() || ip.is_multicast() || ip.is_unspecified() { - return Err("SSRF blocked: Private/local IP not allowed".to_string()); - } - if let std::net::IpAddr::V4(ipv4) = ip { - if ipv4.is_private() || ipv4.is_link_local() { - return Err("SSRF blocked: Private/local IP not allowed".to_string()); - } - } - resolved_addr = Some((host.to_string(), addr)); - } - } - } - } + let resolved_addr = validate_url_ssrf(¤t_url).await?; if let Some((host, addr)) = resolved_addr { builder = builder.resolve(&host, addr); @@ -909,6 +923,7 @@ async fn fetch_media_metadata( username: Option, password: Option, ) -> Result { + validate_url_ssrf(&url).await?; let cache_key = media_metadata_cache_key(&url, &cookie_browser, &username, &password); let cache = MEDIA_METADATA_CACHE.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); @@ -1011,12 +1026,14 @@ async fn fetch_media_metadata_uncached(app_handle: tauri::AppHandle, url: String let mut config_content = String::new(); if let Some(user) = username { if !user.is_empty() { - config_content.push_str(&format!("--username\n{}\n", user)); + let safe_user = user.replace('\n', "").replace('\r', ""); + config_content.push_str(&format!("--username\n{}\n", safe_user)); } } if let Some(pass) = password { if !pass.is_empty() { - config_content.push_str(&format!("--password\n{}\n", pass)); + let safe_pass = pass.replace('\n', "").replace('\r', ""); + config_content.push_str(&format!("--password\n{}\n", safe_pass)); } } use std::io::Write; @@ -1246,6 +1263,7 @@ struct Aria2DaemonGuard { child: Mutex>, startup_error: Mutex>, last_stderr: Mutex, + config_path: Mutex>, } impl Aria2DaemonGuard { @@ -1254,6 +1272,7 @@ impl Aria2DaemonGuard { child: Mutex::new(None), startup_error: Mutex::new(None), last_stderr: Mutex::new(String::new()), + config_path: Mutex::new(None), } } } @@ -3303,7 +3322,6 @@ async fn clear_logs(app_handle: tauri::AppHandle) -> Result<(), String> { async fn export_logs( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, - dest_path: String, ) -> Result { let mut output = format!( "Firelink support logs\nVersion: {}\nOS: {} {}\nArchitecture: {}\nGenerated: {}\n\n", @@ -3360,10 +3378,7 @@ async fn export_logs( } output.push('\n'); } - tokio::fs::write(&dest_path, output) - .await - .map_err(|e| e.to_string())?; - Ok(dest_path) + Ok(output) } #[tauri::command] @@ -4004,9 +4019,16 @@ pub fn run() { match resolve_bundled_binary_path(app.handle(), "aria2c") { Ok(binary_path) => { let mut cmd = std::process::Command::new(&binary_path); + + let mut config_file = tempfile::Builder::new().prefix("aria2-").suffix(".conf").tempfile().expect("failed to create aria2 config file"); + use std::io::Write; + let config_content = format!("rpc-secret={}\n", aria2_secret); + config_file.write_all(config_content.as_bytes()).expect("failed to write aria2 config file"); + let config_path = config_file.into_temp_path(); + cmd.arg("--enable-rpc=true") + .arg(format!("--conf-path={}", config_path.display())) .arg(format!("--rpc-listen-port={}", aria2_port)) - .arg(format!("--rpc-secret={}", aria2_secret)) .arg("--rpc-listen-all=false") .arg("--continue=true") .arg("--retry-wait=2") @@ -4053,6 +4075,7 @@ pub fn run() { let guard = app.state::(); *guard.child.lock().unwrap() = Some(child); + *guard.config_path.lock().unwrap() = Some(config_path); let port = aria2_port; let secret = aria2_secret.clone(); @@ -4141,7 +4164,8 @@ pub fn run() { } } } - // Connection lost, loop and reconnect + // Connection lost, clear permits and reconnect + app_handle_ws.state::().queue_manager.clear_aria2_permits().await; tokio::time::sleep(std::time::Duration::from_secs(4)).await; } }); diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 42ef407..c99ab92 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -240,8 +240,6 @@ impl QueueManager { true } - /// Release the permit parked under `id`, if any. Idempotent. Wakes the - /// dispatcher so a freed slot is claimed promptly. pub async fn release_permit(&self, id: &str) { let removed = self.active_permits.lock().await.remove(id).is_some(); self.active_kinds.lock().await.remove(id); @@ -250,6 +248,22 @@ impl QueueManager { } } + /// Clear all permits belonging to aria2. Useful when aria2 WS connection drops. + pub async fn clear_aria2_permits(&self) { + let ids_to_fail: Vec = { + let kinds = self.active_kinds.lock().await; + kinds + .iter() + .filter(|(_, kind)| matches!(kind, TaskKind::Aria2)) + .map(|(id, _)| id.clone()) + .collect() + }; + + for id in ids_to_fail { + self.apply_completion(&id, PendingOutcome::Error("Aria2 WebSocket connection lost".to_string())).await; + } + } + /// Number of un-acquired permits currently in the semaphore pool. pub fn available_permits(&self) -> usize { self.semaphore.available_permits() diff --git a/src/App.tsx b/src/App.tsx index 3d0426e..0847602 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -368,6 +368,8 @@ function App() { const allCompleted = scheduledItems.every(item => item?.status === 'completed'); settings.setSchedulerActiveDownloadIds([]); settings.setSchedulerRunning(false); + + let timer: number | undefined; if (!allCompleted) { addToast({ message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.', @@ -395,7 +397,7 @@ function App() { ) }); - window.setTimeout(() => { + timer = window.setTimeout(() => { if (cancelled) return; const activeTransfers = useDownloadStore.getState().downloads.some(download => isActiveDownloadStatus(download.status) @@ -418,6 +420,12 @@ function App() { }); }, 10_000); } + + return () => { + if (timer !== undefined) { + window.clearTimeout(timer); + } + }; }, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]); useEffect(() => { @@ -505,7 +513,7 @@ function App() { }, [theme]); useEffect(() => { - initDownloadListener(); + const unlistenDownload = initDownloadListener(); const unlistenTerminalState = listen('download-state', (event) => { if (event.payload.status !== 'completed' && event.payload.status !== 'failed') return; @@ -561,6 +569,7 @@ function App() { unlistenTerminalState.then(f => f()); unlistenExtension.then(f => f()); unlistenDeepLink.then(f => f()); + unlistenDownload.then(f => { if (f) f(); }); }; }, []); diff --git a/src/components/LogsView.tsx b/src/components/LogsView.tsx index d3a4cc5..bc42915 100644 --- a/src/components/LogsView.tsx +++ b/src/components/LogsView.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { invokeCommand as invoke } from '../ipc'; import { save } from '@tauri-apps/plugin-dialog'; +import { writeTextFile } from '@tauri-apps/plugin-fs'; import { attachLogger, setLogPaused, getLogPaused, initLogger } from '../utils/logger'; import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react'; import { WindowDragRegion } from './WindowDragRegion'; @@ -128,7 +129,8 @@ export default function LogsView() { filters: [{ name: 'Log Files', extensions: ['log'] }], }); if (!path) return; - await invoke('export_logs', { destPath: path }); + const logsContent = await invoke('export_logs', {}); + await writeTextFile(path, logsContent); addToast({ message: 'Support logs exported', variant: 'success' }); } catch (e) { console.error('Export failed:', e); diff --git a/src/ipc.ts b/src/ipc.ts index eb25c16..cc3064c 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -71,7 +71,7 @@ type CommandMap = { args: { baseFolder: string; subfolders: Record }; result: void; }; - export_logs: { args: { destPath: string }; result: string }; + export_logs: { args: Record; result: string }; read_logs: { args: { limit: number }; result: string[] }; clear_logs: { args: undefined; result: void }; toggle_log_pause: { args: { pause: boolean }; result: void }; diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 731681a..150f82f 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -98,4 +98,19 @@ export async function initDownloadListener() { } }); } + + return () => { + if (unlistenProgress) { + unlistenProgress(); + unlistenProgress = null; + } + if (unlistenState) { + unlistenState(); + unlistenState = null; + } + if (unlistenTray) { + unlistenTray(); + unlistenTray = null; + } + }; }