From c3755ce886fdde586386bd01ca10ea16a1121c85 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 8 Aug 2026 22:33:49 +0330 Subject: [PATCH] fix(aria2): harden protocol and torrent transfers --- .gitignore | 1 + src-tauri/src/ipc.rs | 5 + src-tauri/src/lib.rs | 98 +++- src-tauri/src/queue.rs | 441 ++++++++++++++---- src-tauri/src/torrent.rs | 148 +++++- src/bindings/DownloadItem.ts | 2 +- src/bindings/EnqueueItem.ts | 2 +- src/components/AddDownloadsModal.tsx | 25 + src/components/PropertiesWindowApp.tsx | 10 +- src/components/PropertiesWindowBridgeHost.tsx | 9 + src/i18n/catalogs/en.ts | 7 + src/i18n/catalogs/fa.ts | 7 + src/i18n/catalogs/he.ts | 7 + src/i18n/catalogs/ru.ts | 7 + src/i18n/catalogs/uk.ts | 7 + src/i18n/catalogs/zh-CN.ts | 7 + src/propertiesBridge.ts | 3 + src/store/useDownloadStore.test.ts | 48 ++ src/store/useDownloadStore.ts | 84 +++- src/utils/downloads.test.ts | 14 + src/utils/downloads.ts | 4 + 21 files changed, 830 insertions(+), 106 deletions(-) diff --git a/.gitignore b/.gitignore index 9585f67..d644d18 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ GEMINI.md implementation_plan.md CROSS_PLATFORM_CHECKLIST.md Cross-platform-checklist-gemini.MD +ARIA2_AVERAGE_USER_AUDIT.md YouTube_media_download_handoff.md Release_checklist.md Release Checklist/ diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index b677d61..339e745 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -182,6 +182,8 @@ pub struct DownloadItem { #[ts(optional)] pub password: Option, #[ts(optional)] + pub sftp_host_key_md: Option, + #[ts(optional)] pub headers: Option, #[ts(optional)] pub checksum: Option, @@ -207,6 +209,9 @@ pub struct DownloadItem { pub last_error: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] + pub credentials_required: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub last_error_kind: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0ad4b37..f376150 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1726,10 +1726,14 @@ async fn resolve_and_validate_url_host( let addrs: Vec<_> = if let Ok(ip) = lookup_host.parse::() { vec![std::net::SocketAddr::new(ip, port)] } else { - tokio::net::lookup_host((lookup_host, port)) - .await - .map_err(|_| "SSRF blocked: DNS resolution failed")? - .collect() + tokio::time::timeout( + std::time::Duration::from_secs(5), + tokio::net::lookup_host((lookup_host, port)), + ) + .await + .map_err(|_| "SSRF blocked: DNS resolution timed out")? + .map_err(|_| "SSRF blocked: DNS resolution failed")? + .collect() }; let addr = addrs.first().copied().ok_or("SSRF blocked: No DNS records")?; if addrs.iter().any(|candidate| is_blocked_network_address(candidate.ip())) { @@ -6160,6 +6164,60 @@ async fn validate_enqueue_uris(url: &str, mirrors: Option<&str>) -> Result<(), S Ok(()) } +async fn validate_torrent_web_seed_destinations(seeds: &[String]) -> Result<(), String> { + for uri in seeds { + let parsed = reqwest::Url::parse(uri) + .map_err(|_| "Torrent web-seed URI is invalid".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none_or(str::is_empty) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + { + return Err("Torrent web-seed URI must use HTTP or HTTPS without credentials or fragments".to_string()); + } + resolve_and_validate_url_host(&parsed).await?; + } + Ok(()) +} + +/// Embedded web seeds are optional Torrent accelerators, not the transfer's +/// source of truth. Keep the peer-only path usable when a stale seed domain is +/// temporarily unavailable, while still rejecting destinations that resolve +/// to local or private addresses before they are offered to Aria2. +pub(crate) async fn filter_torrent_web_seed_destinations( + seeds: &[String], +) -> Result, String> { + let mut allowed = Vec::with_capacity(seeds.len()); + for seed in seeds { + let parsed = reqwest::Url::parse(seed) + .map_err(|_| "Torrent web-seed URI is invalid".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none_or(str::is_empty) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + { + return Err("Torrent web-seed URI must use HTTP or HTTPS without credentials or fragments".to_string()); + } + match resolve_and_validate_url_host(&parsed).await { + Ok(_) => allowed.push(seed.clone()), + Err(error) + if error == "SSRF blocked: DNS resolution failed" + || error == "SSRF blocked: DNS resolution timed out" + || error == "SSRF blocked: No DNS records" => + { + log::warn!( + "Skipping unavailable embedded Torrent web seed {}", + parsed.host_str().unwrap_or("") + ); + } + Err(error) => return Err(error), + } + } + Ok(allowed) +} + async fn validate_torrent_enqueue( app_handle: &tauri::AppHandle, item: &mut queue::EnqueueItem, @@ -6191,6 +6249,17 @@ async fn validate_torrent_enqueue( let bytes = std::fs::read(path) .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; + let normalized_user_web_seeds = queue::normalize_torrent_web_seeds( + item.torrent_web_seeds.as_deref(), + &metadata.files, + )?; + let user_web_seed_uris = normalized_user_web_seeds + .iter() + .map(|seed| seed.uri.clone()) + .collect::>(); + validate_torrent_web_seed_destinations(&user_web_seed_uris).await?; + item.torrent_web_seeds = (!normalized_user_web_seeds.is_empty()) + .then_some(normalized_user_web_seeds); crate::torrent::validate_info_hash( item.torrent_info_hash.as_deref(), &metadata.info_hash, @@ -6545,9 +6614,10 @@ async fn resolve_magnet_metadata( } let client = std::sync::Arc::new(Aria2RpcClient { port, secret }); + let sanitized_source = crate::torrent::sanitize_magnet_uri_for_aria2(source)?; let metadata_result = crate::torrent_probe::run_metadata_probe( client, - source, + &sanitized_source, options, &metadata_path, Duration::from_secs(60), @@ -6737,6 +6807,8 @@ async fn enqueue_download_locked( .await .map_err(AppError::Internal)?; } else { + item.sftp_host_key_md = queue::normalize_sftp_host_key_md(item.sftp_host_key_md.as_deref()) + .map_err(AppError::Internal)?; validate_enqueue_uris(&item.url, item.mirrors.as_deref()) .await .map_err(AppError::Internal)?; @@ -6834,7 +6906,13 @@ async fn enqueue_many( let validation = if item.is_torrent.unwrap_or(false) { validate_torrent_enqueue(&app_handle, &mut item).await } else { - validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await + match queue::normalize_sftp_host_key_md(item.sftp_host_key_md.as_deref()) { + Ok(normalized) => { + item.sftp_host_key_md = normalized; + validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await + } + Err(error) => Err(error), + } }; if let Err(error) = validation { results.push(crate::ipc::EnqueueResult { @@ -8677,6 +8755,7 @@ async fn verify_torrent_data( speed_limit: item.speed_limit.clone(), username: item.username.clone(), password: item.password.clone(), + sftp_host_key_md: item.sftp_host_key_md.clone(), headers: item.headers.clone(), checksum: item.checksum.clone(), cookies: item.cookies.clone(), @@ -8901,7 +8980,10 @@ async fn normalize_persisted_torrent_web_seeds( .await .map_err(|error| format!("could not read cached Torrent metadata: {error}"))?; let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; - crate::queue::normalize_torrent_web_seeds(Some(seeds), &metadata.files) + let normalized = crate::queue::normalize_torrent_web_seeds(Some(seeds), &metadata.files)?; + let web_seed_uris = normalized.iter().map(|seed| seed.uri.clone()).collect::>(); + validate_torrent_web_seed_destinations(&web_seed_uris).await?; + Ok(normalized) } #[tauri::command] @@ -8979,6 +9061,8 @@ async fn set_torrent_web_seeds( ) .await? }; + let web_seed_uris = normalized.iter().map(|seed| seed.uri.clone()).collect::>(); + validate_torrent_web_seed_destinations(&web_seed_uris).await?; let previous_seeds = replace_persisted_torrent_web_seeds(database.inner(), &id, &normalized)?; // The download can cross the queued/active boundary while metadata is diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index c69da3e..2cf85b6 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -51,6 +51,28 @@ pub const DEFAULT_TORRENT_LISTEN_PORT_SPEC: &str = "6881-6999"; pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M"; pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024; +pub fn normalize_sftp_host_key_md(value: Option<&str>) -> Result, String> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let (kind, digest) = value + .split_once('=') + .ok_or_else(|| "SFTP host-key fingerprint must use TYPE=DIGEST form".to_string())?; + let kind = kind.trim().to_ascii_lowercase(); + let digest = digest.trim().to_ascii_lowercase(); + let expected_length = match kind.as_str() { + "md5" => 32, + "sha-1" => 40, + _ => return Err("SFTP host-key fingerprint type must be md5 or sha-1".to_string()), + }; + if digest.len() != expected_length || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!( + "SFTP {kind} host-key fingerprint must contain exactly {expected_length} hexadecimal characters" + )); + } + Ok(Some(format!("{kind}={digest}"))) +} + pub fn normalize_torrent_bind_address(value: Option<&str>) -> Result, String> { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); @@ -732,6 +754,7 @@ pub struct SpawnPayload { pub speed_limit: Option, pub username: Option, pub password: Option, + pub sftp_host_key_md: Option, pub headers: Option, pub checksum: Option, pub cookies: Option, @@ -4980,14 +5003,14 @@ enum BoundedRangeSupport { Unknown, } -async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> i32 { +async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> Result { let requested = clamp_download_connections( payload .connections .unwrap_or(DOWNLOAD_CONNECTIONS_MIN), ); if requested <= 1 { - return requested; + return Ok(requested); } for uri in crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()) { @@ -5002,7 +5025,7 @@ async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> i32 { id, uri_host_for_log(&uri) ); - return 1; + return Ok(1); } Ok(BoundedRangeSupport::Supported) => {} Ok(BoundedRangeSupport::Unknown) => { @@ -5013,6 +5036,7 @@ async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> i32 { requested ); } + Err(error) if error.starts_with("SSRF blocked:") => return Err(error), Err(error) => { log::debug!( "aria2 range probe [{}]: {} probe failed: {}; keeping {} connections", @@ -5025,7 +5049,7 @@ async fn effective_aria2_connections(id: &str, payload: &SpawnPayload) -> i32 { } } - requested + Ok(requested) } fn is_http_uri(uri: &str) -> bool { @@ -5069,44 +5093,93 @@ async fn probe_bounded_range_support( ) -> Result { crate::ensure_reqwest_crypto_provider(); - let mut builder = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::limited(5)) - .timeout(std::time::Duration::from_secs(10)); + let original = reqwest::Url::parse(uri).map_err(|error| error.to_string())?; + let mut current = original.clone(); + let mut credentials_allowed = true; + for redirect_count in 0..=5 { + let (host, address) = crate::resolve_and_validate_url_host(¤t).await?; + let mut builder = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(10)) + .resolve(&host, address); - if let Some(proxy) = payload - .proxy - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - if proxy.eq_ignore_ascii_case("none") { - builder = builder.no_proxy(); - } else { - builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?); + if let Some(proxy) = payload + .proxy + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if proxy.eq_ignore_ascii_case("none") { + builder = builder.no_proxy(); + } else { + builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?); + } } + + let client = builder.build().map_err(|error| error.to_string())?; + let request = client + .get(current.clone()) + .header(reqwest::header::RANGE, "bytes=0-0") + .header(reqwest::header::ACCEPT_ENCODING, "identity"); + let include_credentials = credentials_allowed + && can_forward_payload_credentials(&original, ¤t); + let response = apply_payload_headers(request, payload, include_credentials) + .send() + .await + .map_err(|error| error.to_string())?; + + if response.status().is_redirection() { + if redirect_count == 5 { + return Err("range probe redirect limit exceeded".to_string()); + } + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| "range probe redirect has no valid Location".to_string())?; + let next = current + .join(location) + .map_err(|error| format!("invalid range probe redirect: {error}"))?; + if !matches!(next.scheme(), "http" | "https") { + return Err("range probe redirect uses an unsupported scheme".to_string()); + } + credentials_allowed = credentials_allowed + && can_forward_payload_credentials(&original, &next); + current = next; + continue; + } + + let content_range = response + .headers() + .get(reqwest::header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()); + return Ok(classify_bounded_range_response( + response.status(), + content_range, + )); } - let client = builder.build().map_err(|error| error.to_string())?; - let request = client - .get(uri) - .header(reqwest::header::RANGE, "bytes=0-0") - .header(reqwest::header::ACCEPT_ENCODING, "identity"); - let request = apply_payload_headers(request, payload); - let response = request.send().await.map_err(|error| error.to_string())?; - let content_range = response - .headers() - .get(reqwest::header::CONTENT_RANGE) - .and_then(|value| value.to_str().ok()); + Err("range probe redirect loop exhausted".to_string()) +} - Ok(classify_bounded_range_response( - response.status(), - content_range, - )) +fn can_forward_payload_credentials( + original: &reqwest::Url, + current: &reqwest::Url, +) -> bool { + original.host() == current.host() + && (original.port_or_known_default() == current.port_or_known_default() + || (original.scheme() == "http" + && current.scheme() == "https" + && original.port_or_known_default() == Some(80) + && current.port_or_known_default() == Some(443))) + && (original.scheme() == current.scheme() + || (original.scheme() == "http" && current.scheme() == "https")) } fn apply_payload_headers( mut request: reqwest::RequestBuilder, payload: &SpawnPayload, + include_credentials: bool, ) -> reqwest::RequestBuilder { if let Some(user_agent) = payload .user_agent @@ -5115,40 +5188,111 @@ fn apply_payload_headers( { request = request.header(reqwest::header::USER_AGENT, user_agent); } - if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) { - request = request.header(reqwest::header::COOKIE, cookies); - } - if let Some(headers) = payload.headers.as_deref() { - for line in headers - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - { - let Some((name, value)) = line.split_once(':') else { - continue; - }; - if name.trim().eq_ignore_ascii_case("range") { - continue; + if include_credentials { + if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) { + request = request.header(reqwest::header::COOKIE, cookies); + } + if let Some(headers) = payload.headers.as_deref() { + for line in headers + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + if name.trim().eq_ignore_ascii_case("range") { + continue; + } + let Ok(name) = reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()) else { + continue; + }; + let Ok(value) = reqwest::header::HeaderValue::from_str(value.trim()) else { + continue; + }; + request = request.header(name, value); } - let Ok(name) = reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()) else { - continue; - }; - let Ok(value) = reqwest::header::HeaderValue::from_str(value.trim()) else { - continue; - }; - request = request.header(name, value); + } + if let Some(username) = payload + .username + .as_deref() + .filter(|value| !value.is_empty()) + { + request = request.basic_auth(username, payload.password.as_deref()); } } - if let Some(username) = payload - .username - .as_deref() - .filter(|value| !value.is_empty()) - { - request = request.basic_auth(username, payload.password.as_deref()); - } request } +fn apply_protocol_auth_options( + options: &mut serde_json::Map, + payload: &SpawnPayload, + uris: &[String], +) { + let has_http = uris.iter().any(|uri| is_http_uri(uri)); + let has_ftp = uris.iter().any(|uri| { + url::Url::parse(uri) + .ok() + .is_some_and(|parsed| matches!(parsed.scheme(), "ftp" | "sftp")) + }); + let has_sftp = uris.iter().any(|uri| { + url::Url::parse(uri) + .ok() + .is_some_and(|parsed| parsed.scheme() == "sftp") + }); + if has_http { + if let Some(user) = &payload.username { + options.insert("http-user".to_string(), serde_json::json!(user)); + } + if let Some(pass) = &payload.password { + options.insert("http-passwd".to_string(), serde_json::json!(pass)); + } + } + if has_ftp { + if let Some(user) = &payload.username { + options.insert("ftp-user".to_string(), serde_json::json!(user)); + } + if let Some(pass) = &payload.password { + options.insert("ftp-passwd".to_string(), serde_json::json!(pass)); + } + } + if has_sftp { + if let Some(fingerprint) = &payload.sftp_host_key_md { + options.insert("ssh-host-key-md".to_string(), serde_json::json!(fingerprint)); + } + } +} + +fn apply_checksum_options( + options: &mut serde_json::Map, + checksum: Option<&str>, +) { + if let Some(chk) = checksum { + let formatted_chk = if let Some((algo, digest)) = chk.split_once('=') { + format!("{}={}", algo.to_ascii_lowercase(), digest) + } else { + chk.to_string() + }; + options.insert("checksum".to_string(), serde_json::json!(formatted_chk)); + options.insert("check-integrity".to_string(), serde_json::json!("true")); + } +} + +async fn validate_aria2_transfer_network_policy(uris: &[String]) -> Result<(), String> { + for uri in uris { + let parsed = reqwest::Url::parse(uri).map_err(|_| "SSRF blocked: Invalid URL".to_string())?; + if !matches!(parsed.scheme(), "http" | "https" | "ftp" | "sftp") { + return Err("Unsupported URL scheme".to_string()); + } + // This is deliberately repeated immediately before addUri/addTorrent + // so admission-time DNS is not the only policy check. Aria2 still + // resolves independently later; Firelink therefore does not claim + // that this is an IP pinning boundary. + crate::resolve_and_validate_url_host(&parsed).await?; + } + Ok(()) +} + fn classify_bounded_range_response( status: reqwest::StatusCode, content_range: Option<&str>, @@ -6234,8 +6378,16 @@ impl SidecarSpawner for ProductionSpawner { if !payload.is_torrent { options.insert("out".to_string(), serde_json::json!(safe_filename)); } + let transfer_uris = if payload.is_torrent { + Vec::new() + } else { + crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()) + }; + if !payload.is_torrent { + validate_aria2_transfer_network_policy(&transfer_uris).await?; + } if should_apply_aria2_connection_options(payload) { - let conn = effective_aria2_connections(id, payload).await; + let conn = effective_aria2_connections(id, payload).await?; apply_aria2_connection_options(&mut options, conn); } apply_aria2_follow_options(&mut options, payload); @@ -6255,19 +6407,9 @@ impl SidecarSpawner for ProductionSpawner { { options.insert("max-download-limit".to_string(), serde_json::json!(speed)); } - if let Some(user) = &payload.username { - options.insert("http-user".to_string(), serde_json::json!(user)); - } - if let Some(pass) = &payload.password { - options.insert("http-passwd".to_string(), serde_json::json!(pass)); - } - if let Some(chk) = &payload.checksum { - let formatted_chk = if let Some((algo, digest)) = chk.split_once('=') { - format!("{}={}", algo.to_ascii_lowercase(), digest) - } else { - chk.clone() - }; - options.insert("checksum".to_string(), serde_json::json!(formatted_chk)); + if !payload.is_torrent { + apply_protocol_auth_options(&mut options, payload, &transfer_uris); + apply_checksum_options(&mut options, payload.checksum.as_deref()); } if let Some(ua) = &payload.user_agent { options.insert("user-agent".to_string(), serde_json::json!(ua)); @@ -6301,7 +6443,11 @@ impl SidecarSpawner for ProductionSpawner { let bytes = tokio::fs::read(&path) .await .map_err(|error| format!("could not read cached torrent metadata: {error}"))?; - let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; + let (sanitized_bytes, embedded_web_seeds) = + crate::torrent::sanitize_torrent_bytes_for_aria2(&bytes)?; + let embedded_web_seeds = + crate::filter_torrent_web_seed_destinations(&embedded_web_seeds).await?; + let metadata = crate::torrent::parse_torrent_bytes(&sanitized_bytes)?; options.insert( "index-out".to_string(), serde_json::json!(crate::torrent::aria2_index_outputs(&metadata, &payload.filename)), @@ -6316,19 +6462,19 @@ impl SidecarSpawner for ProductionSpawner { serde_json::json!(indices.iter().map(u32::to_string).collect::>().join(",")), ); } - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - let uris = if payload.torrent_web_seeds.is_some() { - // Typed per-file web seeds are attached after the GID is - // known through changeUri. addTorrent accepts only one - // unscoped URI list, which cannot represent fileIndex. - Vec::new() - } else { - payload - .mirrors - .as_deref() - .map(|mirrors| crate::collect_download_uris("", Some(mirrors))) - .unwrap_or_default() - }; + let encoded = base64::engine::general_purpose::STANDARD.encode(sanitized_bytes); + let mut uris = embedded_web_seeds; + if payload.torrent_web_seeds.is_none() { + uris.extend( + payload + .mirrors + .as_deref() + .map(|mirrors| crate::collect_download_uris("", Some(mirrors))) + .unwrap_or_default(), + ); + uris.sort(); + uris.dedup(); + } ("aria2.addTorrent", serde_json::json!([encoded, uris, options])) } else { let parsed = url::Url::parse(&payload.url) @@ -6343,7 +6489,8 @@ impl SidecarSpawner for ProductionSpawner { if selected.is_some() { return Err("magnet file selection requires resolved torrent metadata".to_string()); } - ("aria2.addUri", serde_json::json!([[payload.url.clone()], options])) + let magnet = crate::torrent::sanitize_magnet_uri_for_aria2(&payload.url)?; + ("aria2.addUri", serde_json::json!([[magnet], options])) } } else { let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()); @@ -6785,6 +6932,9 @@ pub struct EnqueueItem { pub speed_limit: Option, pub username: Option, pub password: Option, + #[serde(default)] + #[ts(optional)] + pub sftp_host_key_md: Option, pub headers: Option, pub checksum: Option, pub cookies: Option, @@ -6898,6 +7048,7 @@ impl EnqueueItem { 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, @@ -8118,6 +8269,118 @@ mod tests { .is_err()); } + #[test] + fn normal_protocol_options_use_the_protocol_specific_aria2_keys() { + let payload = SpawnPayload { + username: Some("alice".to_string()), + password: Some("secret".to_string()), + sftp_host_key_md: Some( + "sha-1=0123456789abcdef0123456789abcdef01234567".to_string(), + ), + ..SpawnPayload::default() + }; + let mut options = serde_json::Map::new(); + apply_protocol_auth_options( + &mut options, + &payload, + &["ftp://example.test/file.bin".to_string()], + ); + assert_eq!(options.get("ftp-user"), Some(&serde_json::json!("alice"))); + assert_eq!(options.get("ftp-passwd"), Some(&serde_json::json!("secret"))); + assert!(!options.contains_key("http-user")); + assert!(!options.contains_key("ssh-host-key-md")); + + options.clear(); + apply_protocol_auth_options( + &mut options, + &payload, + &["sftp://example.test/file.bin".to_string()], + ); + assert_eq!(options.get("ftp-user"), Some(&serde_json::json!("alice"))); + assert_eq!( + options.get("ssh-host-key-md"), + Some(&serde_json::json!("sha-1=0123456789abcdef0123456789abcdef01234567")) + ); + assert!(!options.contains_key("http-user")); + } + + #[test] + fn normal_checksum_options_enable_aria2_integrity_checks() { + let mut options = serde_json::Map::new(); + apply_checksum_options(&mut options, Some("SHA-256=ABCDEF")); + assert_eq!(options.get("checksum"), Some(&serde_json::json!("sha-256=ABCDEF"))); + assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true"))); + + options.clear(); + apply_checksum_options(&mut options, None); + assert!(options.is_empty()); + } + + #[test] + fn sftp_host_key_fingerprints_are_normalized_and_bounded() { + assert_eq!( + normalize_sftp_host_key_md(Some( + " SHA-1=0123456789ABCDEF0123456789ABCDEF01234567 ", + )) + .unwrap() + .as_deref(), + Some("sha-1=0123456789abcdef0123456789abcdef01234567") + ); + assert!(normalize_sftp_host_key_md(Some("sha-256=abcd")).is_err()); + assert!(normalize_sftp_host_key_md(Some("md5=abcd")).is_err()); + assert!(normalize_sftp_host_key_md(Some("sha-1=xyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzx")).is_err()); + assert_eq!(normalize_sftp_host_key_md(Some(" ")).unwrap(), None); + } + + #[test] + fn range_probe_does_not_forward_credentials_across_origins() { + crate::ensure_reqwest_crypto_provider(); + let payload = SpawnPayload { + username: Some("alice".to_string()), + password: Some("secret".to_string()), + cookies: Some("session=secret".to_string()), + headers: Some("Authorization: Bearer secret\nX-Test: yes".to_string()), + ..SpawnPayload::default() + }; + let client = reqwest::Client::builder().build().unwrap(); + let same_origin = apply_payload_headers( + client.get("https://example.test/file"), + &payload, + true, + ) + .build() + .unwrap(); + assert!(same_origin.headers().contains_key(reqwest::header::COOKIE)); + assert!(same_origin.headers().contains_key(reqwest::header::AUTHORIZATION)); + assert_eq!(same_origin.headers().get("x-test").unwrap(), "yes"); + + let cross_origin = apply_payload_headers( + client.get("https://cdn.example.test/file"), + &payload, + false, + ) + .build() + .unwrap(); + assert!(!cross_origin.headers().contains_key(reqwest::header::COOKIE)); + assert!(!cross_origin.headers().contains_key(reqwest::header::AUTHORIZATION)); + assert!(!cross_origin.headers().contains_key("x-test")); + } + + #[test] + fn range_probe_allows_http_to_https_upgrade_but_not_cross_origin_return() { + let original = reqwest::Url::parse("http://example.test/file").unwrap(); + let upgrade = reqwest::Url::parse("https://example.test/file").unwrap(); + let unrelated = reqwest::Url::parse("https://cdn.example.test/file").unwrap(); + + assert!(can_forward_payload_credentials(&original, &original)); + assert!(can_forward_payload_credentials(&original, &upgrade)); + assert!(!can_forward_payload_credentials(&original, &unrelated)); + assert!(!can_forward_payload_credentials( + &reqwest::Url::parse("https://example.test/file").unwrap(), + &reqwest::Url::parse("http://example.test/file").unwrap() + )); + } + #[test] fn enqueue_item_carries_torrent_trackers_into_the_spawn_payload() { let item: EnqueueItem = serde_json::from_value(serde_json::json!({ diff --git a/src-tauri/src/torrent.rs b/src-tauri/src/torrent.rs index 902bfdf..4952560 100644 --- a/src-tauri/src/torrent.rs +++ b/src-tauri/src/torrent.rs @@ -15,6 +15,9 @@ pub struct ParsedTorrent { pub total_bytes: u64, pub files: Vec, pub info_hash: String, + /// URL-list entries from the torrent metainfo. These are validated again + /// at enqueue time because Aria2 consumes the original torrent bytes. + pub web_seeds: Vec, } #[derive(Debug, Clone)] @@ -290,7 +293,7 @@ fn parse_info(info: &BencodeValue) -> Result { let digest = Sha1::digest(encoded); let info_hash = digest.iter().map(|byte| format!("{byte:02x}")).collect(); - Ok(ParsedTorrent { name, total_bytes, files, info_hash }) + Ok(ParsedTorrent { name, total_bytes, files, info_hash, web_seeds: Vec::new() }) } fn canonical_btih(value: &str) -> Option { @@ -358,7 +361,32 @@ pub fn parse_torrent_bytes(bytes: &[u8]) -> Result { let info = root .get(b"info".as_slice()) .ok_or_else(|| "torrent metadata is missing info".to_string())?; - parse_info(info) + let web_seeds = parse_torrent_web_seeds(root.get(b"url-list".as_slice()))?; + let mut parsed = parse_info(info)?; + parsed.web_seeds = web_seeds; + Ok(parsed) +} + +/// Remove the untrusted metainfo URL-list before handing a torrent to Aria2. +/// The info dictionary, and therefore the info hash, remains unchanged. The +/// caller can pass the parsed seeds back through the explicit web-seed policy +/// and add only the destinations that were accepted there. +pub fn sanitize_torrent_bytes_for_aria2(bytes: &[u8]) -> Result<(Vec, Vec), String> { + if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES { + return Err(format!( + "torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes" + )); + } + let root = match Parser::new(bytes).parse()? { + BencodeValue::Dict(value) => value, + _ => return Err("torrent root is not a dictionary".to_string()), + }; + let web_seeds = parse_torrent_web_seeds(root.get(b"url-list".as_slice()))?; + let mut sanitized = root; + sanitized.remove(b"url-list".as_slice()); + let mut encoded = Vec::with_capacity(bytes.len()); + encode(&BencodeValue::Dict(sanitized), &mut encoded); + Ok((encoded, web_seeds)) } fn bounded_optional_text(value: Option<&BencodeValue>, limit: usize) -> Option { @@ -415,6 +443,35 @@ fn collect_torrent_uris(value: Option<&BencodeValue>, schemes: &[&str]) -> Vec) -> Result, String> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let entries = match value { + BencodeValue::Bytes(_) => std::slice::from_ref(value), + BencodeValue::List(entries) => entries.as_slice(), + _ => return Err("torrent url-list field is invalid".to_string()), + }; + if entries.len() > 256 { + return Err("torrent url-list contains too many web seeds".to_string()); + } + let mut normalized = Vec::with_capacity(entries.len()); + for entry in entries { + let BencodeValue::Bytes(bytes) = entry else { + return Err("torrent url-list contains a non-text web seed".to_string()); + }; + let value = String::from_utf8(bytes.clone()) + .map_err(|_| "torrent url-list contains invalid UTF-8".to_string())?; + let value = value.trim(); + let uri = bounded_uri(value, &["http", "https"]) + .ok_or_else(|| "torrent url-list contains an invalid HTTP(S) web seed".to_string())?; + if !normalized.contains(&uri) { + normalized.push(uri); + } + } + Ok(normalized) +} + pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result { if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES { return Err(format!( @@ -429,6 +486,7 @@ pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result value, _ => return Err("torrent info dictionary is invalid".to_string()), @@ -484,7 +542,7 @@ pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result Result { .filter(|value| !value.trim().is_empty()) .map(|value| crate::download_ownership::canonical_download_filename(&value)) .unwrap_or_else(|| format!("torrent-{info_hash}")); - Ok(ParsedTorrent { name, total_bytes: 0, files: Vec::new(), info_hash }) + Ok(ParsedTorrent { name, total_bytes: 0, files: Vec::new(), info_hash, web_seeds: Vec::new() }) +} + +/// Return the Magnet URI form that Firelink may hand to Aria2. Direct source +/// parameters can make Aria2 fetch arbitrary HTTP/FTP/SFTP resources during +/// metadata resolution, so keep the peer/tracker identity parameters but +/// remove `ws`, `as`, and `xs` sources. Users can add validated web seeds after +/// metadata is available through the transactional per-file path. +pub fn sanitize_magnet_uri_for_aria2(source: &str) -> Result { + let mut parsed = url::Url::parse(source.trim()).map_err(|_| "invalid magnet URI".to_string())?; + if parsed.scheme() != "magnet" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + || parsed.host_str().is_some() + || parsed.port().is_some() + { + return Err("magnet URI contains an invalid authority or fragment".to_string()); + } + + let mut has_info_hash = false; + let mut query = url::form_urlencoded::Serializer::new(String::new()); + for (key, value) in parsed.query_pairs() { + if matches!(key.as_ref(), "ws" | "as" | "xs") { + continue; + } + if key == "xt" { + let hash = value + .strip_prefix("urn:btih:") + .and_then(canonical_btih) + .ok_or_else(|| "magnet URI has no valid BitTorrent info hash".to_string())?; + query.append_pair("xt", &format!("urn:btih:{hash}")); + has_info_hash = true; + } else { + query.append_pair(&key, &value); + } + } + if !has_info_hash { + return Err("magnet URI has no valid BitTorrent info hash".to_string()); + } + let query = query.finish(); + parsed.set_query(Some(&query)); + Ok(parsed.to_string()) } pub fn magnet_allows_cached_metadata(source: &str) -> bool { @@ -1107,6 +1207,46 @@ mod tests { assert_eq!(parsed.info_hash.len(), 40); } + #[test] + fn retains_only_strict_http_web_seeds_for_enqueue_policy_validation() { + let parsed = parse_torrent_bytes( + b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae", + ) + .expect("torrent with an HTTP web seed should parse"); + assert_eq!(parsed.web_seeds, vec!["https://example.test/a"]); + + let credentials = parse_torrent_bytes( + b"d4:infod6:lengthi5e4:name4:teste8:url-list32:https://user:pass@example.test/aee", + ); + assert!(credentials.is_err()); + } + + #[test] + fn sanitizing_torrent_web_seeds_preserves_the_info_hash() { + let bytes = b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae"; + let original = parse_torrent_bytes(bytes).expect("torrent should parse"); + let (sanitized, web_seeds) = sanitize_torrent_bytes_for_aria2(bytes) + .expect("torrent should be sanitized"); + let parsed = parse_torrent_bytes(&sanitized).expect("sanitized torrent should parse"); + + assert_eq!(web_seeds, vec!["https://example.test/a"]); + assert!(parsed.web_seeds.is_empty()); + assert_eq!(parsed.info_hash, original.info_hash); + } + + #[test] + fn magnet_sanitization_removes_direct_sources_but_preserves_identity() { + let sanitized = sanitize_magnet_uri_for_aria2( + "magnet:?xt=urn:btih:0123456789012345678901234567890123456789&dn=demo&tr=udp%3A%2F%2Ftracker.example%3A80&ws=https%3A%2F%2Flocal.example%2Ffile&as=https%3A%2F%2Fother.example%2Ffile", + ) + .expect("magnet should sanitize"); + assert!(sanitized.contains("xt=urn%3Abtih%3A0123456789012345678901234567890123456789")); + assert!(sanitized.contains("dn=demo")); + assert!(sanitized.contains("tr=udp%3A%2F%2Ftracker.example%3A80")); + assert!(!sanitized.contains("ws=")); + assert!(!sanitized.contains("as=")); + } + #[test] fn validates_torrent_output_names_as_single_safe_components() { for name in ["test", "My Torrent (1)", "archive.tar"] { diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index f1635e5..d82a5a3 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -4,4 +4,4 @@ import type { DownloadErrorKind } from "./DownloadErrorKind"; import type { DownloadStatus } from "./DownloadStatus"; import type { TorrentWebSeed } from "./TorrentWebSeed"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array, torrentWebSeedsNative?: Array, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, }; +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, sftpHostKeyMd?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, credentialsRequired?: boolean, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array, torrentWebSeedsNative?: Array, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, }; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index ba2d056..1b04d86 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { TorrentWebSeed } from "./TorrentWebSeed"; -export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, }; +export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 32327a5..199f495 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -284,6 +284,7 @@ export const AddDownloadsModal = () => { const [useAuth, setUseAuth] = useState(false); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [sftpHostKeyMd, setSftpHostKeyMd] = useState(''); const [advancedExpanded, setAdvancedExpanded] = useState(false); const [playlistQualityExpanded, setPlaylistQualityExpanded] = useState(true); @@ -411,6 +412,7 @@ export const AddDownloadsModal = () => { setUseAuth(false); setUsername(''); setPassword(''); + setSftpHostKeyMd(''); setAdvancedExpanded(false); setChecksumEnabled(false); setChecksumAlgo('SHA-256'); @@ -1515,6 +1517,9 @@ export const AddDownloadsModal = () => { speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined, username: useAuth ? username.trim() : undefined, password: useAuth ? password.trim() : undefined, + sftpHostKeyMd: !item.isTorrent && item.sourceUrl.trim().toLowerCase().startsWith('sftp:') + ? sftpHostKeyMd.trim() || undefined + : undefined, headers: headersForRow(contextUrl) || undefined, checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgo}=${checksumValue.trim()}` @@ -1706,6 +1711,9 @@ export const AddDownloadsModal = () => { return Boolean(selected && selected.length > 0 && selected.length < item.torrentFiles.length); }; const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex]; + const hasSftpRows = parsedItems.some(item => item.selected !== false + && !item.isTorrent + && item.sourceUrl.trim().toLowerCase().startsWith('sftp:')); const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl; const selectedPlaylistRows = selectedPlaylistSourceUrl ? parsedItems.filter(item => item.playlistSourceUrl === selectedPlaylistSourceUrl && item.selected !== false) @@ -2854,6 +2862,23 @@ export const AddDownloadsModal = () => { )} + {hasSftpRows && ( +
+ + setSftpHostKeyMd(event.target.value)} + placeholder={t($ => $.addDownloads.sftpHostKeyMdHint)} + className="add-download-control w-full px-3 py-1.5 text-xs font-mono" + autoComplete="off" + /> +

{t($ => $.addDownloads.sftpHostKeyMdDescription)}

+
+ )} +