fix: resolve 7 critical security vulnerabilities from audit

This commit is contained in:
NimBold
2026-06-24 23:36:21 +03:30
parent 1a687d0081
commit b147ec5f78
8 changed files with 110 additions and 92 deletions
+10 -57
View File
@@ -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": [
+1
View File
@@ -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",
+53 -29
View File
@@ -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<Option<(String, std::net::SocketAddr)>, 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<String>, username: Option<String>, password: Option<String>) -> Result<MetadataResponse, String> {
let mut current_url = url.clone();
@@ -740,26 +773,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 mut resolved_addr = None;
if let Ok(parsed) = reqwest::Url::parse(&current_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(&current_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<String>,
password: Option<String>,
) -> Result<MediaMetadata, String> {
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<Option<std::process::Child>>,
startup_error: Mutex<Option<String>>,
last_stderr: Mutex<String>,
config_path: Mutex<Option<tempfile::TempPath>>,
}
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<String, String> {
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::<Aria2DaemonGuard>();
*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::<AppState>().queue_manager.clear_aria2_permits().await;
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
}
});
+16 -2
View File
@@ -240,8 +240,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
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<R: tauri::Runtime> QueueManager<R> {
}
}
/// Clear all permits belonging to aria2. Useful when aria2 WS connection drops.
pub async fn clear_aria2_permits(&self) {
let ids_to_fail: Vec<String> = {
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()
+11 -2
View File
@@ -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() {
</div>
)
});
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(); });
};
}, []);
+3 -1
View File
@@ -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);
+1 -1
View File
@@ -71,7 +71,7 @@ type CommandMap = {
args: { baseFolder: string; subfolders: Record<string, string> };
result: void;
};
export_logs: { args: { destPath: string }; result: string };
export_logs: { args: Record<string, never>; result: string };
read_logs: { args: { limit: number }; result: string[] };
clear_logs: { args: undefined; result: void };
toggle_log_pause: { args: { pause: boolean }; result: void };
+15
View File
@@ -98,4 +98,19 @@ export async function initDownloadListener() {
}
});
}
return () => {
if (unlistenProgress) {
unlistenProgress();
unlistenProgress = null;
}
if (unlistenState) {
unlistenState();
unlistenState = null;
}
if (unlistenTray) {
unlistenTray();
unlistenTray = null;
}
};
}