fix(downloads): confine download paths to the download root
Both halves of the path a download writes to arrived from the frontend
unchecked. `start_download` and the queue pump built their target as
`PathBuf::from(target_dir).join(file_path)`, and `mark_download_completed`
stored a frontend-supplied `file_path` on the row verbatim — the same
column that is later read back into `std::fs::remove_file` when a
download is deleted. A correct sanitiser already existed and
`download_item_and_start` used it, but `download_item` is a command in
its own right, so calling it directly routed the guard around.
The guard moves inside. `confine_to_root` folds `..` away lexically and
requires the result to sit inside the storage root, modelled on
`media_server::resolve_path` — the check comes after the join because
`Path::join` drops the base when the joined half is absolute, so an
absolute `file_path` is obeyed rather than folded. `confine_queued_path`
sanitises a queued path per component (so the already-safe name
`download_item_and_start` passes in is not sanitised into a second,
different one) and confines it. Applied in `download_item`, at both join
sites, and to what `mark_download_completed` writes.
Every path the app builds for itself is returned unchanged, including
the absolute ones `download_series`/`download_season` produce from
`${targetDir}/videos`, so no existing row or file on disk is orphaned.
The pump fails an offending row rather than skipping it, because the
pump re-queries and would otherwise not terminate.
Not a live vulnerability: reaching these commands with hostile input
needs script execution in a webview whose CSP is `script-src 'self'`.
This is hardening and consistency.
TRACES: DR-211 | UT-205
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use std::path::PathBuf;
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::{Manager, State};
|
use tauri::{Manager, State};
|
||||||
|
|
||||||
@@ -132,6 +132,73 @@ fn sanitize_filename(name: &str) -> String {
|
|||||||
.collect()
|
.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
|
/// Request payload for download_item_and_start (bundled to stay within specta's
|
||||||
/// 10-argument command limit).
|
/// 10-argument command limit).
|
||||||
#[derive(Debug, specta::Type, serde::Deserialize)]
|
#[derive(Debug, specta::Type, serde::Deserialize)]
|
||||||
@@ -253,6 +320,18 @@ pub async fn download_item(
|
|||||||
album_name,
|
album_name,
|
||||||
expected_size,
|
expected_size,
|
||||||
} = request;
|
} = 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 db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
@@ -1286,6 +1365,20 @@ pub async fn mark_download_completed(
|
|||||||
bytes_downloaded: i64,
|
bytes_downloaded: i64,
|
||||||
file_path: String,
|
file_path: String,
|
||||||
) -> Result<(), 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 db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
@@ -1416,6 +1509,14 @@ pub async fn start_download(
|
|||||||
item_id, file_path, file_size
|
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
|
// Make a HEAD request to get the file size from Content-Length header
|
||||||
debug!("Making HEAD request to get file size...");
|
debug!("Making HEAD request to get file size...");
|
||||||
let head_response = reqwest::Client::new().head(&stream_url).send().await;
|
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),
|
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
|
// Get a clone of the active downloads Arc for unregistering later
|
||||||
let active_downloads = {
|
let active_downloads = {
|
||||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
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
|
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
|
// Register the slot. If registration fails (race: another pump filled
|
||||||
// the last slot), stop — we'll be re-pumped when a slot frees.
|
// 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(
|
spawn_download_worker(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
download_id,
|
download_id,
|
||||||
@@ -2421,6 +2548,90 @@ mod tests {
|
|||||||
assert_eq!(sanitize_filename("track/1.flac"), "track_1.flac");
|
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
|
/// Helper to set up test database with required foreign key data
|
||||||
fn setup_test_db() -> Database {
|
fn setup_test_db() -> Database {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user