Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions crates/exarch-core/tests/security/cve_regression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}

// ── GHSA-2367-c296-3mp2 variant: hardlink inode corruption (issue #130) ──────
//
// When a TAR archive contains a hardlink entry whose link name is later reused
Expand Down
87 changes: 85 additions & 2 deletions tests/cve/rustsec_2026_0067.rs
Original file line number Diff line number Diff line change
@@ -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 -> <external path>`.
//! 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;
Expand Down Expand Up @@ -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<u8> {
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");
Expand Down
Loading