From 0062515161f740d290838f6ff80cc7f2398c5285 Mon Sep 17 00:00:00 2001 From: "Andrei G." Date: Wed, 25 Mar 2026 22:02:23 +0100 Subject: [PATCH] test: add regression fixture for RUSTSEC-2026-0067 symlink+dir chmod (#132) Add two CVE regression tests for RUSTSEC-2026-0067 / CVE-2026-33056 / GHSA-j4xf-2g29-59ph. The attack chains a symlink entry `subdir -> ../external` with a subsequent directory entry `subdir`; tar-rs < 0.4.45 would follow the symlink and chmod the external directory. The new tests verify exarch's security layer blocks this before reaching tar-rs: with default config (symlinks disabled) the symlink entry is rejected; with allow_symlinks=true the escape target is rejected as SymlinkEscape. Both tests assert no directories are created outside the extraction root. --- CHANGELOG.md | 9 ++ .../tests/security/cve_regression.rs | 71 +++++++++++++++ tests/cve/rustsec_2026_0067.rs | 87 ++++++++++++++++++- 3 files changed, 165 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b4a8b..705a6ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Tests + +- Add regression tests for RUSTSEC-2026-0067 symlink+directory chmod attack + (CVE-2026-33056 / GHSA-j4xf-2g29-59ph). Two new test cases verify that an + archive combining `subdir -> ../external` (symlink) followed by a directory + entry `subdir` is rejected before tar-rs can chmod the external directory — + both with default config (symlinks disabled) and with `allow_symlinks = true` + (#132). + ### Security - Fix two-hop symlink chain bypass in `SafeSymlink` and `SafeHardlink` validation diff --git a/crates/exarch-core/tests/security/cve_regression.rs b/crates/exarch-core/tests/security/cve_regression.rs index fd6973b..b25a168 100644 --- a/crates/exarch-core/tests/security/cve_regression.rs +++ b/crates/exarch-core/tests/security/cve_regression.rs @@ -273,6 +273,77 @@ fn test_cve_2025_48387_absolute_hardlink_rejected() { ); } +// ── RUSTSEC-2026-0067 / CVE-2026-33056: tar-rs symlink+directory chmod ─────── +// +// tar-rs < 0.4.45 followed symlinks when applying chmod during directory entry +// extraction. An archive that creates `subdir -> ../external` (symlink) and +// then a directory entry `subdir` would chmod the external directory on the +// host filesystem. Fixed in tar-rs 0.4.45. +// +// exarch blocks this at the security layer: the symlink entry is rejected +// (symlinks disabled by default), so the subsequent directory entry either +// also fails or is harmless because the symlink was never written. + +#[test] +fn test_rustsec_2026_0067_symlink_dir_chmod_default_config() { + // Default config: symlinks are disabled; the symlink entry must be rejected + // before tar-rs ever has a chance to follow it for the directory chmod. + let tar_data = TarTestBuilder::new() + .add_symlink("subdir", "../external") + .add_directory("subdir/") + .build(); + + let temp = TempDir::new().unwrap(); + let mut archive = TarArchive::new(Cursor::new(tar_data)); + let result = archive.extract(temp.path(), &SecurityConfig::default()); + + assert!( + matches!( + result, + Err(ExtractionError::SecurityViolation { .. } + | ExtractionError::SymlinkEscape { .. } + | ExtractionError::PathTraversal { .. }) + ), + "symlink+dir chmod attack must be rejected with default config, got: {result:?}" + ); + + // No files must exist outside the extraction root. + let external = temp.path().parent().unwrap().join("external"); + assert!( + !external.exists(), + "extraction must not create directories outside root" + ); +} + +#[test] +fn test_rustsec_2026_0067_symlink_dir_chmod_symlinks_allowed() { + // With symlinks enabled, the symlink `subdir -> ../external` points outside + // the extraction root and must be rejected with SymlinkEscape. + let tar_data = TarTestBuilder::new() + .add_symlink("subdir", "../external") + .add_directory("subdir/") + .build(); + + let temp = TempDir::new().unwrap(); + let mut config = SecurityConfig::default(); + config.allowed.symlinks = true; + + let mut archive = TarArchive::new(Cursor::new(tar_data)); + let result = archive.extract(temp.path(), &config); + + assert!( + matches!(result, Err(ExtractionError::SymlinkEscape { .. })), + "symlink escaping root must be rejected even when symlinks are allowed, got: {result:?}" + ); + + // No files must exist outside the extraction root. + let external = temp.path().parent().unwrap().join("external"); + assert!( + !external.exists(), + "extraction must not create directories outside root" + ); +} + // ── Windows backslash path traversal ───────────────────────────────────────── // // Archives created on Windows may use `\` as a path separator. On Windows diff --git a/tests/cve/rustsec_2026_0067.rs b/tests/cve/rustsec_2026_0067.rs index 7dfd1a6..29ae909 100644 --- a/tests/cve/rustsec_2026_0067.rs +++ b/tests/cve/rustsec_2026_0067.rs @@ -1,15 +1,23 @@ -//! Regression test for RUSTSEC-2026-0067. +//! Regression tests for RUSTSEC-2026-0067 / CVE-2026-33056 / GHSA-j4xf-2g29-59ph. //! //! `tar 0.4.44` `unpack_in` followed symlinks when applying permissions //! (`chmod`), allowing an attacker to change permissions on arbitrary //! directories outside the extraction root via a crafted symlink entry. //! Fixed in `tar 0.4.45`. //! -//! This test verifies that: +//! Attack pattern (symlink+directory chmod): +//! 1. Archive contains symlink `subdir -> `. +//! 2. Archive contains directory entry `subdir` (or `subdir/child`). +//! 3. tar-rs < 0.4.45 would follow the symlink and `chmod` the external directory. +//! +//! These tests verify that: //! 1. Normal extraction with symlinks disabled works correctly. //! 2. A symlink entry pointing outside the extraction root is rejected by //! exarch's security layer before reaching `tar::unpack_in`. +//! 3. The specific symlink+directory chmod attack pattern is blocked even when +//! the symlink uses a relative (`../`) escape. +use exarch_core::ExtractionError; use exarch_core::formats::TarArchive; use exarch_core::formats::traits::ArchiveFormat; use exarch_core::SecurityConfig; @@ -74,6 +82,81 @@ fn test_rustsec_2026_0067_symlink_escape_rejected() { } } +/// Build a TAR archive that reproduces the exact RUSTSEC-2026-0067 chmod attack: +/// symlink `subdir -> ../external`, then directory entry `subdir`. +/// +/// On tar-rs < 0.4.45 this would `chmod` the directory that `../external` resolves +/// to (i.e., outside the extraction root). On patched versions the symlink is +/// never followed for permission operations. +fn build_symlink_dir_chmod_tar() -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + + // Entry 1: symlink "subdir" -> "../external" (relative escape) + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_cksum(); + builder + .append_link(&mut header, "subdir", "../external") + .expect("failed to append symlink"); + + // Entry 2: directory "subdir" — the chmod attack vector + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, "subdir/", &[] as &[u8]) + .expect("failed to append dir"); + + builder.into_inner().expect("failed to finish builder") +} + +/// RUSTSEC-2026-0067 symlink+directory chmod attack is blocked. +/// +/// Verifies that a TAR archive containing a symlink followed by a directory +/// entry with the same name does not escape the extraction root or silently +/// chmod an external directory. Extraction must either be rejected with a +/// security error (`SymlinkEscape` / `PathTraversal`) or complete without +/// writing the symlink outside the root. +#[test] +fn test_rustsec_2026_0067_symlink_dir_chmod_blocked() { + let temp = TempDir::new().expect("failed to create temp dir"); + let config = SecurityConfig::default(); // symlinks disabled by default + + let tar_data = build_symlink_dir_chmod_tar(); + let mut archive = TarArchive::new(Cursor::new(tar_data)); + let result = ArchiveFormat::extract(&mut archive, temp.path(), &config); + + match result { + Err(ExtractionError::SymlinkEscape { .. } | ExtractionError::PathTraversal { .. }) => { + // Ideal: exarch's security layer rejected the escape attempt. + } + Err(other) => { + // Any other error is also acceptable — what matters is that + // the archive did not silently apply permissions externally. + let _ = other; + } + Ok(_) => { + // Extraction succeeded — assert the symlink was not written. + let subdir = temp.path().join("subdir"); + assert!( + !subdir.is_symlink(), + "symlink 'subdir' must not be extracted when symlinks are disabled" + ); + } + } + + // Regardless of outcome, nothing must exist outside the extraction root. + let external = temp.path().parent().expect("temp has parent").join("external"); + assert!( + !external.exists(), + "external directory must not be created outside extraction root" + ); +} + #[test] fn test_rustsec_2026_0067_normal_extraction_unaffected() { let temp = TempDir::new().expect("failed to create temp dir");