fix(aria2): harden protocol and torrent transfers

This commit is contained in:
NimBold
2026-08-08 22:33:49 +03:30
parent 6c9950a690
commit c3755ce886
21 changed files with 830 additions and 106 deletions
+5
View File
@@ -182,6 +182,8 @@ pub struct DownloadItem {
#[ts(optional)]
pub password: Option<String>,
#[ts(optional)]
pub sftp_host_key_md: Option<String>,
#[ts(optional)]
pub headers: Option<String>,
#[ts(optional)]
pub checksum: Option<String>,
@@ -207,6 +209,9 @@ pub struct DownloadItem {
pub last_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub credentials_required: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub last_error_kind: Option<DownloadErrorKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
+91 -7
View File
@@ -1726,10 +1726,14 @@ async fn resolve_and_validate_url_host(
let addrs: Vec<_> = if let Ok(ip) = lookup_host.parse::<std::net::IpAddr>() {
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<Vec<String>, 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("<unknown host>")
);
}
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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
+352 -89
View File
@@ -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<Option<String>, 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<Option<String>, 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<String>,
pub username: Option<String>,
pub password: Option<String>,
pub sftp_host_key_md: Option<String>,
pub headers: Option<String>,
pub checksum: Option<String>,
pub cookies: Option<String>,
@@ -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<i32, String> {
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<BoundedRangeSupport, String> {
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(&current).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, &current);
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<String, serde_json::Value>,
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<String, serde_json::Value>,
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::<Vec<_>>().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<String>,
pub username: Option<String>,
pub password: Option<String>,
#[serde(default)]
#[ts(optional)]
pub sftp_host_key_md: Option<String>,
pub headers: Option<String>,
pub checksum: Option<String>,
pub cookies: Option<String>,
@@ -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!({
+144 -4
View File
@@ -15,6 +15,9 @@ pub struct ParsedTorrent {
pub total_bytes: u64,
pub files: Vec<TorrentFile>,
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<String>,
}
#[derive(Debug, Clone)]
@@ -290,7 +293,7 @@ fn parse_info(info: &BencodeValue) -> Result<ParsedTorrent, String> {
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<String> {
@@ -358,7 +361,32 @@ pub fn parse_torrent_bytes(bytes: &[u8]) -> Result<ParsedTorrent, String> {
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<u8>, Vec<String>), 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<String> {
@@ -415,6 +443,35 @@ fn collect_torrent_uris(value: Option<&BencodeValue>, schemes: &[&str]) -> Vec<S
values
}
fn parse_torrent_web_seeds(value: Option<&BencodeValue>) -> Result<Vec<String>, 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<crate::ipc::TorrentDetails, String> {
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<crate::ipc::TorrentDet
.get(b"info".as_slice())
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
let parsed = parse_info(info)?;
let web_seeds = parse_torrent_web_seeds(root.get(b"url-list".as_slice()))?;
let info_dict = match info {
BencodeValue::Dict(value) => value,
_ => return Err("torrent info dictionary is invalid".to_string()),
@@ -484,7 +542,7 @@ pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result<crate::ipc::TorrentDet
4_096,
),
trackers,
web_seeds: collect_torrent_uris(root.get(b"url-list".as_slice()), &["http", "https"]),
web_seeds,
})
}
@@ -542,7 +600,49 @@ fn magnet_metadata(source: &str) -> Result<ParsedTorrent, String> {
.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<String, String> {
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"] {