Skip to content

Commit c17cb1b

Browse files
committed
Enable opt-in to deny creation of symlinks outside target directory
1 parent 1408d42 commit c17cb1b

File tree

6 files changed

+277
-0
lines changed

6 files changed

+277
-0
lines changed

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: 32 additions & 0 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> {
@@ -617,6 +630,25 @@ impl<R: Read + Unpin> EntryFields<R> {
617630
)
618631
})?;
619632
} else {
633+
if !self.allow_external_symlinks {
634+
if let Some(target_base) = target_base {
635+
// Determine the normalized, absolute destination of the symlink.
636+
let link_dst = if src.is_absolute() {
637+
normalize_absolute(src.as_ref())
638+
} else {
639+
normalize_relative(dst.parent().unwrap(), src.as_ref())
640+
};
641+
642+
// Verify that the symlink destination is inside the target directory.
643+
if !link_dst.is_some_and(|link_dst| link_dst.starts_with(target_base)) {
644+
return Err(other(&format!(
645+
"symlink destination for {} is outside of the target directory",
646+
String::from_utf8_lossy(self.header.as_bytes())
647+
)));
648+
}
649+
}
650+
}
651+
620652
match symlink(&src, dst).await {
621653
Ok(()) => Ok(()),
622654
Err(err) => {

src/fs.rs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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());
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());
37+
debug_assert!(dst.is_absolute());
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+
use crate::fs::{normalize_absolute, normalize_relative};
67+
68+
#[test]
69+
#[cfg(unix)]
70+
fn test_normalize_absolute() {
71+
// Basic absolute path.
72+
assert_eq!(
73+
normalize_absolute(Path::new("/a/b/c")),
74+
Some(PathBuf::from("/a/b/c"))
75+
);
76+
77+
// Path with `..` (should remove `b`).
78+
assert_eq!(
79+
normalize_absolute(Path::new("/a/b/../c")),
80+
Some(PathBuf::from("/a/c"))
81+
);
82+
83+
// Path with `.`, should be ignored.
84+
assert_eq!(
85+
normalize_absolute(Path::new("/a/./b")),
86+
Some(PathBuf::from("/a/b"))
87+
);
88+
89+
// Excessive `..` that escapes root.
90+
assert_eq!(normalize_absolute(Path::new("/../b")), None);
91+
}
92+
93+
#[test]
94+
#[cfg(windows)]
95+
fn test_normalize_absolute() {
96+
// Basic absolute path.
97+
assert_eq!(
98+
normalize_absolute(Path::new(r"C:\a\b\c")),
99+
Some(PathBuf::from(r"C:\a\b\c"))
100+
);
101+
102+
// Path with `..` (should remove `b`).
103+
assert_eq!(
104+
normalize_absolute(Path::new(r"C:\a\b\..\c")),
105+
Some(PathBuf::from(r"C:\a\c"))
106+
);
107+
108+
// Path with `.`, should be ignored.
109+
assert_eq!(
110+
normalize_absolute(Path::new(r"C:\a\.\b")),
111+
Some(PathBuf::from(r"C:\a\b"))
112+
);
113+
114+
// Excessive `..` that escapes root.
115+
assert_eq!(normalize_absolute(Path::new(r"C:\..\b")), None);
116+
}
117+
118+
#[test]
119+
#[cfg(unix)]
120+
fn test_normalize_relative() {
121+
let dst = Path::new("/home/user/dst");
122+
123+
// Basic relative path.
124+
assert_eq!(
125+
normalize_relative(dst, Path::new("a/b/c")),
126+
Some(PathBuf::from("/home/user/dst/a/b/c"))
127+
);
128+
129+
// Path with `..`, should remove `b`.
130+
assert_eq!(
131+
normalize_relative(dst, Path::new("a/b/../c")),
132+
Some(PathBuf::from("/home/user/dst/a/c"))
133+
);
134+
135+
// Path with `.` should be ignored.
136+
assert_eq!(
137+
normalize_relative(dst, Path::new("./a/b")),
138+
Some(PathBuf::from("/home/user/dst/a/b"))
139+
);
140+
141+
// Path escaping `dst`, should return None.
142+
assert_eq!(
143+
normalize_relative(dst, Path::new("../../../../outside")),
144+
None
145+
);
146+
147+
// Excessive `..`, escaping `dst`.
148+
assert_eq!(normalize_relative(dst, Path::new("../../../../")), None);
149+
}
150+
}

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

tests/entry.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ extern crate tempfile;
66
use tokio::{fs, fs::File, io::AsyncReadExt};
77
use tokio_stream::*;
88

9+
use async_tar::ArchiveBuilder;
910
use tempfile::Builder;
1011

1112
macro_rules! t {
@@ -388,3 +389,76 @@ async fn modify_symlink_just_created() {
388389
t!(t!(File::open(&test).await).read_to_end(&mut contents).await);
389390
assert_eq!(contents.len(), 0);
390391
}
392+
393+
#[tokio::test]
394+
async fn deny_absolute_symlink() {
395+
let mut ar = async_tar::Builder::new(Vec::new());
396+
397+
let mut header = async_tar::Header::new_gnu();
398+
header.set_size(0);
399+
header.set_entry_type(async_tar::EntryType::Symlink);
400+
t!(header.set_path("foo"));
401+
t!(header.set_link_name("/bar"));
402+
header.set_cksum();
403+
t!(ar.append(&header, &[][..]).await);
404+
405+
let bytes = t!(ar.into_inner().await);
406+
407+
let builder = ArchiveBuilder::new(&bytes[..]).set_allow_external_symlinks(false);
408+
let mut ar = builder.build();
409+
410+
let td = t!(Builder::new().prefix("tar").tempdir());
411+
assert!(ar.unpack(td.path()).await.is_err());
412+
}
413+
414+
#[tokio::test]
415+
async fn deny_relative_link() {
416+
let mut ar = async_tar::Builder::new(Vec::new());
417+
418+
let mut header = async_tar::Header::new_gnu();
419+
header.set_size(0);
420+
header.set_entry_type(async_tar::EntryType::Symlink);
421+
t!(header.set_path("foo"));
422+
t!(header.set_link_name("../../../../"));
423+
header.set_cksum();
424+
t!(ar.append(&header, &[][..]).await);
425+
426+
let bytes = t!(ar.into_inner().await);
427+
428+
let builder = ArchiveBuilder::new(&bytes[..]).set_allow_external_symlinks(false);
429+
let mut ar = builder.build();
430+
431+
let td = t!(Builder::new().prefix("tar").tempdir());
432+
assert!(ar.unpack(td.path()).await.is_err());
433+
}
434+
435+
#[tokio::test]
436+
#[cfg(unix)]
437+
async fn accept_relative_link() {
438+
let mut ar = async_tar::Builder::new(Vec::new());
439+
440+
let mut header = async_tar::Header::new_gnu();
441+
header.set_size(0);
442+
header.set_entry_type(async_tar::EntryType::Symlink);
443+
t!(header.set_path("foo/bar"));
444+
t!(header.set_link_name("../baz"));
445+
header.set_cksum();
446+
t!(ar.append(&header, &[][..]).await);
447+
448+
let mut header = async_tar::Header::new_gnu();
449+
header.set_size(1);
450+
header.set_entry_type(async_tar::EntryType::Regular);
451+
t!(header.set_path("baz"));
452+
header.set_cksum();
453+
t!(ar.append(&header, &b"x"[..]).await);
454+
455+
let bytes = t!(ar.into_inner().await);
456+
457+
let builder = ArchiveBuilder::new(&bytes[..]).set_allow_external_symlinks(false);
458+
let mut ar = builder.build();
459+
460+
let td = t!(Builder::new().prefix("tar").tempdir());
461+
t!(ar.unpack(td.path()).await);
462+
t!(td.path().join("foo/bar").symlink_metadata());
463+
t!(File::open(td.path().join("foo").join("bar")).await);
464+
}

0 commit comments

Comments
 (0)