From f5bdd88e5c1a25704a67a677946f6bcbf3e1ec2e Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 19 Aug 2026 18:25:54 +0330 Subject: [PATCH] fix(downloads): recover degraded connections and restart state - recover persistent Aria2 connection-pool collapse without restarting stable or rate-limited pools - preserve sanitized browser context while failing closed on credential-bearing and malformed restart state - fence resume paths and harden portable and Torrent persistence with regression coverage --- src-tauri/src/db.rs | 291 ++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 291 ++++++++++++++++++++++++++++- src/store/useDownloadStore.test.ts | 2 + src/store/useDownloadStore.ts | 18 +- src/utils/downloads.test.ts | 51 +++++ src/utils/downloads.ts | 170 ++++++++++++++++- 6 files changed, 809 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index b8807d2..f2b7142 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1049,11 +1049,16 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { // These values are accepted from users, browser extensions, or URLs and // may contain credentials or bearer tokens. Portable queues keep their // useful metadata, but never persist these values beside the executable. - let mut removed_transfer_context = false; - for key in ["password", "cookies", "headers", "mirrors", "proxy"] { + // Safe, stable browser context is sanitized in place so a normal captured + // download does not become unresumable merely because it has a Referer or + // User-Agent. Unknown and credential-bearing headers still fail closed. + let is_torrent = object.get("isTorrent").and_then(Value::as_bool) == Some(true); + let mut removed_transfer_context = sanitize_portable_request_headers(object); + for key in ["password", "cookies", "mirrors", "proxy"] { if object .get(key) .is_some_and(|value| !value.is_null() && !value_is_empty(value)) + && !(is_torrent && matches!(key, "password" | "cookies")) { removed_transfer_context = true; } @@ -1127,6 +1132,190 @@ fn remove_persisted_transfer_secrets(value: &mut Value) { } } +const PORTABLE_NON_CREDENTIAL_REQUEST_HEADERS: &[&str] = &[ + "accept", + "accept-charset", + "accept-encoding", + "accept-language", + "cache-control", + "connection", + "dnt", + "host", + "if-match", + "if-modified-since", + "if-none-match", + "if-range", + "if-unmodified-since", + "origin", + "pragma", + "priority", + "range", + "referer", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "sec-fetch-user", + "sec-gpc", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "user-agent", + "via", + "warning", +]; + +const PORTABLE_PERSISTABLE_REQUEST_HEADERS: &[&str] = &[ + "accept", + "accept-charset", + "accept-encoding", + "accept-language", + "cache-control", + "dnt", + "origin", + "pragma", + "priority", + "referer", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "sec-fetch-user", + "sec-gpc", + "user-agent", +]; + +fn portable_header_is_known_non_credential(name: &str) -> bool { + PORTABLE_NON_CREDENTIAL_REQUEST_HEADERS + .iter() + .any(|candidate| *candidate == name) +} + +fn portable_header_is_persistable(name: &str) -> bool { + PORTABLE_PERSISTABLE_REQUEST_HEADERS + .iter() + .any(|candidate| *candidate == name) +} + +fn sanitize_portable_request_header_value(name: &str, value: &str) -> Option { + if value.chars().any(char::is_control) { + return None; + } + + match name { + "referer" => { + let mut parsed = url::Url::parse(value).ok()?; + if !matches!(parsed.scheme(), "http" | "https") { + return None; + } + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + parsed.set_query(None); + parsed.set_fragment(None); + Some(parsed.to_string()) + } + "origin" => { + let parsed = url::Url::parse(value).ok()?; + matches!(parsed.scheme(), "http" | "https") + .then(|| parsed.origin().ascii_serialization()) + } + _ => Some(value.to_string()), + } +} + +fn portable_request_header_requires_recovery(name: &str, value: &str) -> bool { + if value.trim().is_empty() { + return false; + } + if value.chars().any(char::is_control) { + return true; + } + + if name == "referer" || name == "origin" { + let Ok(parsed) = url::Url::parse(value) else { + return true; + }; + if !matches!(parsed.scheme(), "http" | "https") { + return true; + } + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return true; + } + return name == "origin" && !parsed.path().is_empty() && parsed.path() != "/"; + } + + false +} + +/// Remove unsafe request headers from portable persistence while retaining +/// sanitized, stable browser context. The boolean reports whether the input +/// contained credential-bearing, unknown, or malformed header context that +/// makes automatic restart unsafe. Torrent browser context is metadata-only: +/// it is removed without making a cached-metadata Torrent unresumable. +fn sanitize_portable_request_headers(object: &mut serde_json::Map) -> bool { + let Some(raw_value) = object.get("headers").cloned() else { + return false; + }; + if raw_value.is_null() || value_is_empty(&raw_value) { + object.remove("headers"); + return false; + } + + let Some(raw_headers) = raw_value.as_str() else { + object.remove("headers"); + return true; + }; + + if object.get("isTorrent").and_then(Value::as_bool) == Some(true) { + object.remove("headers"); + return false; + } + + let mut retained = Vec::new(); + let mut removed_context = false; + for line in raw_headers.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Some((raw_name, raw_value)) = trimmed.split_once(':') else { + removed_context = true; + continue; + }; + let name = raw_name.trim().to_ascii_lowercase(); + if !portable_header_is_known_non_credential(&name) { + removed_context = true; + continue; + } + if portable_request_header_requires_recovery(&name, raw_value.trim()) { + removed_context = true; + } + let Some(sanitized_value) = sanitize_portable_request_header_value(&name, raw_value.trim()) + else { + continue; + }; + if portable_header_is_persistable(&name) { + retained.push(format!("{}: {sanitized_value}", raw_name.trim())); + } + } + + if retained.is_empty() { + object.remove("headers"); + } else { + object.insert("headers".to_string(), Value::String(retained.join("\n"))); + } + removed_context +} + fn sanitize_portable_torrent_tracker_field( object: &mut serde_json::Map, key: &str, @@ -2383,6 +2572,104 @@ mod tests { assert_eq!(saved["torrentExcludeTrackers"], "https://tracker.example/exclude"); } + #[test] + fn portable_download_persistence_keeps_sanitized_safe_browser_context() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + let data = json!([{ + "id": "download-safe-headers", + "status": "queued", + "queueId": "main", + "url": "https://example.com/file", + "headers": "Referer: https://example.com/page\nUser-Agent: Firelink-Test" + }]) + .to_string(); + + replace_downloads(&mut connection, &data, true).unwrap(); + + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert_eq!(saved["status"], "queued"); + assert_ne!(saved.get("resumable"), Some(&Value::Bool(false))); + assert_eq!( + saved["headers"], + "Referer: https://example.com/page\nUser-Agent: Firelink-Test" + ); + } + + #[test] + fn portable_download_persistence_rejects_sensitive_referer_context() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + let data = json!([{ + "id": "download-sensitive-referer", + "status": "queued", + "queueId": "main", + "url": "https://example.com/file", + "headers": "Referer: https://example.com/page?token=secret#fragment" + }]) + .to_string(); + + replace_downloads(&mut connection, &data, true).unwrap(); + + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert_eq!(saved["status"], "failed"); + assert_eq!(saved["resumable"], false); + assert_eq!(saved["headers"], "Referer: https://example.com/page"); + assert!(!saved.to_string().contains("secret")); + } + + #[test] + fn portable_download_persistence_rejects_unknown_header_context() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + let data = json!([{ + "id": "download-unknown-header", + "status": "queued", + "queueId": "main", + "url": "https://example.com/file", + "headers": "X-Request-Signature:" + }]) + .to_string(); + + replace_downloads(&mut connection, &data, true).unwrap(); + + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert_eq!(saved["status"], "failed"); + assert_eq!(saved["resumable"], false); + assert!(saved.get("headers").is_none()); + } + + #[test] + fn portable_torrent_persistence_strips_metadata_credentials_without_blocking_restart() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let mut connection = state.lock().unwrap(); + let data = json!([{ + "id": "torrent-browser-context", + "status": "queued", + "queueId": "main", + "isTorrent": true, + "url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", + "password": "metadata-only-secret", + "cookies": "session=metadata-only", + "headers": "User-Agent: Browser" + }]) + .to_string(); + + replace_downloads(&mut connection, &data, true).unwrap(); + + let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); + assert_eq!(saved["status"], "queued"); + assert_ne!(saved.get("resumable"), Some(&Value::Bool(false))); + assert!(saved.get("password").is_none()); + assert!(saved.get("cookies").is_none()); + assert!(saved.get("headers").is_none()); + assert!(!saved.to_string().contains("metadata-only-secret")); + } + #[test] fn download_state_commit_is_atomic_across_downloads_and_queues() { let temp = TempDir::new().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e620bf6..dec2933 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12641,6 +12641,274 @@ mod tests { ); } + #[test] + fn partial_connection_pool_collapse_recovers_when_throughput_stays_healthy() { + let start = Instant::now(); + let mut observation = Aria2ConnectionObservation::default(); + + for offset in 0..3 { + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + (10 + offset) * 1024 * 1024, + 2.2 * 1024.0 * 1024.0, + 16, + 16, + false, + start + Duration::from_secs(offset), + ); + } + + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + 13 * 1024 * 1024, + 1.4 * 1024.0 * 1024.0, + 12, + 16, + false, + start + Duration::from_secs(31), + ), + None, + "the first sustained underfill sample starts the recovery timer" + ); + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + 14 * 1024 * 1024, + 1.4 * 1024.0 * 1024.0, + 12, + 16, + false, + start + Duration::from_secs(62), + ), + Some(Aria2RecoveryReason::ConnectionPoolCollapse), + "a persistent 12/16 pool must recover even when remaining throughput is above half of peak" + ); + } + + #[test] + fn stable_underfilled_connection_pool_does_not_trigger_recovery() { + let start = Instant::now(); + let mut observation = Aria2ConnectionObservation::default(); + + for offset in 0..3 { + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + (10 + offset) * 1024 * 1024, + 2.2 * 1024.0 * 1024.0, + 16, + 16, + false, + start + Duration::from_secs(offset), + ); + } + + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + 13 * 1024 * 1024, + 2.2 * 1024.0 * 1024.0, + 12, + 16, + false, + start + Duration::from_secs(31), + ), + None + ); + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + 14 * 1024 * 1024, + 2.2 * 1024.0 * 1024.0, + 12, + 16, + false, + start + Duration::from_secs(62), + ), + None, + "a stable 12/16 pool at the healthy rate is not a collapse" + ); + assert_eq!(observation.recovery_attempts, 0); + } + + #[test] + fn effective_connection_reduction_does_not_trigger_partial_recovery() { + let start = Instant::now(); + let mut observation = Aria2ConnectionObservation::default(); + + for offset in 0..3 { + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + (10 + offset) * 1024 * 1024, + 2.2 * 1024.0 * 1024.0, + 16, + 16, + false, + start + Duration::from_secs(offset), + ); + } + + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + 13 * 1024 * 1024, + 1.7 * 1024.0 * 1024.0, + 4, + 4, + false, + start + Duration::from_secs(31), + ), + None + ); + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + 14 * 1024 * 1024, + 1.7 * 1024.0 * 1024.0, + 4, + 4, + false, + start + Duration::from_secs(62), + ), + None, + "a reduced effective pool must not be compared with its stale larger baseline" + ); + assert_eq!(observation.recovery_attempts, 0); + } + + #[test] + fn one_lost_connection_in_a_small_pool_does_not_trigger_partial_recovery() { + let start = Instant::now(); + let mut observation = Aria2ConnectionObservation::default(); + + for offset in 0..3 { + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + (10 + offset) * 1024 * 1024, + 2.2 * 1024.0 * 1024.0, + 4, + 4, + false, + start + Duration::from_secs(offset), + ); + } + + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + 13 * 1024 * 1024, + 1.7 * 1024.0 * 1024.0, + 3, + 4, + false, + start + Duration::from_secs(31), + ), + None + ); + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + 14 * 1024 * 1024, + 1.7 * 1024.0 * 1024.0, + 3, + 4, + false, + start + Duration::from_secs(62), + ), + None, + "a single lost connection in a small pool is not enough evidence of collapse" + ); + } + + #[test] + fn one_connection_after_a_healthy_pool_recovers_even_at_full_rate() { + let start = Instant::now(); + let mut observation = Aria2ConnectionObservation::default(); + + for offset in 0..3 { + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + (10 + offset) * 1024 * 1024, + 2.2 * 1024.0 * 1024.0, + 16, + 16, + false, + start + Duration::from_secs(offset), + ); + } + + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + 13 * 1024 * 1024, + 2.2 * 1024.0 * 1024.0, + 1, + 16, + false, + start + Duration::from_secs(31), + ), + None + ); + assert_eq!( + observe_aria2_connections( + &mut observation, + "gid-1", + "active", + 8 * 1024 * 1024 * 1024, + 14 * 1024 * 1024, + 2.2 * 1024.0 * 1024.0, + 1, + 16, + false, + start + Duration::from_secs(62), + ), + Some(Aria2RecoveryReason::ConnectionPoolCollapse), + "a collapse to one connection must not depend on throughput falling first" + ); + } + #[test] fn partial_connection_pool_collapse_stays_quiet_while_speed_limited() { let start = Instant::now(); @@ -14357,6 +14625,7 @@ struct Aria2ConnectionObservation { no_progress_since: Option, last_refreshed_at: Option, peak_speed_bytes: f64, + peak_active_connections: i32, last_completed: u64, last_logged_active_connections: Option, last_connection_logged_at: Option, @@ -14402,6 +14671,8 @@ const ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY: u64 = 1024 * 1024; const ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY: f64 = 64.0 * 1024.0; const ARIA2_DEGRADED_SPEED_FRACTION: f64 = 0.20; const ARIA2_CONNECTION_POOL_DEGRADED_FRACTION: f64 = 0.75; +const ARIA2_CONNECTION_POOL_DEGRADED_SPEED_FRACTION: f64 = 0.80; +const ARIA2_MIN_CONNECTIONS_LOST_FOR_PARTIAL_RECOVERY: i32 = 2; const ARIA2_MIN_HEALTHY_SPEED_SAMPLES: u8 = 3; const ARIA2_MAX_CONSECUTIVE_RECOVERY_ATTEMPTS: u8 = 3; const ARIA2_CONNECTION_DIAGNOSTIC_INTERVAL: Duration = Duration::from_secs(30); @@ -14498,6 +14769,12 @@ fn observe_aria2_connections_with_epoch( observation.healthy_speed_samples = observation .healthy_speed_samples .saturating_add(1); + let observed_active_connections = active_connections + .min(effective_connections) + .max(0); + observation.peak_active_connections = observation + .peak_active_connections + .max(observed_active_connections); } let slow_throughput = !speed_limited && observation.saw_multiple_connections @@ -14510,11 +14787,19 @@ fn observe_aria2_connections_with_epoch( && observation.saw_multiple_connections && observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES && effective_connections >= 4 - && (active_connections as f64) - <= (effective_connections as f64) * ARIA2_CONNECTION_POOL_DEGRADED_FRACTION + && observation.peak_active_connections.min(effective_connections) >= 4 + && active_connections.min(effective_connections) + <= observation + .peak_active_connections + .min(effective_connections) + .saturating_sub(ARIA2_MIN_CONNECTIONS_LOST_FOR_PARTIAL_RECOVERY) + && (active_connections.min(effective_connections) as f64) + <= (observation.peak_active_connections.min(effective_connections) as f64) + * ARIA2_CONNECTION_POOL_DEGRADED_FRACTION && observation.peak_speed_bytes >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY && speed_bytes > 0.0 - && speed_bytes < observation.peak_speed_bytes * 0.5; + && speed_bytes + < observation.peak_speed_bytes * ARIA2_CONNECTION_POOL_DEGRADED_SPEED_FRACTION; let connection_pool_collapse = !speed_limited && observation.saw_multiple_connections && observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 8fcdd0a..5cc5c49 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -2459,6 +2459,8 @@ describe('useDownloadStore', () => { status: 'paused', category: 'Other', dateAdded: '', + username: 'alice', + headers: 'Referer: https://example.com/page', credentialsRequired: true }] as any[], backendRegisteredIds: new Set(['credential-resume-gated']) diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index bb0a38a..10e0e7b 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -10,7 +10,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope'; import type { Queue } from '../bindings/Queue'; import { useSettingsStore } from './useSettingsStore'; import { useDownloadProgressStore } from './downloadProgressStore'; -import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, isActiveDownloadStatus, isAllocationPhaseEligible, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, isActiveDownloadStatus, isAllocationPhaseEligible, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { resolveCategoryDestination } from '../utils/downloadLocations'; @@ -357,7 +357,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): if (item.isTorrent !== true && item.credentialsRequired === true && !hasCredentialMaterial(item.password) && !hasCredentialMaterial(item.cookies) - && !hasCredentialMaterial(item.headers) + && !hasCredentialBearingHeaders(item.headers) && !hasCredentialMaterial(keychainPassword)) { markCredentialsRequired(id); return false; @@ -1172,9 +1172,13 @@ export const useDownloadStore = create((set, get) => { const credentialsUpdated = (['password', 'cookies', 'headers'] as const) .some(field => Object.prototype.hasOwnProperty.call(updates, field)); const nextCredentialMaterial = (['password', 'cookies', 'headers'] as const) - .some(field => hasCredentialMaterial( - Object.prototype.hasOwnProperty.call(updates, field) ? updates[field] : item[field] - )); + .some(field => field === 'headers' + ? hasCredentialBearingHeaders( + Object.prototype.hasOwnProperty.call(updates, field) ? updates[field] : item[field] + ) + : hasCredentialMaterial( + Object.prototype.hasOwnProperty.call(updates, field) ? updates[field] : item[field] + )); const normalizedUpdates = { ...(updates.fileName === undefined ? updates @@ -1290,7 +1294,7 @@ export const useDownloadStore = create((set, get) => { if (targetItem.isTorrent !== true && targetItem.credentialsRequired === true && !hasCredentialMaterial(targetItem.password) && !hasCredentialMaterial(targetItem.cookies) - && !hasCredentialMaterial(targetItem.headers)) { + && !hasCredentialBearingHeaders(targetItem.headers)) { if (!resumeWithoutCredentials) { const settings = useSettingsStore.getState(); const login = getSiteLogin(targetItem.url, settings); @@ -2677,7 +2681,7 @@ export const useDownloadStore = create((set, get) => { if (item.isTorrent !== true && item.credentialsRequired === true && !hasCredentialMaterial(item.password) && !hasCredentialMaterial(item.cookies) - && !hasCredentialMaterial(item.headers) + && !hasCredentialBearingHeaders(item.headers) && !hasCredentialMaterial(keychainPassword)) { markCredentialsRequired(item.id); continue; diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 01345a8..7833f73 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -89,6 +89,57 @@ describe('download persistence progress snapshots', () => { expect(persisted.headers).toBeUndefined(); }); + it('does not gate browser request context headers after restart', () => { + const persisted = redactDownloadForPersistence({ + ...item('paused'), + headers: 'Referer: https://example.com/page\nUser-Agent: Browser', + }); + + expect(persisted.credentialsRequired).toBeUndefined(); + expect(persisted.headers).toBe('Referer: https://example.com/page\nUser-Agent: Browser'); + }); + + it('requires confirmation when a Referer contains restart-sensitive URL context', () => { + const persisted = redactDownloadForPersistence({ + ...item('paused'), + headers: 'Referer: https://example.com/page?session=secret#part', + }); + + expect(persisted.credentialsRequired).toBe(true); + expect(persisted.headers).toBe('Referer: https://example.com/page'); + expect(JSON.stringify(persisted)).not.toContain('secret'); + }); + + it('marks username-only authentication as requiring credentials after restart', () => { + const persisted = redactDownloadForPersistence({ + ...item('paused'), + username: 'alice', + }); + + expect(persisted.credentialsRequired).toBe(true); + expect(persisted.username).toBe('alice'); + }); + + it('fails closed for unknown custom headers that may carry credentials', () => { + const persisted = redactDownloadForPersistence({ + ...item('paused'), + headers: 'X-Download-Token: secret', + }); + + expect(persisted.credentialsRequired).toBe(true); + expect(persisted.headers).toBeUndefined(); + }); + + it('fails closed for empty unknown credential headers', () => { + const persisted = redactDownloadForPersistence({ + ...item('paused'), + headers: 'X-Auth-Token:', + }); + + expect(persisted.credentialsRequired).toBe(true); + expect(persisted.headers).toBeUndefined(); + }); + it('does not create a credential gate for Torrent metadata context', () => { const persisted = redactDownloadForPersistence({ ...item('paused'), diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index f854148..c886da8 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -580,13 +580,163 @@ export const isMediaUrl = (rawUrl: string): boolean => { * session (see `enqueue_download` payloads) but are stripped at the * persistence boundary so the user-data database contains no plaintext credentials. */ -const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const; +const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies'] as const; +// Browser captures commonly include request context such as Referer and +// User-Agent. Keep this an explicit allowlist so those known non-credential +// headers do not gate a restart, while an unknown custom header fails closed +// and remains eligible for a credential-confirmation retry. +const NON_CREDENTIAL_REQUEST_HEADERS = new Set([ + 'accept', + 'accept-charset', + 'accept-encoding', + 'accept-language', + 'cache-control', + 'connection', + 'dnt', + 'host', + 'if-match', + 'if-modified-since', + 'if-none-match', + 'if-range', + 'if-unmodified-since', + 'origin', + 'pragma', + 'priority', + 'range', + 'referer', + 'sec-ch-ua', + 'sec-ch-ua-mobile', + 'sec-ch-ua-platform', + 'sec-fetch-dest', + 'sec-fetch-mode', + 'sec-fetch-site', + 'sec-fetch-user', + 'sec-gpc', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'user-agent', + 'via', + 'warning', +]); +// Only stable request context is safe to carry into a later lifecycle. Range, +// conditional, hop-by-hop, and routing headers describe the old HTTP request +// and can conflict with Aria2's own resume negotiation. +const PERSISTABLE_REQUEST_HEADERS = new Set([ + 'accept', + 'accept-charset', + 'accept-encoding', + 'accept-language', + 'cache-control', + 'dnt', + 'origin', + 'pragma', + 'priority', + 'referer', + 'sec-ch-ua', + 'sec-ch-ua-mobile', + 'sec-ch-ua-platform', + 'sec-fetch-dest', + 'sec-fetch-mode', + 'sec-fetch-site', + 'sec-fetch-user', + 'sec-gpc', + 'user-agent', +]); const VOLATILE_PROGRESS_STATUSES = new Set([ 'downloading', 'verifying', 'seeding' ]); +const hasCredentialMaterial = (value: string | null | undefined): boolean => + typeof value === 'string' && value.trim().length > 0; + +const headerValueRequiresRecovery = (name: string, value: string): boolean => { + if (!value.trim()) return false; + if (/[\u0000-\u001f\u007f]/.test(value)) return true; + + if (name === 'referer' || name === 'origin') { + try { + const parsed = new URL(value); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return true; + if (parsed.username || parsed.password || parsed.search || parsed.hash) return true; + // Origin must not carry a path that would be silently discarded by the + // persistence sanitizer. + return name === 'origin' && parsed.pathname !== '/' && parsed.pathname !== ''; + } catch { + return true; + } + } + + return false; +}; + +export const hasCredentialBearingHeaders = (headers: string | null | undefined): boolean => { + if (!hasCredentialMaterial(headers)) return false; + return headers!.split(/\r?\n/).some(line => { + const trimmed = line.trim(); + if (!trimmed) return false; + const separator = trimmed.indexOf(':'); + if (separator <= 0) return true; + const name = trimmed.slice(0, separator).trim().toLowerCase(); + // An unknown header is sensitive even when its value is empty: the + // redacted value cannot tell us whether it was a placeholder for a token + // or an intentionally empty request field. + return !NON_CREDENTIAL_REQUEST_HEADERS.has(name) + || headerValueRequiresRecovery(name, trimmed.slice(separator + 1).trim()); + }); +}; + +const persistableHeaderValue = (name: string, value: string): string | undefined => { + if (/[\u0000-\u001f\u007f]/.test(value)) return undefined; + + if (name === 'referer') { + try { + const parsed = new URL(value); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined; + parsed.username = ''; + parsed.password = ''; + // A Referer query or fragment can carry a signed URL or user token. + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); + } catch { + return undefined; + } + } + + if (name === 'origin') { + try { + const parsed = new URL(value); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined; + return parsed.origin; + } catch { + return undefined; + } + } + + return value; +}; + +const persistableRequestHeaders = (headers: string | null | undefined): string | undefined => { + if (!hasCredentialMaterial(headers)) return undefined; + + const lines = headers!.split(/\r?\n/).flatMap(line => { + const trimmed = line.trim(); + if (!trimmed) return []; + const separator = trimmed.indexOf(':'); + if (separator <= 0) return []; + const name = trimmed.slice(0, separator).trim().toLowerCase(); + if (!PERSISTABLE_REQUEST_HEADERS.has(name)) return []; + const value = persistableHeaderValue(name, trimmed.slice(separator + 1).trim()); + return value === undefined ? [] : [`${trimmed.slice(0, separator).trim()}: ${value}`]; + }); + + return lines.length > 0 ? lines.join('\n') : undefined; +}; + /** * Returns a shallow copy of `item` with secret fields removed. Volatile * progress fields (`fraction`, `speed`, `eta`) are also dropped as in the @@ -596,6 +746,12 @@ const VOLATILE_PROGRESS_STATUSES = new Set([ * state stay in memory to avoid a database write for every progress tick. * Non-ticking states retain counters so paused, queued, staged, retrying, and * processing snapshots remain useful across restart and reconfiguration. + * The credential marker is narrower than the redacted field set: ordinary + * browser request context such as Referer does not require a credentialed + * restart, while passwords, cookies, usernames, and unknown custom headers + * do. A sanitized subset of stable browser context is retained so a resumed + * download keeps anti-hotlink and content-negotiation context without + * persisting credentials or stale range state. * * Note: standard persistence intentionally retains `url` because it is the * download source. The backend applies a stricter portable-mode policy: URL @@ -611,7 +767,10 @@ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem = delete copy.credentialsRequired; delete copy.username; } else if (item.credentialsRequired === true - || DOWNLOAD_SECRET_FIELDS.some(field => Boolean(item[field]))) { + || hasCredentialMaterial(item.username) + || hasCredentialMaterial(item.password) + || hasCredentialMaterial(item.cookies) + || hasCredentialBearingHeaders(item.headers)) { copy.credentialsRequired = true; } delete copy.fraction; @@ -625,6 +784,13 @@ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem = for (const field of DOWNLOAD_SECRET_FIELDS) { delete copy[field]; } + if (item.isTorrent === true) { + delete copy.headers; + } else { + const savedHeaders = persistableRequestHeaders(item.headers); + if (savedHeaders) copy.headers = savedHeaders; + else delete copy.headers; + } // Error classification is derived from the live native state and must not // become a persistence field or influence a new lifecycle after restart. delete copy.lastErrorKind;