Skip to content

Commit a3d7f73

Browse files
committed
Enable opt-in to deny creation of symlinks outside target directory
1 parent ac2aade commit a3d7f73

File tree

7 files changed

+285
-5
lines changed

7 files changed

+285
-5
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 0.5.2
4+
5+
* Enable opt-in to deny creation of symlinks outside target directory by @charliermarsh in https://github.com/astral-sh/tokio-tar/pull/46
6+
37
## 0.5.1
48

59
* Add test to reproduce issue in `impl Stream for Entries` causing filename truncation by @charliermarsh in https://github.com/astral-sh/tokio-tar/pull/41

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ the following modifications:
1919
In [`alexcrichton/tar-rs`](https://github.com/alexcrichton/tar-rs), setting `preserve_permissions`
2020
to `false` will still set read, write, and execute permissions on extracted files, but will avoid
2121
setting extended permissions (e.g., `setuid`, `setgid`, and `sticky` bits).
22+
- Setting `allow_external_symlinks` to `false` will avoid extracting symlinks that point outside the
23+
unpack target. Operations that _write_ outside the unpack directory are _always_ denied; but by
24+
default, symlinks that _read_ outside the unpack directory are allowed.
2225

2326
See the [changelog](CHANGELOG.md) for a more detailed list of changes.
2427

src/archive.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ pub struct ArchiveInner<R> {
4444
unpack_xattrs: bool,
4545
preserve_permissions: bool,
4646
preserve_mtime: bool,
47+
allow_external_symlinks: bool,
4748
overwrite: bool,
4849
ignore_zeros: bool,
4950
obj: Mutex<R>,
@@ -55,6 +56,7 @@ pub struct ArchiveBuilder<R: Read + Unpin> {
5556
unpack_xattrs: bool,
5657
preserve_permissions: bool,
5758
preserve_mtime: bool,
59+
allow_external_symlinks: bool,
5860
overwrite: bool,
5961
ignore_zeros: bool,
6062
}
@@ -66,6 +68,7 @@ impl<R: Read + Unpin> ArchiveBuilder<R> {
6668
unpack_xattrs: false,
6769
preserve_permissions: false,
6870
preserve_mtime: true,
71+
allow_external_symlinks: true,
6972
overwrite: true,
7073
ignore_zeros: false,
7174
obj,
@@ -118,12 +121,23 @@ impl<R: Read + Unpin> ArchiveBuilder<R> {
118121
self
119122
}
120123

124+
/// Indicate whether to deny symlinks that point outside the destination
125+
/// directory when unpacking this entry. (Writing to locations outside the
126+
/// destination directory is _always_ forbidden.)
127+
///
128+
/// This flag is enabled by default.
129+
pub fn set_allow_external_symlinks(mut self, allow_external_symlinks: bool) -> Self {
130+
self.allow_external_symlinks = allow_external_symlinks;
131+
self
132+
}
133+
121134
/// Construct the archive, ready to accept inputs.
122135
pub fn build(self) -> Archive<R> {
123136
let Self {
124137
unpack_xattrs,
125138
preserve_permissions,
126139
preserve_mtime,
140+
allow_external_symlinks,
127141
overwrite,
128142
ignore_zeros,
129143
obj,
@@ -134,6 +148,7 @@ impl<R: Read + Unpin> ArchiveBuilder<R> {
134148
unpack_xattrs,
135149
preserve_permissions,
136150
preserve_mtime,
151+
allow_external_symlinks,
137152
overwrite,
138153
ignore_zeros,
139154
obj: Mutex::new(obj),
@@ -151,6 +166,7 @@ impl<R: Read + Unpin> Archive<R> {
151166
unpack_xattrs: false,
152167
preserve_permissions: false,
153168
preserve_mtime: true,
169+
allow_external_symlinks: true,
154170
overwrite: true,
155171
ignore_zeros: false,
156172
obj: Mutex::new(obj),
@@ -565,6 +581,7 @@ fn poll_next_raw<R: Read + Unpin>(
565581
preserve_permissions: archive.inner.preserve_permissions,
566582
preserve_mtime: archive.inner.preserve_mtime,
567583
overwrite: archive.inner.overwrite,
584+
allow_external_symlinks: archive.inner.allow_external_symlinks,
568585
read_state: None,
569586
};
570587

src/entry.rs

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::fs::{normalize_absolute, normalize_relative};
12
use crate::{
23
error::TarError, header::bytes2path, other, pax::pax_extensions, Archive, Header, PaxExtensions,
34
};
@@ -54,6 +55,7 @@ pub struct EntryFields<R: Read + Unpin> {
5455
pub preserve_permissions: bool,
5556
pub preserve_mtime: bool,
5657
pub overwrite: bool,
58+
pub allow_external_symlinks: bool,
5759
pub(crate) read_state: Option<EntryIo<R>>,
5860
}
5961

@@ -71,6 +73,8 @@ impl<R: Read + Unpin> fmt::Debug for EntryFields<R> {
7173
.field("unpack_xattrs", &self.unpack_xattrs)
7274
.field("preserve_permissions", &self.preserve_permissions)
7375
.field("preserve_mtime", &self.preserve_mtime)
76+
.field("overwrite", &self.overwrite)
77+
.field("allow_external_symlinks", &self.allow_external_symlinks)
7478
.field("read_state", &self.read_state)
7579
.finish()
7680
}
@@ -321,6 +325,15 @@ impl<R: Read + Unpin> Entry<R> {
321325
pub fn set_preserve_mtime(&mut self, preserve: bool) {
322326
self.fields.preserve_mtime = preserve;
323327
}
328+
329+
/// Indicate whether to deny symlinks that point outside the destination
330+
/// directory when unpacking this entry. (Writing to locations outside the
331+
/// destination directory is _always_ forbidden.)
332+
///
333+
/// This flag is enabled by default.
334+
pub fn set_allow_external_symlinks(&mut self, allow_external_symlinks: bool) {
335+
self.fields.allow_external_symlinks = allow_external_symlinks;
336+
}
324337
}
325338

326339
impl<R: Read + Unpin> Read for Entry<R> {
@@ -571,17 +584,14 @@ impl<R: Read + Unpin> EntryFields<R> {
571584
let src = match self.link_name()? {
572585
Some(name) => name,
573586
None => {
574-
return Err(other(&format!(
575-
"hard link listed for {} but no link name found",
576-
String::from_utf8_lossy(self.header.as_bytes())
577-
)));
587+
return Err(other("hard link listed but no link name found"));
578588
}
579589
};
580590

581591
if src.iter().count() == 0 {
582592
return Err(other(&format!(
583593
"symlink destination for {} is empty",
584-
String::from_utf8_lossy(self.header.as_bytes())
594+
src.display()
585595
)));
586596
}
587597

@@ -617,6 +627,26 @@ impl<R: Read + Unpin> EntryFields<R> {
617627
)
618628
})?;
619629
} else {
630+
if !self.allow_external_symlinks {
631+
if let Some(target_base) = target_base {
632+
// Determine the normalized, absolute destination of the symlink.
633+
let link_dst = if src.is_absolute() {
634+
normalize_absolute(src.as_ref())
635+
} else {
636+
dst.parent()
637+
.and_then(|parent| normalize_relative(parent, src.as_ref()))
638+
};
639+
640+
// Verify that the symlink destination is inside the target directory.
641+
if !link_dst.is_some_and(|link_dst| link_dst.starts_with(target_base)) {
642+
return Err(other(&format!(
643+
"symlink destination for {} is outside of the target directory",
644+
src.display()
645+
)));
646+
}
647+
}
648+
}
649+
620650
match symlink(&src, dst).await {
621651
Ok(()) => Ok(()),
622652
Err(err) => {

src/fs.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
use std::path::{Component, Path, PathBuf};
2+
3+
/// Normalize an absolute path.
4+
pub(crate) fn normalize_absolute(p: &Path) -> Option<PathBuf> {
5+
debug_assert!(p.is_absolute(), "Target must be an absolute path");
6+
7+
let mut resolved = PathBuf::new();
8+
for component in p.components() {
9+
match component {
10+
Component::Prefix(prefix) => {
11+
// Windows-specific: preserve drive letter and prefix.
12+
resolved.push(prefix.as_os_str());
13+
}
14+
Component::RootDir => {
15+
// Append root directory after prefix.
16+
resolved.push(component.as_os_str());
17+
}
18+
Component::CurDir => {
19+
// Ignore `.`
20+
}
21+
Component::ParentDir => {
22+
if !resolved.pop() {
23+
return None;
24+
}
25+
}
26+
Component::Normal(segment) => {
27+
resolved.push(segment);
28+
}
29+
}
30+
}
31+
Some(resolved)
32+
}
33+
34+
/// Normalize a path relative to a destination directory.
35+
pub(crate) fn normalize_relative(dst: &Path, p: &Path) -> Option<PathBuf> {
36+
debug_assert!(!p.is_absolute(), "Target must be a relative path");
37+
debug_assert!(dst.is_absolute(), "Destination must be an absolute path");
38+
39+
let mut resolved = dst.to_path_buf();
40+
for component in p.components() {
41+
match component {
42+
Component::RootDir | Component::Prefix(_) => {
43+
// E.g., `/usr` on Windows.
44+
return None;
45+
}
46+
Component::CurDir => {
47+
// Ignore `.`
48+
}
49+
Component::ParentDir => {
50+
if !resolved.pop() {
51+
return None;
52+
}
53+
}
54+
Component::Normal(segment) => {
55+
resolved.push(segment);
56+
}
57+
}
58+
}
59+
Some(resolved)
60+
}
61+
62+
#[cfg(test)]
63+
mod tests {
64+
use std::path::{Path, PathBuf};
65+
66+
#[test]
67+
#[cfg(unix)]
68+
fn test_normalize_absolute() {
69+
// Basic absolute path.
70+
assert_eq!(
71+
crate::fs::normalize_absolute(Path::new("/a/b/c")),
72+
Some(PathBuf::from("/a/b/c"))
73+
);
74+
75+
// Path with `..` (should remove `b`).
76+
assert_eq!(
77+
crate::fs::normalize_absolute(Path::new("/a/b/../c")),
78+
Some(PathBuf::from("/a/c"))
79+
);
80+
81+
// Path with `.`, should be ignored.
82+
assert_eq!(
83+
crate::fs::normalize_absolute(Path::new("/a/./b")),
84+
Some(PathBuf::from("/a/b"))
85+
);
86+
87+
// Excessive `..` that escapes root.
88+
assert_eq!(crate::fs::normalize_absolute(Path::new("/../b")), None);
89+
}
90+
91+
#[test]
92+
#[cfg(windows)]
93+
fn test_normalize_absolute() {
94+
// Basic absolute path.
95+
assert_eq!(
96+
crate::fs::normalize_absolute(Path::new(r"C:\a\b\c")),
97+
Some(PathBuf::from(r"C:\a\b\c"))
98+
);
99+
100+
// Path with `..` (should remove `b`).
101+
assert_eq!(
102+
crate::fs::normalize_absolute(Path::new(r"C:\a\b\..\c")),
103+
Some(PathBuf::from(r"C:\a\c"))
104+
);
105+
106+
// Path with `.`, should be ignored.
107+
assert_eq!(
108+
crate::fs::normalize_absolute(Path::new(r"C:\a\.\b")),
109+
Some(PathBuf::from(r"C:\a\b"))
110+
);
111+
112+
// Excessive `..` that escapes root.
113+
assert_eq!(crate::fs::normalize_absolute(Path::new(r"C:\..\b")), None);
114+
}
115+
116+
#[test]
117+
#[cfg(unix)]
118+
fn test_normalize_relative() {
119+
let dst = Path::new("/home/user/dst");
120+
121+
// Basic relative path.
122+
assert_eq!(
123+
crate::fs::normalize_relative(dst, Path::new("a/b/c")),
124+
Some(PathBuf::from("/home/user/dst/a/b/c"))
125+
);
126+
127+
// Path with `..`, should remove `b`.
128+
assert_eq!(
129+
crate::fs::normalize_relative(dst, Path::new("a/b/../c")),
130+
Some(PathBuf::from("/home/user/dst/a/c"))
131+
);
132+
133+
// Path with `.` should be ignored.
134+
assert_eq!(
135+
crate::fs::normalize_relative(dst, Path::new("./a/b")),
136+
Some(PathBuf::from("/home/user/dst/a/b"))
137+
);
138+
139+
// Path escaping `dst`, should return None.
140+
assert_eq!(
141+
crate::fs::normalize_relative(dst, Path::new("../../../../outside")),
142+
None
143+
);
144+
145+
// Excessive `..`, escaping `dst`.
146+
assert_eq!(
147+
crate::fs::normalize_relative(dst, Path::new("../../../../")),
148+
None
149+
);
150+
}
151+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ mod builder;
3737
mod entry;
3838
mod entry_type;
3939
mod error;
40+
mod fs;
4041
mod header;
4142
mod pax;
4243

0 commit comments

Comments
 (0)