Skip to content

Commit 6b8fccd

Browse files
committed
Enclosed
1 parent e98a6a1 commit 6b8fccd

File tree

6 files changed

+41
-35
lines changed

6 files changed

+41
-35
lines changed

Cargo.lock

Lines changed: 0 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ rust-netrc = { version = "0.1.2" }
142142
rustc-hash = { version = "2.0.0" }
143143
rustix = { version = "0.38.37", default-features = false, features = ["fs", "std"] }
144144
same-file = { version = "1.0.6" }
145-
sanitize-filename = { version = "0.5.0" }
146145
schemars = { version = "0.8.21", features = ["url"] }
147146
seahash = { version = "4.1.0" }
148147
serde = { version = "1.0.210", features = ["derive"] }

crates/uv-extract/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ md-5 = { workspace = true }
2828
rayon = { workspace = true }
2929
reqwest = { workspace = true }
3030
rustc-hash = { workspace = true }
31-
sanitize-filename = { workspace = true }
3231
sha2 = { workspace = true }
3332
thiserror = { workspace = true }
3433
tokio = { workspace = true }

crates/uv-extract/src/stream.rs

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::path::{Path, PathBuf};
1+
use std::path::{Component, Path, PathBuf};
22
use std::pin::Pin;
33

44
use futures::StreamExt;
@@ -21,22 +21,24 @@ pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
2121
reader: R,
2222
target: impl AsRef<Path>,
2323
) -> Result<(), Error> {
24-
/// Sanitize a filename for use on Windows.
25-
fn sanitize(filename: &str) -> PathBuf {
26-
filename
27-
.replace('\\', "/")
28-
.split('/')
29-
.map(|segment| {
30-
sanitize_filename::sanitize_with_options(
31-
segment,
32-
sanitize_filename::Options {
33-
windows: cfg!(windows),
34-
truncate: false,
35-
replacement: "",
36-
},
37-
)
38-
})
39-
.collect()
24+
/// Ensure the file path is safe to use as a [`Path`].
25+
///
26+
/// See: <https://docs.rs/zip/latest/zip/read/struct.ZipFile.html#method.enclosed_name>
27+
pub(crate) fn enclosed_name(file_name: &str) -> Option<PathBuf> {
28+
if file_name.contains('\0') {
29+
return None;
30+
}
31+
let path = PathBuf::from(file_name);
32+
let mut depth = 0usize;
33+
for component in path.components() {
34+
match component {
35+
Component::Prefix(_) | Component::RootDir => return None,
36+
Component::ParentDir => depth = depth.checked_sub(1)?,
37+
Component::Normal(_) => depth += 1,
38+
Component::CurDir => (),
39+
}
40+
}
41+
Some(path)
4042
}
4143

4244
let target = target.as_ref();
@@ -48,7 +50,17 @@ pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
4850
while let Some(mut entry) = zip.next_with_entry().await? {
4951
// Construct the (expected) path to the file on-disk.
5052
let path = entry.reader().entry().filename().as_str()?;
51-
let path = target.join(sanitize(path));
53+
54+
// Sanitize the file name to prevent directory traversal attacks.
55+
let Some(path) = enclosed_name(path) else {
56+
warn!("Skipping unsafe file name: {path}");
57+
58+
// Close current file prior to proceeding, as per:
59+
// https://docs.rs/async_zip/0.0.16/async_zip/base/read/stream/
60+
zip = entry.skip().await?;
61+
continue;
62+
};
63+
let path = target.join(path);
5264
let is_dir = entry.reader().entry().dir()?;
5365

5466
// Either create the directory or write the file to disk.
@@ -75,7 +87,7 @@ pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
7587
tokio::io::copy(&mut reader, &mut writer).await?;
7688
}
7789

78-
// Close current file to get access to the next one. See docs:
90+
// Close current file prior to proceeding, as per:
7991
// https://docs.rs/async_zip/0.0.16/async_zip/base/read/stream/
8092
zip = entry.skip().await?;
8193
}
@@ -104,7 +116,10 @@ pub async fn unzip<R: tokio::io::AsyncRead + Unpin>(
104116
if has_any_executable_bit != 0 {
105117
// Construct the (expected) path to the file on-disk.
106118
let path = entry.filename().as_str()?;
107-
let path = target.join(sanitize(path));
119+
let Some(path) = enclosed_name(path) else {
120+
continue;
121+
};
122+
let path = target.join(path);
108123

109124
let permissions = fs_err::tokio::metadata(&path).await?.permissions();
110125
if permissions.mode() & 0o111 != 0o111 {

crates/uv-extract/src/sync.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::sync::Mutex;
33

44
use rayon::prelude::*;
55
use rustc_hash::FxHashSet;
6+
use tracing::warn;
67
use zip::ZipArchive;
78

89
use crate::vendor::{CloneableSeekableReader, HasLength};
@@ -25,6 +26,7 @@ pub fn unzip<R: Send + std::io::Read + std::io::Seek + HasLength>(
2526

2627
// Determine the path of the file within the wheel.
2728
let Some(enclosed_name) = file.enclosed_name() else {
29+
warn!("Skipping unsafe file name: {}", file.name());
2830
return Ok(());
2931
};
3032

crates/uv/tests/it/pip_sync.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5631,8 +5631,10 @@ fn sanitize() -> Result<()> {
56315631
"###
56325632
);
56335633

5634-
// There should be a `payload` file in `site-packages` (but _not_ outside of it).
5635-
assert!(context.site_packages().join("payload").exists());
5634+
// There should be no `payload` file in the root.
5635+
if let Some(parent) = context.temp_dir.parent() {
5636+
assert!(!parent.join("payload").exists());
5637+
}
56365638

56375639
Ok(())
56385640
}

0 commit comments

Comments
 (0)