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"] }
regex = "1.10"
reqwest = { version = "0.12", features = ["json", "stream"] }
uuid = "1"
uuid = { version = "1", features = ["v4"] }
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
tauri-plugin-notification = "2.3.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");
}
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 Some(host) = parsed.host_str() {
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() => {
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);
if let Some(ref user) = username {
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
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
let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap();
@@ -807,17 +815,16 @@ pub(crate) async fn start_media_download_internal(
_ => break,
}
}
status = child.wait() => {
println!("child exit status: {:?}", status);
if let Ok(exit_status) = status {
if exit_status.success() {
let _ = app_handle.emit("download-complete", id.to_string());
} else {
let _ = app_handle.emit("download-failed", id.to_string());
}
}
break;
}
}
}
let status = child.wait().await;
println!("child exit status: {:?}", status);
if let Ok(exit_status) = status {
if exit_status.success() {
let _ = app_handle.emit("download-complete", id.to_string());
} else {
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]
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 current_preventer.is_none() {
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())
.map(|addr| addr.port())
.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()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
restore_main_window(app);
@@ -1280,7 +1287,7 @@ pub fn run() {
.arg("--summary-interval=1")
.arg("--console-log-level=warn")
.arg("--download-result=hide")
.arg("--check-certificate=false")
.arg("--check-certificate=true")
.spawn();
match aria2_process {
+1 -3
View File
@@ -98,9 +98,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
};
const handleResume = (item: DownloadItem) => {
useDownloadStore.setState((state) => ({
downloads: state.downloads.map(d => d.id === item.id ? { ...d, status: 'queued', speed: '-', eta: '-' } : d)
}));
updateDownload(item.id, { status: 'queued', _dispatched: false, speed: '-', eta: '-' });
useDownloadStore.getState().processQueue();
};
+2 -3
View File
@@ -10,7 +10,6 @@ export const PropertiesModal = () => {
const {
selectedPropertiesDownloadId,
setSelectedPropertiesDownloadId,
downloads,
updateDownload
} = useDownloadStore();
@@ -43,7 +42,7 @@ export const PropertiesModal = () => {
useEffect(() => {
if (selectedPropertiesDownloadId) {
const activeItem = downloads.find(d => d.id === selectedPropertiesDownloadId);
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
if (activeItem) {
setItem(activeItem);
setUrl(activeItem.url);
@@ -89,7 +88,7 @@ export const PropertiesModal = () => {
} else {
setItem(null);
}
}, [selectedPropertiesDownloadId, downloads, defaultDownloadPath]);
}, [selectedPropertiesDownloadId, defaultDownloadPath]);
if (!selectedPropertiesDownloadId || !item) return null;
+103 -66
View File
@@ -87,6 +87,8 @@ interface DownloadState {
initDB: () => Promise<void>;
}
let isProcessingQueue = false;
export const useDownloadStore = create<DownloadState>((set, get) => ({
downloads: [],
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);
if (item && item.status === 'downloading') {
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) {
console.error("Failed to terminate download on deletion:", e);
}
} else if (item && deleteFile) {
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) {
console.error("Failed to delete file from disk:", e);
}
@@ -218,6 +222,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
: 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();
return runnableIds.length;
},
@@ -235,6 +247,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
: 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(() => {})));
syncSystemIntegrations();
return activeIds.length;
@@ -265,6 +285,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
removeQueue: (id) => {
if (id === MAIN_QUEUE_ID) return;
const affectedDownloads = get().downloads.filter(d => d.queueId === id);
set((state) => ({
queues: state.queues.filter(q => q.id !== id),
downloads: state.downloads.map(d =>
@@ -274,8 +297,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
invoke('db_delete_queue', { id }).catch(console.error);
// Also we need to save the updated downloads to DB
const downloads = get().downloads.filter(d => d.queueId === id);
downloads.forEach(d => {
affectedDownloads.forEach(d => {
const toSave = { ...d, queueId: MAIN_QUEUE_ID };
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);
@@ -296,6 +318,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
// Auto resume downloads that were active
const active = get().downloads.filter(d => d.status === 'downloading');
const settings = useSettingsStore.getState();
active.forEach(item => {
if (item.isMedia) {
invoke('start_media_download', {
@@ -305,7 +328,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
filename: item.fileName,
formatSelector: item.mediaFormatSelector || null,
cookieSource: null,
speedLimit: item.speedLimit || null,
speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || null,
password: item.password || null,
headers: item.headers || null,
@@ -320,7 +343,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
destination: item.destination || '~/Downloads',
filename: item.fileName,
connections: item.connections ?? null,
speedLimit: item.speedLimit || null,
speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
username: item.username || null,
password: item.password || null,
headers: item.headers || null,
@@ -340,70 +363,84 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
},
processQueue: async () => {
const { downloads, updateDownload } = get();
if (isProcessingQueue) return;
isProcessingQueue = true;
// Find all queued items that haven't been dispatched to the backend yet
const itemsToStart = downloads.filter(d => d.status === 'queued' && !d._dispatched);
for (const item of itemsToStart) {
// Mark as dispatched so we don't send it again on the next pass
updateDownload(item.id, { _dispatched: true });
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';
try {
const { downloads, updateDownload } = get();
const settings = useSettingsStore.getState();
const concurrentLimit = settings.maxConcurrentDownloads || 3;
const activeCount = downloads.filter(d => d.status === 'downloading').length;
let availableSlots = concurrentLimit - activeCount;
if (item.isMedia) {
await invoke('start_media_download', {
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
formatSelector: item.mediaFormatSelector || null,
cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
speedLimit: item.speedLimit || 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 || 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)
});
if (availableSlots <= 0) return;
const itemsToStart = downloads.filter(d => d.status === 'queued' && !d._dispatched);
for (const item of itemsToStart) {
if (availableSlots <= 0) break;
availableSlots--;
// Mark as dispatched so we don't send it again on the next pass
updateDownload(item.id, { _dispatched: true });
try {
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) {
await invoke('start_media_download', {
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
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 }),
setMaxConcurrentDownloads: (max) => {
set({ maxConcurrentDownloads: max });
invoke('set_concurrent_limit', { limit: max }).catch(console.error);
},
setGlobalSpeedLimit: (limit) => {
set({ globalSpeedLimit: limit });
invoke('set_global_speed_limit', { limit: limit === '' || limit === '0' ? null : limit }).catch(console.error);
},
setActiveView: (view) => set({ activeView: view }),
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),