fix(downloads): confine download paths to the download root
file_path and target_dir reached PathBuf::join unchecked from the frontend, and mark_download_completed stored a caller-supplied path that is later fed to remove_file. A correct sanitiser already existed — download_item_and_start used it — but download_item is itself a command taking file_path raw, so the guard was simply routed around. It now lives inside download_item, alongside a join-then-confine check modelled on media_server::resolve_path. Sanitising is per path component, not whole-string: the latter would silently turn downloads/x.mp3 into downloads_x.mp3 and relocate every existing download. TRACES: | DR-211 | UT-205
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
#[cfg(test)]
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::{debug, error, info, warn};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
@@ -132,6 +132,73 @@ fn sanitize_filename(name: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The directory every download has to stay inside: the storage root
|
||||
/// `storage_get_path` hands the frontend, which is the database's parent.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
fn download_root(db: &DatabaseWrapper) -> Result<PathBuf, String> {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
database
|
||||
.path()
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.ok_or_else(|| "Database path has no parent directory".to_string())
|
||||
}
|
||||
|
||||
/// Fold `..` out of `candidate` and require what is left to sit inside `root`.
|
||||
///
|
||||
/// Lexical rather than `canonicalize`, the same way `media_server::resolve_path`
|
||||
/// does it: the file usually does not exist yet, so canonicalising would fail on
|
||||
/// the ordinary case. The check has to come *after* the caller's join, because
|
||||
/// `Path::join` drops the base when the joined half is absolute — such a path is
|
||||
/// not folded, it is obeyed, and only the `starts_with` below catches it.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String> {
|
||||
let mut resolved = PathBuf::new();
|
||||
for component in candidate.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
other => resolved.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.starts_with(root) {
|
||||
Ok(resolved)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Refusing a download path outside the download directory: {}",
|
||||
candidate.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a queued download's path and confine it to the download directory.
|
||||
///
|
||||
/// Every path the app builds for itself comes back unchanged — files on disk and
|
||||
/// `downloads` rows point at these exact spellings — and [`sanitize_filename`]
|
||||
/// is idempotent, so the already-safe name `download_item_and_start` passes in
|
||||
/// is not sanitized into a second, different one.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String> {
|
||||
let mut sanitized = PathBuf::new();
|
||||
for component in Path::new(file_path).components() {
|
||||
match component {
|
||||
Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())),
|
||||
// Kept as they are, so `confine_to_root` is the single thing
|
||||
// deciding whether what they add up to is still inside the root.
|
||||
other => sanitized.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
confine_to_root(root, &root.join(&sanitized))?;
|
||||
Ok(sanitized.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Request payload for download_item_and_start (bundled to stay within specta's
|
||||
/// 10-argument command limit).
|
||||
#[derive(Debug, specta::Type, serde::Deserialize)]
|
||||
@@ -253,6 +320,18 @@ pub async fn download_item(
|
||||
album_name,
|
||||
expected_size,
|
||||
} = request;
|
||||
|
||||
// `start_download` joins this onto the target directory, and `Path::join`
|
||||
// drops the base when the second half is absolute, so the row itself has to
|
||||
// be confined — not only the place it is used. `download_item_and_start`
|
||||
// sanitizes the name it builds, but `download_item` is a command in its own
|
||||
// right, so that guard was simply routed around by calling this directly.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let file_path = {
|
||||
let root = download_root(&db)?;
|
||||
confine_queued_path(&root, &file_path)?
|
||||
};
|
||||
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -1286,6 +1365,20 @@ pub async fn mark_download_completed(
|
||||
bytes_downloaded: i64,
|
||||
file_path: String,
|
||||
) -> Result<(), String> {
|
||||
// Deleting a download reads this straight back into `std::fs::remove_file`,
|
||||
// so a row must never come to name a file outside the download directory.
|
||||
// The worker reports the absolute path it wrote, and joining an absolute
|
||||
// path onto the root yields it unchanged, so that case is stored verbatim;
|
||||
// the frontend's fallback to the row's own (relative) path resolves under
|
||||
// the root, where the worker put it.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let file_path = {
|
||||
let root = download_root(&db)?;
|
||||
confine_to_root(&root, &root.join(&file_path))?
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -1416,6 +1509,14 @@ pub async fn start_download(
|
||||
item_id, file_path, file_size
|
||||
);
|
||||
|
||||
// Both halves of this join reached us from the frontend, so resolve them
|
||||
// against the download directory before a single byte is written.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let target_path = {
|
||||
let root = download_root(&db)?;
|
||||
confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))?
|
||||
};
|
||||
|
||||
// Make a HEAD request to get the file size from Content-Length header
|
||||
debug!("Making HEAD request to get file size...");
|
||||
let head_response = reqwest::Client::new().head(&stream_url).send().await;
|
||||
@@ -1489,9 +1590,6 @@ pub async fn start_download(
|
||||
Err(e) => error!(" Event emit failed: {:?}", e),
|
||||
}
|
||||
|
||||
// Build target path
|
||||
let target_path = PathBuf::from(&target_dir).join(&file_path);
|
||||
|
||||
// Get a clone of the active downloads Arc for unregistering later
|
||||
let active_downloads = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
@@ -1762,6 +1860,36 @@ pub(crate) async fn pump_download_queue(
|
||||
None => return, // Nothing pending to start
|
||||
};
|
||||
|
||||
// Confine the row's path before it takes a slot. A row whose target
|
||||
// escapes the download directory can never start, so it is failed here
|
||||
// rather than picked again on the next pass — this loop re-queries, so
|
||||
// merely skipping it would not terminate.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let confined = {
|
||||
let db_state = app.state::<DatabaseWrapper>();
|
||||
download_root(&db_state).and_then(|root| {
|
||||
confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))
|
||||
})
|
||||
};
|
||||
let target_path = match confined {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
error!("[pump] Refusing download {}: {}", download_id, e);
|
||||
let fail_query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
|
||||
vec![QueryParam::String(e), QueryParam::Int64(download_id)],
|
||||
);
|
||||
if let Err(db_err) = db_service.execute(fail_query).await {
|
||||
error!(
|
||||
"[pump] Failed to mark download {} failed: {}",
|
||||
download_id, db_err
|
||||
);
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Register the slot. If registration fails (race: another pump filled
|
||||
// the last slot), stop — we'll be re-pumped when a slot frees.
|
||||
{
|
||||
@@ -1809,7 +1937,6 @@ pub(crate) async fn pump_download_queue(
|
||||
},
|
||||
);
|
||||
|
||||
let target_path = PathBuf::from(&target_dir).join(&file_path);
|
||||
spawn_download_worker(
|
||||
app.clone(),
|
||||
download_id,
|
||||
@@ -2421,6 +2548,90 @@ mod tests {
|
||||
assert_eq!(sanitize_filename("track/1.flac"), "track_1.flac");
|
||||
}
|
||||
|
||||
/// The download directory as it looks on a device, for the path tests.
|
||||
const TEST_ROOT: &str = "/data/data/com.dtourolle.jellytau/files";
|
||||
|
||||
/// A queued `file_path` cannot walk out of the download directory.
|
||||
///
|
||||
/// `download_item` is a command in its own right, so sanitizing in
|
||||
/// `download_item_and_start` was routed around by invoking it directly, and
|
||||
/// `start_download` then joined the raw string onto the target directory.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
#[test]
|
||||
fn test_queued_download_paths_cannot_escape_the_download_directory() {
|
||||
let root = Path::new(TEST_ROOT);
|
||||
|
||||
assert!(confine_queued_path(root, "downloads/../../../../etc/cron.d/pwn").is_err());
|
||||
assert!(confine_queued_path(root, "../.bashrc").is_err());
|
||||
assert!(confine_queued_path(root, "/etc/cron.d/pwn").is_err());
|
||||
|
||||
// Why the absolute case needs its own guard rather than folding: the
|
||||
// join the download path performs discards the base entirely.
|
||||
assert_eq!(
|
||||
PathBuf::from(root).join("/etc/cron.d/pwn"),
|
||||
PathBuf::from("/etc/cron.d/pwn")
|
||||
);
|
||||
}
|
||||
|
||||
/// The paths the app builds for itself have to survive unchanged: files are
|
||||
/// already on disk and `downloads` rows point at these exact spellings.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
#[test]
|
||||
fn test_queued_download_paths_are_otherwise_unchanged() {
|
||||
let root = Path::new(TEST_ROOT);
|
||||
|
||||
for path in [
|
||||
"downloads/9f8e7d6c", // MediaCard's queue-for-reconnect
|
||||
"videos/movies/Arrival.mp4", // VideoDownloadButton
|
||||
"albums/abc123/01 - Opening.mp3", // queue_album_tracks
|
||||
// download_series/download_season build an absolute path, because
|
||||
// their base_path is `${targetDir}/videos`.
|
||||
"/data/data/com.dtourolle.jellytau/files/videos/Show/S01E02_Pilot.mp4",
|
||||
] {
|
||||
assert_eq!(confine_queued_path(root, path).unwrap(), path);
|
||||
}
|
||||
|
||||
// `download_item_and_start` sanitizes the name before calling
|
||||
// `download_item`; sanitizing it again must not yield a second, different
|
||||
// name, which would orphan the row and the file it names.
|
||||
let already = format!("downloads/{}.mp3", sanitize_filename("AC/DC: Live?"));
|
||||
assert_eq!(confine_queued_path(root, &already).unwrap(), already);
|
||||
}
|
||||
|
||||
/// A completed row's `file_path` is read straight back into
|
||||
/// `std::fs::remove_file` when the download is deleted, so `mark_download_completed`
|
||||
/// must not be able to register a file outside the download directory.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
#[test]
|
||||
fn test_a_completed_download_cannot_register_a_file_outside_the_root() {
|
||||
let root = Path::new(TEST_ROOT);
|
||||
|
||||
// What the worker actually reports — the absolute path it wrote. Stored
|
||||
// exactly as it arrives.
|
||||
let written = "/data/data/com.dtourolle.jellytau/files/downloads/9f8e7d6c";
|
||||
assert_eq!(
|
||||
confine_to_root(root, Path::new(written)).unwrap(),
|
||||
PathBuf::from(written)
|
||||
);
|
||||
|
||||
// The row's own path, if the frontend falls back to it: relative, and it
|
||||
// resolves to where the worker wrote the file.
|
||||
assert_eq!(
|
||||
confine_to_root(root, &root.join("downloads/9f8e7d6c")).unwrap(),
|
||||
PathBuf::from(written)
|
||||
);
|
||||
|
||||
assert!(confine_to_root(root, Path::new("/home/u/.ssh/id_ed25519")).is_err());
|
||||
assert!(confine_to_root(
|
||||
root,
|
||||
Path::new("/data/data/com.dtourolle.jellytau/files/../../../../etc/passwd")
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
/// Helper to set up test database with required foreign key data
|
||||
fn setup_test_db() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user