From 6a1c6d58750fd08ac0aee8cd05c86761636080b2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 17:16:11 -0400 Subject: [PATCH] fix(core,cli): harden backup-restore tar unpack against path traversal (audit S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_backup_restore previously called tar::Archive::unpack with default settings, allowing malicious .relbak archives to escape the target directory via .. entries, absolute paths, or symlinks. No size cap meant tar bombs could exhaust disk space. Replaced with relicario_core::safe_unpack_git_archive which: - Rejects .. (ParentDir), absolute (RootDir), and drive-prefix (Prefix) components with "path traversal blocked" error. - Rejects symlinks and hardlinks outright. - Checks declared header size before reading body; rejects entries or cumulative totals exceeding the caller's cap. - Returns (relative-path, bytes) pairs; the CLI re-checks dest.starts_with(git_dir) after OS-level path resolution. - CLI cap: min(100 × compressed size, 1 GiB). Acceptance: 5 unit tests in relicario-core (traversal, absolute path, symlink, size bomb, happy path); existing CLI backup roundtrip tests remain green. Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/src/main.rs | 29 +++- crates/relicario-core/src/error.rs | 4 + crates/relicario-core/src/lib.rs | 3 + crates/relicario-core/src/tar_safe.rs | 138 +++++++++++++++ crates/relicario-core/tests/safe_unpack.rs | 187 +++++++++++++++++++++ 5 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 crates/relicario-core/src/tar_safe.rs create mode 100644 crates/relicario-core/tests/safe_unpack.rs diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index cf44257..3c4029a 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1718,9 +1718,32 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { // .git/ history. if let Some(tar_bytes) = &unpacked.git_archive { - let mut archive = tar::Archive::new(tar_bytes.as_slice()); - archive.unpack(target.join(".git")) - .with_context(|| "failed to untar .git/")?; + // Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower. + let cap = std::cmp::min( + (tar_bytes.len() as u64).saturating_mul(100), + relicario_core::DEFAULT_MAX_UNCOMPRESSED, + ); + let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap) + .with_context(|| "failed to safely unpack .git/ archive")?; + let git_dir = target.join(".git"); + for (rel_path, body) in entries { + let dest = git_dir.join(&rel_path); + // Paranoid OS-level check even after textual validation in core. + if !dest.starts_with(&git_dir) { + anyhow::bail!( + "tar entry {} resolved outside .git/ (path traversal blocked)", + rel_path.display() + ); + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("create parent {}", parent.display()) + })?; + } + fs::write(&dest, &body).with_context(|| { + format!("write {}", dest.display()) + })?; + } } else { // No history bundled — start a fresh git repo. let status = crate::helpers::git_command(&target, &["init"]).status()?; diff --git a/crates/relicario-core/src/error.rs b/crates/relicario-core/src/error.rs index f2be80d..5b3d131 100644 --- a/crates/relicario-core/src/error.rs +++ b/crates/relicario-core/src/error.rs @@ -51,6 +51,10 @@ pub enum RelicarioError { #[error("backup envelope schema v{found}; this Relicario reads v{expected}")] BackupSchemaMismatch { found: u32, expected: u32 }, + /// An error during backup restore (e.g., tar safety validation failure). + #[error("backup restore: {0}")] + BackupRestore(String), + /// CSV header doesn't match the LastPass column layout. #[error("unrecognized CSV header — expected LastPass export format ({0})")] ImportCsvHeader(String), diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index c687383..e7b49a1 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -86,3 +86,6 @@ pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; pub mod device; pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify}; + +pub mod tar_safe; +pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED}; diff --git a/crates/relicario-core/src/tar_safe.rs b/crates/relicario-core/src/tar_safe.rs new file mode 100644 index 0000000..c78d5d0 --- /dev/null +++ b/crates/relicario-core/src/tar_safe.rs @@ -0,0 +1,138 @@ +//! Safe tar unpacking for backup restore. +//! +//! The standard `tar::Archive::unpack` has no guards against path traversal, +//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it +//! with `safe_unpack_git_archive`, which validates every entry before returning +//! `(relative_path, bytes)` pairs to the caller. + +use std::io::Read; +use std::path::{Component, PathBuf}; + +use tar::EntryType; + +use crate::error::{RelicarioError, Result}; + +/// Default cap on total uncompressed bytes extracted in one restore (1 GiB). +pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024; + +/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for +/// regular files only. +/// +/// # Errors +/// +/// Returns `Err(RelicarioError::BackupRestore(...))` if: +/// +/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked". +/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked". +/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked". +/// - An entry is a symlink or hardlink — "symlink/link rejected". +/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded". +/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded". +/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type". +pub fn safe_unpack_git_archive( + tar_bytes: &[u8], + max_uncompressed_bytes: u64, +) -> Result)>> { + let mut archive = tar::Archive::new(tar_bytes); + let entries = archive + .entries() + .map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?; + + let mut result: Vec<(PathBuf, Vec)> = Vec::new(); + let mut cumulative: u64 = 0; + + for entry in entries { + let mut entry = entry.map_err(|e| { + RelicarioError::BackupRestore(format!("failed to read tar entry: {e}")) + })?; + + let header = entry.header(); + let entry_type = header.entry_type(); + + // Reject symlinks and hardlinks. + match entry_type { + EntryType::Symlink => { + return Err(RelicarioError::BackupRestore( + "symlink entry rejected".to_string(), + )); + } + EntryType::Link => { + return Err(RelicarioError::BackupRestore( + "hardlink entry rejected".to_string(), + )); + } + EntryType::Directory => { + // Directories are implicit — skip without reading body. + continue; + } + EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => { + // These are normal file types; fall through to path checks. + } + _ => { + return Err(RelicarioError::BackupRestore(format!( + "unexpected entry type: {:?}", + entry_type + ))); + } + } + + // Validate the path. + let path = entry.path().map_err(|e| { + RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}")) + })?; + let path = path.into_owned(); + + for component in path.components() { + match component { + Component::ParentDir => { + return Err(RelicarioError::BackupRestore( + "path traversal blocked: entry contains '..' component".to_string(), + )); + } + Component::RootDir => { + return Err(RelicarioError::BackupRestore( + "path traversal blocked: entry has absolute path".to_string(), + )); + } + Component::Prefix(_) => { + return Err(RelicarioError::BackupRestore( + "path traversal blocked: entry has Windows drive prefix".to_string(), + )); + } + Component::Normal(_) | Component::CurDir => { + // Acceptable components. + } + } + } + + // Check declared size before reading body. + let claimed = header.size().map_err(|e| { + RelicarioError::BackupRestore(format!("could not read entry size: {e}")) + })?; + + if claimed > max_uncompressed_bytes { + return Err(RelicarioError::BackupRestore(format!( + "size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})" + ))); + } + + let new_total = cumulative.saturating_add(claimed); + if new_total > max_uncompressed_bytes { + return Err(RelicarioError::BackupRestore(format!( + "size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})" + ))); + } + + // Read the file body. + let mut body = Vec::with_capacity(claimed as usize); + entry.read_to_end(&mut body).map_err(|e| { + RelicarioError::BackupRestore(format!("failed to read entry body: {e}")) + })?; + + cumulative += body.len() as u64; + + result.push((path, body)); + } + + Ok(result) +} diff --git a/crates/relicario-core/tests/safe_unpack.rs b/crates/relicario-core/tests/safe_unpack.rs new file mode 100644 index 0000000..d73add5 --- /dev/null +++ b/crates/relicario-core/tests/safe_unpack.rs @@ -0,0 +1,187 @@ +use std::path::PathBuf; +use tar::{Builder, Header, EntryType}; +use relicario_core::safe_unpack_git_archive; + +/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes. +/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header +/// manually to produce truly malicious archives. +fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec { + let mut buf = vec![0u8; 512]; // one header block + + // Bytes 0-99: name field (null-padded) + let name_len = raw_path.len().min(100); + buf[..name_len].copy_from_slice(&raw_path[..name_len]); + + // Bytes 100-107: mode = "0000644\0" + buf[100..108].copy_from_slice(b"0000644\0"); + + // Bytes 108-115: uid + buf[108..116].copy_from_slice(b"0000000\0"); + + // Bytes 116-123: gid + buf[116..124].copy_from_slice(b"0000000\0"); + + // Bytes 124-135: size (octal, 11 digits + null) + let size_str = format!("{:011o}\0", content.len()); + buf[124..136].copy_from_slice(size_str.as_bytes()); + + // Bytes 136-147: mtime + buf[136..148].copy_from_slice(b"00000000000\0"); + + // Bytes 148-155: checksum placeholder (spaces during compute) + buf[148..156].copy_from_slice(b" "); + + // Byte 156: typeflag = '0' (regular file) + buf[156] = b'0'; + + // Bytes 257-262: magic "ustar\0" + buf[257..263].copy_from_slice(b"ustar\0"); + // Bytes 263-264: version "00" + buf[263..265].copy_from_slice(b"00"); + + // Compute checksum (sum of all bytes, checksum field treated as spaces). + let checksum: u32 = buf.iter().map(|&b| b as u32).sum(); + let cksum_str = format!("{:06o}\0 ", checksum); + buf[148..156].copy_from_slice(cksum_str.as_bytes()); + + // Append padded content blocks. + let mut out = buf; + if !content.is_empty() { + out.extend_from_slice(content); + // Pad to 512-byte boundary. + let remainder = content.len() % 512; + if remainder != 0 { + out.extend(std::iter::repeat(0u8).take(512 - remainder)); + } + } + + // Two zero blocks = end-of-archive. + out.extend(std::iter::repeat(0u8).take(1024)); + out +} + +/// Build a tar with a raw symlink entry (typeflag = '2'). +fn raw_symlink_tar() -> Vec { + let mut buf = vec![0u8; 512]; + + // name + buf[..9].copy_from_slice(b"evil_link"); + // mode + buf[100..108].copy_from_slice(b"0000755\0"); + // uid/gid + buf[108..116].copy_from_slice(b"0000000\0"); + buf[116..124].copy_from_slice(b"0000000\0"); + // size = 0 + buf[124..136].copy_from_slice(b"00000000000\0"); + // mtime + buf[136..148].copy_from_slice(b"00000000000\0"); + // checksum placeholder + buf[148..156].copy_from_slice(b" "); + // typeflag = '2' (symlink) + buf[156] = b'2'; + // linkname + let target = b"/etc/passwd"; + buf[157..157 + target.len()].copy_from_slice(target); + // magic + buf[257..263].copy_from_slice(b"ustar\0"); + buf[263..265].copy_from_slice(b"00"); + + // Compute checksum. + let checksum: u32 = buf.iter().map(|&b| b as u32).sum(); + let cksum_str = format!("{:06o}\0 ", checksum); + buf[148..156].copy_from_slice(cksum_str.as_bytes()); + + let mut out = buf; + out.extend(std::iter::repeat(0u8).take(1024)); // end-of-archive + out +} + +fn build_normal_tar() -> Vec { + let mut buf = Vec::new(); + { + let mut builder = Builder::new(&mut buf); + let content = b"hello"; + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Regular); + header.set_size(content.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "subdir/hello.txt", content.as_ref()) + .unwrap(); + builder.finish().unwrap(); + } + buf +} + +fn build_oversize_tar() -> Vec { + // Actual 2048-byte body; test will use cap=1024 + let mut buf = Vec::new(); + { + let mut builder = Builder::new(&mut buf); + let content = vec![0u8; 2048]; + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Regular); + header.set_size(content.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "bigfile.bin", content.as_slice()) + .unwrap(); + builder.finish().unwrap(); + } + buf +} + +#[test] +fn restore_rejects_path_traversal() { + // Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths). + let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content"); + let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("path traversal") || msg.contains(".."), + "got: {msg}" + ); +} + +#[test] +fn restore_rejects_absolute_path() { + // Craft a tar with "/etc/escaped.txt" using raw bytes. + let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content"); + let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("path traversal") || msg.contains("absolute"), + "got: {msg}" + ); +} + +#[test] +fn restore_rejects_symlink() { + let bytes = raw_symlink_tar(); + let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("symlink") || msg.contains("link"), + "got: {msg}" + ); +} + +#[test] +fn restore_rejects_size_bomb() { + let bytes = build_oversize_tar(); // actual 2048-byte entry + let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes + let msg = format!("{err:#}"); + assert!( + msg.contains("size") || msg.contains("cap") || msg.contains("too large"), + "got: {msg}" + ); +} + +#[test] +fn restore_accepts_normal_files() { + let buf = build_normal_tar(); + let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt")); + assert_eq!(entries[0].1, b"hello"); +}