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"); +}