From 886388d5f207ccd03d2820436e8617b8a242c172 Mon Sep 17 00:00:00 2001 From: NimBold Date: Tue, 21 Jul 2026 08:39:54 +0330 Subject: [PATCH] fix: harden audited download and release paths --- .github/workflows/release.yml | 10 ++- src-tauri/src/extension_server.rs | 16 +++- src-tauri/src/lib.rs | 9 ++- src-tauri/src/queue.rs | 16 +++- src-tauri/src/scheduler.rs | 107 ++++++++++++++++++++++++--- src/components/AddDownloadsModal.tsx | 7 +- src/components/DownloadItem.tsx | 4 +- src/components/DownloadTable.tsx | 68 +++++++++++++---- src/components/PropertiesModal.tsx | 2 +- src/components/SchedulerView.tsx | 2 +- src/components/SettingsView.tsx | 2 +- src/index.css | 10 ++- src/store/useDownloadStore.test.ts | 2 +- src/store/useDownloadStore.ts | 15 +++- 14 files changed, 219 insertions(+), 51 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d573e1..df3890b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,7 +123,8 @@ jobs: if (-not $installer) { throw "Windows NSIS installer artifact was not produced." } $extractRoot = "$env:RUNNER_TEMP/firelink-installer" Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue - 7z x $installer.FullName "-o$extractRoot" -y + & 7z x $installer.FullName "-o$extractRoot" -y + if ($LASTEXITCODE -ne 0) { throw "7z failed to extract the Windows installer payload (exit code $LASTEXITCODE)." } node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }} $portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload" @@ -249,11 +250,12 @@ jobs: local pattern="$1" local destination="$2" local source - source="$(find release-assets -maxdepth 1 -type f -name "$pattern" -print -quit)" - if [[ -z "$source" ]]; then - echo "::error::Missing release asset matching $pattern" + mapfile -d '' -t matches < <(find release-assets -maxdepth 1 -type f -name "$pattern" -print0) + if (( ${#matches[@]} != 1 )); then + echo "::error::Expected exactly one release asset matching $pattern, found ${#matches[@]}" exit 1 fi + source="${matches[0]}" mv "$source" "release-assets/$destination" } diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 5940726..acaf8ca 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -443,7 +443,7 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option 1); let cookie_scopes = if !payload.media && urls.len() == 1 { @@ -548,7 +548,17 @@ fn normalize_headers(headers: Option, media: bool) -> Option { .lines() .filter(|line| { line.split_once(':') - .map(|(name, _)| !name.trim().eq_ignore_ascii_case("cookie")) + .map(|(name, _)| { + !matches!( + name.trim().to_ascii_lowercase().as_str(), + "authorization" + | "cookie" + | "cookie2" + | "proxy-authorization" + | "set-cookie" + | "set-cookie2" + ) + }) .unwrap_or(true) }) .collect::>() @@ -881,7 +891,7 @@ mod tests { silent: false, filename: None, headers: Some(format!( - "Cookie: stale={};\nUser-Agent: Firefox", + "Cookie: stale={};\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox", "x".repeat(64 * 1024) )), cookies: Some(format!("large={}", "x".repeat(64 * 1024))), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da9fb8a..31c2b7d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1912,6 +1912,8 @@ async fn fetch_metadata( let mut current_res = match head_req.send().await { Ok(response) => response, Err(head_error) => build_get_range().send().await.map_err(|get_error| { + let head_error = crate::redact_sensitive_text(&head_error.to_string()); + let get_error = crate::redact_sensitive_text(&get_error.to_string()); format!( "HEAD metadata request failed ({head_error}); ranged GET fallback failed ({get_error})" ) @@ -2815,6 +2817,9 @@ fn approve_download_root(app_handle: tauri::AppHandle, path: String) -> Result QueueManager { } fn automatic_retry_limit(max_tries: Option) -> usize { - max_tries.unwrap_or(0).max(0) as usize + max_tries.unwrap_or(MAX_RETRIES as i32).max(0) as usize } fn aria2_attempt_limit(max_tries: Option) -> u32 { @@ -1977,8 +1977,9 @@ impl SidecarSpawner for ProductionSpawner { } } Err(e) => { - log::error!("aria2 addUri [{}] failed: {}", id, e); - Err(format!("aria2 addUri failed: {e}")) + let safe_error = crate::redact_sensitive_text(&e); + log::error!("aria2 addUri [{}] failed: {}", id, safe_error); + Err(format!("aria2 addUri failed: {safe_error}")) } } } @@ -2288,4 +2289,11 @@ mod tests { assert_eq!(aria2_attempt_limit(Some(0)), 1); assert_eq!(aria2_attempt_limit(Some(10)), 1); } + + #[test] + fn omitted_retry_limit_uses_the_shared_default_but_zero_stays_explicit() { + assert_eq!(automatic_retry_limit(None), MAX_RETRIES); + assert_eq!(automatic_retry_limit(Some(0)), 0); + assert_eq!(automatic_retry_limit(Some(2)), 2); + } } diff --git a/src-tauri/src/scheduler.rs b/src-tauri/src/scheduler.rs index a1d6fe8..e7a46cc 100644 --- a/src-tauri/src/scheduler.rs +++ b/src-tauri/src/scheduler.rs @@ -26,6 +26,26 @@ fn stop_is_due( && last_stop_key != stop_key } +fn overnight_stop_is_due( + stop_time_enabled: bool, + start_minute: Option, + stop_minute: Option, + current_minute: u32, + previous_day_allowed: bool, + last_start_key: &str, + previous_start_key: &str, + last_stop_key: &str, + stop_key: &str, +) -> bool { + stop_time_enabled + && previous_day_allowed + && start_minute.zip(stop_minute).is_some_and(|(start, stop)| { + stop < start && current_minute >= stop && current_minute < start + }) + && last_start_key == previous_start_key + && last_stop_key != stop_key +} + pub fn spawn_scheduler( app_handle: tauri::AppHandle, settings_cache: Arc>>, @@ -56,20 +76,20 @@ pub fn spawn_scheduler( let allowed_today = scheduler.everyday || scheduler.selected_days.contains(¤t_day); - if !allowed_today { - continue; - } - let date_key = now.format("%Y-%m-%d").to_string(); let start_key = format!("{date_key}-start"); let stop_key = format!("{date_key}-stop"); let start_minute = minute_of_day(&scheduler.start_time); let stop_minute = minute_of_day(&scheduler.stop_time); + let overnight = start_minute + .zip(stop_minute) + .is_some_and(|(start, stop)| stop < start); let before_stop = !scheduler.stop_time_enabled || stop_minute.is_some_and(|stop| current_minute < stop); - if start_minute.is_some_and(|start| current_minute >= start) - && before_stop + if allowed_today + && start_minute.is_some_and(|start| current_minute >= start) + && (overnight || before_stop) && scheduler_last_start_key != start_key && last_emit .get("start") @@ -85,17 +105,43 @@ pub fn spawn_scheduler( last_emit.insert("start", std::time::Instant::now()); } - if stop_is_due( + let same_day_stop_due = allowed_today + && !overnight + && stop_is_due( + scheduler.stop_time_enabled, + stop_minute, + current_minute, + &scheduler_last_start_key, + &start_key, + &scheduler_last_stop_key, + &stop_key, + ); + let previous_day = now.date_naive().pred_opt(); + let previous_day_allowed = previous_day.is_some_and(|day| { + scheduler.everyday + || scheduler + .selected_days + .contains(&day.weekday().num_days_from_sunday()) + }); + let previous_start_key = previous_day + .map(|day| format!("{}-start", day.format("%Y-%m-%d"))) + .unwrap_or_default(); + let overnight_stop_due = overnight_stop_is_due( scheduler.stop_time_enabled, + start_minute, stop_minute, current_minute, + previous_day_allowed, &scheduler_last_start_key, - &start_key, + &previous_start_key, &scheduler_last_stop_key, &stop_key, - ) && last_emit - .get("stop") - .is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5)) + ); + + if (same_day_stop_due || overnight_stop_due) + && last_emit + .get("stop") + .is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5)) { let _ = app_handle.emit( "schedule-trigger", @@ -113,7 +159,7 @@ pub fn spawn_scheduler( #[cfg(test)] mod tests { - use super::{minute_of_day, stop_is_due}; + use super::{minute_of_day, overnight_stop_is_due, stop_is_due}; #[test] fn parses_valid_scheduler_times() { @@ -150,4 +196,41 @@ mod tests { "2026-06-22-stop", )); } + + #[test] + fn overnight_stop_uses_the_previous_day_start() { + assert!(overnight_stop_is_due( + true, + Some(1320), + Some(360), + 420, + true, + "2026-06-22-start", + "2026-06-22-start", + "", + "2026-06-23-stop", + )); + assert!(!overnight_stop_is_due( + true, + Some(1320), + Some(360), + 1380, + true, + "2026-06-22-start", + "2026-06-22-start", + "", + "2026-06-22-stop", + )); + assert!(!overnight_stop_is_due( + true, + Some(1320), + Some(360), + 420, + false, + "2026-06-22-start", + "2026-06-22-start", + "", + "2026-06-23-stop", + )); + } } diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 0cda2b3..c1b87d3 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -230,11 +230,11 @@ export const AddDownloadsModal = () => { const closeModalFromDismissAction = useCallback(() => { if (isSubmitting || isSubmittingRef.current || showKeychainModal) return; const hasPendingInput = Boolean( - urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim() + urls.trim() || pendingAddUrls.trim() || parsedItems.some(item => item.selected !== false) || headers.trim() || cookies.trim() ); if (hasPendingInput && !window.confirm(t($ => $.addDownloads.discardSetup))) return; toggleAddModal(false); - }, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, showKeychainModal, toggleAddModal, urls]); + }, [cookies, headers, isSubmitting, parsedItems, pendingAddUrls, showKeychainModal, toggleAddModal, urls]); useEffect(() => { if (!isAddModalOpen) { @@ -1802,13 +1802,14 @@ export const AddDownloadsModal = () => {
{ cookiesManuallyEditedRef.current = true; setCookies(e.target.value); }} placeholder={t($ => $.addDownloads.cookiePlaceholder)} + autoComplete="off" className="add-download-control w-full px-3 py-1.5 text-xs font-mono" aria-label={t($ => $.addDownloads.cookies)} /> diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index fabb6c3..8ead9e4 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -16,6 +16,7 @@ interface DownloadItemProps { queueIndex: number; queueLength: number; tableGridTemplate: string; + tableMinWidth: number; setContextMenu: (menu: { x: number; y: number; id: string }) => void; handlePause: (id: string, skipConfirm?: boolean) => void; handleResume: (item: DownloadItemType) => void; @@ -31,6 +32,7 @@ export const DownloadItem = React.memo(({ queueIndex, queueLength, tableGridTemplate, + tableMinWidth, setContextMenu, handlePause, handleResume, @@ -84,7 +86,7 @@ export const DownloadItem = React.memo(({ return (
onClick(e, download)} onContextMenu={(e) => { e.preventDefault(); diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index f45bc23..a172cfb 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -30,8 +30,28 @@ interface DownloadTableProps { } const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170]; +const COLUMN_MINIMUMS = [0, 58, 92, 58, 48, 112]; const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths'; +const normalizeColumnWidths = (value: unknown): number[] => { + if (!Array.isArray(value) || value.length !== DEFAULT_COLUMN_WIDTHS.length) { + return DEFAULT_COLUMN_WIDTHS; + } + return value.map((width, index) => + typeof width === 'number' && Number.isFinite(width) + ? Math.max(COLUMN_MINIMUMS[index], width) + : DEFAULT_COLUMN_WIDTHS[index] + ); +}; + +const persistColumnWidths = (widths: number[]): void => { + try { + window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(widths)); + } catch { + // Local storage can be unavailable in restricted WebView contexts. + } +}; + export const DownloadTable: React.FC = ({ filter }) => { const { t } = useTranslation(); const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore(); @@ -54,6 +74,7 @@ export const DownloadTable: React.FC = ({ filter }) => { const [lastSelectedId, setLastSelectedId] = useState(null); const [sortConfig, setSortConfig] = useState({ column: 'Date Added', direction: 'desc' }); const [queueSortConfig, setQueueSortConfig] = useState(null); + const resizeCleanupRef = useRef<(() => void) | null>(null); const selectedIdsRef = useRef(selectedIds); const lastSelectedIdRef = useRef(lastSelectedId); const sortedDownloadsRef = useRef([]); @@ -62,38 +83,56 @@ export const DownloadTable: React.FC = ({ filter }) => { const [columnWidths, setColumnWidths] = useState(() => { try { const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null'); - return Array.isArray(stored) && - stored.length === DEFAULT_COLUMN_WIDTHS.length && - stored.every(value => typeof value === 'number' && Number.isFinite(value)) - ? stored - : DEFAULT_COLUMN_WIDTHS; + return normalizeColumnWidths(stored); } catch { return DEFAULT_COLUMN_WIDTHS; } }); - const columnMinimums = [0, 58, 92, 58, 48, 112]; - const tableGridTemplate = columnWidths.map((width, index) => `minmax(${columnMinimums[index]}px, ${width}fr)`).join(' '); + const columnWidthsRef = useRef(columnWidths); + columnWidthsRef.current = columnWidths; + const normalizedColumnWidths = columnWidths.map((width, index) => + Math.max(COLUMN_MINIMUMS[index], width) + ); + const tableGridTemplate = [ + ...normalizedColumnWidths.slice(0, -1).map(width => `${width}px`), + 'minmax(0, 1fr)', + `${normalizedColumnWidths[normalizedColumnWidths.length - 1]}px` + ].join(' '); + const tableMinWidth = normalizedColumnWidths.reduce( + (total, width) => total + width, + 0 + ); const startColumnResize = (index: number, event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); + resizeCleanupRef.current?.(); const startX = event.clientX; const startWidth = columnWidths[index]; const handlePointerMove = (moveEvent: PointerEvent) => { - const nextWidth = Math.max(columnMinimums[index], startWidth + moveEvent.clientX - startX); - setColumnWidths(widths => widths.map((width, columnIndex) => columnIndex === index ? nextWidth : width)); + const nextWidth = Math.max(COLUMN_MINIMUMS[index], startWidth + moveEvent.clientX - startX); + setColumnWidths(widths => { + const nextWidths = widths.map((width, columnIndex) => columnIndex === index ? nextWidth : width); + columnWidthsRef.current = nextWidths; + return nextWidths; + }); }; const handlePointerUp = () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); + persistColumnWidths(columnWidthsRef.current); document.body.classList.remove('is-resizing'); + resizeCleanupRef.current = null; }; + resizeCleanupRef.current = handlePointerUp; document.body.classList.add('is-resizing'); window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerUp); }; useEffect(() => { @@ -109,9 +148,9 @@ export const DownloadTable: React.FC = ({ filter }) => { }; }, []); - useEffect(() => { - window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(columnWidths)); - }, [columnWidths]); + useEffect(() => () => { + resizeCleanupRef.current?.(); + }, []); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -539,7 +578,7 @@ export const DownloadTable: React.FC = ({ filter }) => {
-
+
{[ { key: 'File Name' as const, label: t($ => $.downloadTable.headers.fileName) }, { key: 'Size' as const, label: t($ => $.downloadTable.headers.size) }, @@ -569,7 +608,7 @@ export const DownloadTable: React.FC = ({ filter }) => { ))}
-
+
{sortedDownloads.length === 0 ? (
@@ -609,6 +648,7 @@ export const DownloadTable: React.FC = ({ filter }) => { queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1} queueLength={queuePositionsByDownloadId.get(d.id)?.length ?? 0} tableGridTemplate={tableGridTemplate} + tableMinWidth={tableMinWidth} setContextMenu={handleContextMenu} handlePause={handlePause} handleResume={handleResume} diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 9ee9a07..8240fa6 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -453,7 +453,7 @@ export const PropertiesModal = () => { )} - setCookies(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.cookies)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" /> + setCookies(e.target.value)} disabled={transferLocked} autoComplete="off" placeholder={t($ => $.properties.cookies)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
diff --git a/src/components/SchedulerView.tsx b/src/components/SchedulerView.tsx index 1153fd7..01c73be 100644 --- a/src/components/SchedulerView.tsx +++ b/src/components/SchedulerView.tsx @@ -130,7 +130,7 @@ export default function SchedulerView() { addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true }); return; } - if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) { + if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) === minuteOfDay(draft.startTime)) { addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true }); return; } diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index c7febf1..7b4c604 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -562,7 +562,6 @@ runEngineChecks(false); }); if (base && typeof base === 'string') { const approvedBase = await settings.approveDownloadRoot(base); - settings.setBaseDownloadFolder(approvedBase); try { if (settings.categorySubfoldersEnabled) { const safeSubfolders = Object.fromEntries( @@ -586,6 +585,7 @@ runEngineChecks(false); showToast(t($ => $.settings.locations.baseFolderCreateFailed, { detail: String(e) }), 'warning'); return; } + settings.setBaseDownloadFolder(approvedBase); showToast(t($ => $.settings.locations.baseFolderUpdated), 'success'); } } catch (e) { diff --git a/src/index.css b/src/index.css index 6c77f79..47e3d8d 100644 --- a/src/index.css +++ b/src/index.css @@ -1902,7 +1902,7 @@ html[data-list-density="relaxed"] { flex-direction: column; min-height: 0; min-width: 0; - overflow-x: hidden; + overflow-x: auto; overflow-y: hidden; } @@ -1935,6 +1935,11 @@ html[data-list-density="relaxed"] { padding-inline-end: 0; } +.download-table-header > div:last-child, +.download-row > div:last-child { + grid-column: 7; +} + .download-table-header > div > span { min-width: 0; overflow: hidden; @@ -1991,7 +1996,8 @@ body.is-resizing .column-resize-handle:hover::after { .download-table-list { height: 100%; min-height: 0; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; display: flex; flex-direction: column; } diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 44efe55..e9479f3 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -1879,7 +1879,7 @@ describe('useDownloadStore', () => { referer: 'https://adult.example/watch/123', silent: false, filename: null, - headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nUser-Agent: Firefox Test`, + headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox Test`, cookies: `oversized=${'x'.repeat(64 * 1024)}`, cookie_scopes: null, media: true, diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index d6ec350..cbebc09 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -209,12 +209,21 @@ export class SystemProxyResolutionError extends Error { const isSystemProxyConfigurationError = (error: unknown): boolean => error instanceof SystemProxyResolutionError; -const stripCookieHeaders = (value: string | null | undefined): string => +const stripSensitiveMediaHeaders = (value: string | null | undefined): string => (value || '') .split(/\r?\n/) .filter(line => { const separator = line.indexOf(':'); - return separator < 0 || line.slice(0, separator).trim().toLowerCase() !== 'cookie'; + if (separator < 0) return true; + const name = line.slice(0, separator).trim().toLowerCase(); + return ![ + 'authorization', + 'cookie', + 'cookie2', + 'proxy-authorization', + 'set-cookie', + 'set-cookie2' + ].includes(name); }) .join('\n') .trim(); @@ -1035,7 +1044,7 @@ export const useDownloadStore = create((set, get) => { // extension builds; ordinary captured downloads retain their cookies. const cookies = request.media === true ? null : request.cookies; const headers = request.media === true - ? stripCookieHeaders(request.headers) || null + ? stripSensitiveMediaHeaders(request.headers) || null : request.headers; get().openAddModalWithUrls(