mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user