fix(torrent): harden allocation and credential isolation

- Extend allocation-phase eligibility to preallocated Torrent admission while excluding none, verify-only, and media work.

- Strip Torrent metadata credentials at intake, persistence, renderer, native, and Aria2 header boundaries.

- Add restart, batch-admission, persistence, and native regression coverage.
This commit is contained in:
NimBold
2026-08-19 08:49:02 +03:30
parent 2bce25868c
commit 566632b7ad
7 changed files with 378 additions and 90 deletions
+1
View File
@@ -6855,6 +6855,7 @@ async fn validate_torrent_enqueue(
app_handle: &tauri::AppHandle,
item: &mut queue::EnqueueItem,
) -> Result<(), String> {
item.strip_torrent_credentials();
if item.is_media.unwrap_or(false) {
return Err("torrent transfer cannot be a media download".to_string());
}
+123 -65
View File
@@ -6948,6 +6948,30 @@ fn apply_aria2_torrent_options(
Ok(())
}
fn apply_aria2_header_options(
options: &mut serde_json::Map<String, serde_json::Value>,
payload: &SpawnPayload,
credentials_allowed: bool,
) {
if !credentials_allowed {
return;
}
let mut header_list = Vec::new();
if let Some(cookies) = &payload.cookies {
header_list.push(format!("Cookie: {cookies}"));
}
if let Some(headers) = &payload.headers {
for line in headers.lines() {
if !line.trim().is_empty() {
header_list.push(line.trim().to_string());
}
}
}
if !header_list.is_empty() {
options.insert("header".to_string(), serde_json::json!(header_list));
}
}
impl ProductionSpawner {
pub fn new(app_handle: AppHandle<tauri::Wry>) -> Self {
Self { app_handle }
@@ -7055,7 +7079,10 @@ impl SidecarSpawner for ProductionSpawner {
}
let (transfer_uris, requested_connections, transfer_connections, credentials_allowed) =
if payload.is_torrent {
(Vec::new(), DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MIN, true)
// Torrent metadata acquisition credentials are never transfer
// credentials. Torrent trackers and web seeds use their own
// validated URI/options paths below.
(Vec::new(), DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MIN, false)
} else {
let requested_uris =
crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
@@ -7134,22 +7161,7 @@ impl SidecarSpawner for ProductionSpawner {
if let Some(ua) = &payload.user_agent {
options.insert("user-agent".to_string(), serde_json::json!(ua));
}
let mut header_list = Vec::new();
if payload.is_torrent || credentials_allowed {
if let Some(cook) = &payload.cookies {
header_list.push(format!("Cookie: {}", cook));
}
if let Some(hdrs) = &payload.headers {
for line in hdrs.lines() {
if !line.trim().is_empty() {
header_list.push(line.trim().to_string());
}
}
}
}
if !header_list.is_empty() {
options.insert("header".to_string(), serde_json::json!(header_list));
}
apply_aria2_header_options(&mut options, payload, credentials_allowed);
if let Some(prox) = proxy_value {
options.insert("all-proxy".to_string(), serde_json::json!(prox));
}
@@ -7715,7 +7727,7 @@ impl SidecarSpawner for ProductionSpawner {
}
}
#[derive(Debug, Clone, Deserialize, TS)]
#[derive(Debug, Clone, Default, Deserialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct EnqueueItem {
pub id: String,
@@ -7827,73 +7839,84 @@ pub struct EnqueueItem {
}
impl EnqueueItem {
pub fn strip_torrent_credentials(&mut self) {
if self.is_torrent.unwrap_or(false) {
self.username = None;
self.password = None;
self.headers = None;
self.cookies = None;
}
}
pub fn into_task(self) -> QueuedTask {
let media = self.is_media.unwrap_or(false);
let mut item = self;
item.strip_torrent_credentials();
let media = item.is_media.unwrap_or(false);
let kind = if media {
TaskKind::Media
} else {
TaskKind::Aria2
};
let id = self.id.clone();
let id = item.id.clone();
QueuedTask {
id,
queue_id: self.queue_id,
queue_id: item.queue_id,
kind,
lifecycle_generation: self
lifecycle_generation: item
.lifecycle_generation
.as_deref()
.and_then(|generation| generation.parse().ok())
.unwrap_or_default(),
payload: SpawnPayload {
url: self.url,
destination: self.destination,
filename: self.filename,
connections: self.connections,
speed_limit: self.speed_limit,
username: self.username,
password: self.password,
sftp_host_key_md: self.sftp_host_key_md,
headers: self.headers,
checksum: self.checksum,
cookies: self.cookies,
mirrors: self.mirrors,
user_agent: self.user_agent,
max_tries: self.max_tries,
minimum_normal_download_speed_kib: self
url: item.url,
destination: item.destination,
filename: item.filename,
connections: item.connections,
speed_limit: item.speed_limit,
username: item.username,
password: item.password,
sftp_host_key_md: item.sftp_host_key_md,
headers: item.headers,
checksum: item.checksum,
cookies: item.cookies,
mirrors: item.mirrors,
user_agent: item.user_agent,
max_tries: item.max_tries,
minimum_normal_download_speed_kib: item
.minimum_normal_download_speed_kib
.unwrap_or_default(),
retry_not_found_errors: self.retry_not_found_errors.unwrap_or(false),
adaptive_mirror_selection: self.adaptive_mirror_selection.unwrap_or(true),
proxy: self.proxy,
retry_not_found_errors: item.retry_not_found_errors.unwrap_or(false),
adaptive_mirror_selection: item.adaptive_mirror_selection.unwrap_or(true),
proxy: item.proxy,
aria2_resolver_mode: Aria2ResolverMode::Automatic,
format_selector: self.format_selector,
cookie_source: self.cookie_source,
format_selector: item.format_selector,
cookie_source: item.cookie_source,
is_media: media,
is_torrent: self.is_torrent.unwrap_or(false),
torrent_path: self.torrent_path,
torrent_file_indices: self.torrent_file_indices,
torrent_seed_time: self.torrent_seed_remaining.or(self.torrent_seed_time),
torrent_seed_ratio: self.torrent_seed_ratio,
torrent_seed_remaining: self.torrent_seed_remaining,
torrent_web_seeds: self.torrent_web_seeds,
torrent_upload_limit: self.torrent_upload_limit,
torrent_max_peers: self.torrent_max_peers,
torrent_peer_speed_limit: self.torrent_peer_speed_limit,
torrent_check_integrity: self.torrent_check_integrity.unwrap_or(false),
torrent_trackers: self.torrent_trackers,
torrent_exclude_trackers: self.torrent_exclude_trackers,
torrent_tracker_connect_timeout: self.torrent_tracker_connect_timeout,
torrent_tracker_timeout: self.torrent_tracker_timeout,
torrent_tracker_interval: self.torrent_tracker_interval,
torrent_stop_timeout: self.torrent_stop_timeout,
torrent_prioritize_piece: self.torrent_prioritize_piece,
torrent_remove_unselected_file: self
is_torrent: item.is_torrent.unwrap_or(false),
torrent_path: item.torrent_path,
torrent_file_indices: item.torrent_file_indices,
torrent_seed_time: item.torrent_seed_remaining.or(item.torrent_seed_time),
torrent_seed_ratio: item.torrent_seed_ratio,
torrent_seed_remaining: item.torrent_seed_remaining,
torrent_web_seeds: item.torrent_web_seeds,
torrent_upload_limit: item.torrent_upload_limit,
torrent_max_peers: item.torrent_max_peers,
torrent_peer_speed_limit: item.torrent_peer_speed_limit,
torrent_check_integrity: item.torrent_check_integrity.unwrap_or(false),
torrent_trackers: item.torrent_trackers,
torrent_exclude_trackers: item.torrent_exclude_trackers,
torrent_tracker_connect_timeout: item.torrent_tracker_connect_timeout,
torrent_tracker_timeout: item.torrent_tracker_timeout,
torrent_tracker_interval: item.torrent_tracker_interval,
torrent_stop_timeout: item.torrent_stop_timeout,
torrent_prioritize_piece: item.torrent_prioritize_piece,
torrent_remove_unselected_file: item
.torrent_remove_unselected_file
.unwrap_or(false),
torrent_encryption_policy: self.torrent_encryption_policy,
torrent_file_allocation: self.torrent_file_allocation,
torrent_verify_only: self.torrent_verify_only.unwrap_or(false),
torrent_verify_restore_status: self.torrent_verify_restore_status,
torrent_encryption_policy: item.torrent_encryption_policy,
torrent_file_allocation: item.torrent_file_allocation,
torrent_verify_only: item.torrent_verify_only.unwrap_or(false),
torrent_verify_restore_status: item.torrent_verify_restore_status,
torrent_verified_length: None,
},
}
@@ -8075,6 +8098,41 @@ mod tests {
assert_eq!(normal_options.get("max-connection-per-server"), Some(&serde_json::json!("16")));
}
#[test]
fn torrent_payloads_do_not_emit_generic_headers_or_cookies() {
let payload = SpawnPayload {
is_torrent: true,
headers: Some("User-Agent: browser\nAuthorization: secret".to_string()),
cookies: Some("session=secret".to_string()),
..Default::default()
};
let mut options = serde_json::Map::new();
apply_aria2_header_options(&mut options, &payload, false);
assert!(!options.contains_key("header"));
}
#[test]
fn torrent_enqueue_items_strip_generic_credentials_before_task_creation() {
let mut item = EnqueueItem {
is_torrent: Some(true),
username: Some("browser-user".to_string()),
password: Some("secret".to_string()),
headers: Some("Authorization: secret".to_string()),
cookies: Some("session=secret".to_string()),
..Default::default()
};
item.strip_torrent_credentials();
let task = item.into_task();
assert!(task.payload.username.is_none());
assert!(task.payload.password.is_none());
assert!(task.payload.headers.is_none());
assert!(task.payload.cookies.is_none());
}
#[test]
fn torrent_network_and_storage_settings_are_normalized_at_the_boundary() {
assert_eq!(