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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **7z backslash path traversal (#376)**: Entry names with embedded `\` (e.g. `..\..\x`)
are now normalized to `/`-separated paths before validation. Previously, on Unix, such
names were treated as a single path component and slipped past traversal detection; they
are now correctly rejected as `PathTraversal` errors.

- **DRY: centralized entry-name normalization (#365)**: Extracted
`formats::common::normalize_entry_name` as the single shared point for `\` → `/`
normalization. The 7z handler now calls this helper in the pre-validation loop, the
extraction callback, and the list/verify path (`inspection/list.rs`), so that all three
operations agree on traversal detection. `SafePath::validate` documents the caller contract
that entry names must be normalized before `PathBuf` construction.

### Changed

- Refreshed transitive dependencies via `cargo update` (18 lock entries updated, no direct manifest changes).
Expand Down
28 changes: 28 additions & 0 deletions crates/exarch-core/src/formats/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,34 @@ impl Default for DirCache {
}
}

/// Normalizes a raw archive entry name by replacing backslashes with forward
/// slashes.
///
/// Windows-created archives (particularly 7z) may embed `\` as a path
/// separator. On Unix, `\` is a valid filename character, so
/// `PathBuf::from("..\\..\\x")` produces a single path component rather than
/// three — bypassing traversal detection. Callers must normalize before
/// constructing a `PathBuf` and passing to `SafePath::validate`.
///
/// This is a no-op for entry names that contain no `\`.
///
/// # Examples
///
/// ```ignore
/// # use exarch_core::formats::common::normalize_entry_name;
/// assert_eq!(normalize_entry_name("foo\\bar\\baz.txt"), "foo/bar/baz.txt");
/// assert_eq!(normalize_entry_name("plain/path.txt"), "plain/path.txt");
/// ```
#[inline]
#[must_use]
pub fn normalize_entry_name(name: &str) -> String {
if name.contains('\\') {
name.replace('\\', "/")
} else {
name.to_owned()
}
}

/// Creates a file with permissions enforced after creation to bypass umask.
///
/// On Unix platforms, this function uses `OpenOptions::mode()` to hint the
Expand Down
32 changes: 30 additions & 2 deletions crates/exarch-core/src/formats/sevenz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ impl<R: Read + Seek> SevenZArchive<R> {
let mut extract_fn = |entry: &sevenz_rust2::ArchiveEntry,
reader: &mut dyn Read|
-> std::result::Result<bool, sevenz_rust2::Error> {
let entry_path = std::path::PathBuf::from(&entry.name);
let entry_path = std::path::PathBuf::from(common::normalize_entry_name(&entry.name));
ctx.current_idx = ctx.current_idx.saturating_add(1);
let idx = ctx.current_idx;
ctx.progress
Expand Down Expand Up @@ -557,7 +557,7 @@ impl<R: Read + Seek> ArchiveFormat for SevenZArchive<R> {
// - symlink detection: Not exposed, non-directory entries treated as files
let mut prevalidator = EntryValidator::new(config, &dest);
for entry in &self.entries {
let path = std::path::PathBuf::from(&entry.name);
let path = std::path::PathBuf::from(common::normalize_entry_name(&entry.name));
let entry_type = if entry.is_directory {
EntryType::Directory
} else {
Expand Down Expand Up @@ -1513,6 +1513,34 @@ mod tests {
writer.finish().unwrap().into_inner()
}

/// Test #376: entry names with embedded `\` must be treated as path
/// separators.
///
/// On Unix, `PathBuf::from("..\\..\\x")` would produce a single component,
/// bypassing traversal detection. After normalization `\` → `/`, the name
/// becomes `../../x` and must be rejected.
#[test]
fn test_7z_backslash_entry_rejected() {
let data = make_sevenz_archive(&[("..\\..\\x", b"payload")]);
let mut archive = SevenZArchive::new(Cursor::new(data)).unwrap();

let temp = TempDir::new().unwrap();
let result = archive.extract(
temp.path(),
&SecurityConfig::default(),
&ExtractionOptions::default(),
&mut crate::NoopProgress,
);
assert!(
result.is_err(),
"backslash-encoded traversal must be rejected, got: {result:?}"
);
assert!(
matches!(result.unwrap_err(), ArchiveError::PathTraversal { .. }),
"expected PathTraversal error"
);
}

#[test]
fn test_7z_absolute_path_rejected_by_default() {
let data = make_sevenz_archive(&[("/etc/shadow", b"secret")]);
Expand Down
3 changes: 2 additions & 1 deletion crates/exarch-core/src/inspection/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::ArchiveError;
use crate::Result;
use crate::SecurityConfig;
use crate::error::QuotaResource;
use crate::formats::common::normalize_entry_name;
use crate::formats::detect::ArchiveType;
use crate::formats::detect::detect_format;
use crate::formats::zip::read_zip_symlink_target;
Expand Down Expand Up @@ -452,7 +453,7 @@ fn list_sevenz_archive(
});
}

let path = PathBuf::from(&entry.name);
let path = PathBuf::from(normalize_entry_name(&entry.name));

if contains_traversal(&path, config) {
return Err(ArchiveError::PathTraversal { path });
Expand Down
9 changes: 9 additions & 0 deletions crates/exarch-core/src/types/safe_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ impl SafePath {
/// returns `None` are pre-handled in `resolve_entry_path` before reaching
/// this method.
///
/// # Caller contract
///
/// Callers MUST normalize `\` to `/` in entry names before constructing a
/// `PathBuf` and passing it here. On Unix, `\` is a valid filename
/// character, so `PathBuf::from("..\\..\\x")` yields a single component
/// rather than three — defeating traversal detection. Use
/// `formats::common::normalize_entry_name` for 7z (and any format that
/// stores Windows-style paths) before calling `validate`.
///
/// # Errors
///
/// Returns an error if any validation step fails:
Expand Down
Loading