Skip to content

Commit e260764

Browse files
fix: reject symlinked output ancestors
1 parent 04b63de commit e260764

3 files changed

Lines changed: 213 additions & 23 deletions

File tree

crates/cli/src/main.rs

Lines changed: 97 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,98 @@ fn extraction_io(action: &str, path: &Path, error: &io::Error) -> CliError {
648648
CliError::io(format!("{action} {}: {error}", path.display()))
649649
}
650650

651+
fn is_known_immutable_root_alias(name: &OsStr) -> bool {
652+
#[cfg(target_os = "macos")]
653+
{
654+
name == OsStr::new("var") || name == OsStr::new("tmp")
655+
}
656+
#[cfg(not(target_os = "macos"))]
657+
{
658+
let _ = name;
659+
false
660+
}
661+
}
662+
663+
fn open_or_create_output_component(
664+
parent: &Dir,
665+
name: &OsStr,
666+
output_dir: &Path,
667+
allow_root_alias: bool,
668+
) -> Result<Dir, CliError> {
669+
match parent.open_dir_nofollow(name) {
670+
Ok(directory) => return Ok(directory),
671+
Err(error) if error.kind() != io::ErrorKind::NotFound => {
672+
if allow_root_alias
673+
&& is_known_immutable_root_alias(name)
674+
&& parent
675+
.symlink_metadata(name)
676+
.is_ok_and(|metadata| metadata.file_type().is_symlink())
677+
{
678+
// These macOS root aliases are not mutable by ordinary users.
679+
return parent.open_dir(name).map_err(|open_error| {
680+
extraction_io("failed to open output directory", output_dir, &open_error)
681+
});
682+
}
683+
return Err(extraction_io("failed to open output directory", output_dir, &error));
684+
}
685+
Err(_) => {}
686+
}
687+
688+
match parent.create_dir(name) {
689+
Ok(()) => {}
690+
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
691+
Err(error) => {
692+
return Err(extraction_io("failed to create output directory", output_dir, &error));
693+
}
694+
}
695+
696+
parent
697+
.open_dir_nofollow(name)
698+
.map_err(|error| extraction_io("failed to open output directory", output_dir, &error))
699+
}
700+
701+
fn open_or_create_output_dir(output_dir: &Path) -> Result<Dir, CliError> {
702+
let absolute = std::path::absolute(output_dir)
703+
.map_err(|error| extraction_io("failed to resolve output directory", output_dir, &error))?;
704+
let root_path = absolute.ancestors().last().ok_or_else(|| {
705+
CliError::io(format!("output directory has no root: {}", output_dir.display()))
706+
})?;
707+
let relative = absolute.strip_prefix(root_path).map_err(|error| {
708+
CliError::io(format!(
709+
"failed to resolve output directory {}: {error}",
710+
output_dir.display()
711+
))
712+
})?;
713+
let mut components = Vec::new();
714+
for component in relative.components() {
715+
match component {
716+
Component::Normal(name) => components.push(name.to_os_string()),
717+
Component::CurDir => {}
718+
Component::ParentDir => {
719+
components.pop().ok_or_else(|| {
720+
CliError::path_traversal(format!(
721+
"output directory escapes its filesystem root: {}",
722+
output_dir.display()
723+
))
724+
})?;
725+
}
726+
Component::Prefix(_) | Component::RootDir => {
727+
return Err(CliError::invalid_input(format!(
728+
"output directory contains an unexpected root component: {}",
729+
output_dir.display()
730+
)));
731+
}
732+
}
733+
}
734+
735+
let mut current = Dir::open_ambient_dir(root_path, ambient_authority())
736+
.map_err(|error| extraction_io("failed to open output directory", output_dir, &error))?;
737+
for (index, component) in components.iter().enumerate() {
738+
current = open_or_create_output_component(&current, component, output_dir, index == 0)?;
739+
}
740+
Ok(current)
741+
}
742+
651743
fn parent_entry_is_unsafe(parent: &Dir, name: &OsStr) -> Result<bool, CliError> {
652744
match parent.symlink_metadata(name) {
653745
Ok(metadata) => Ok(metadata.file_type().is_symlink() || !metadata.is_dir()),
@@ -1783,21 +1875,15 @@ fn parse_fetch_url(input: &str) -> Result<Url, CliError> {
17831875
Ok(url)
17841876
}
17851877

1786-
fn fetch_output_dir(output: &Option<PathBuf>) -> Result<PathBuf, CliError> {
1878+
fn fetch_output_dir(output: &Option<PathBuf>) -> Result<(PathBuf, Dir), CliError> {
17871879
let output_dir = match output {
17881880
Some(dir) => dir.clone(),
17891881
None => {
17901882
std::env::current_dir().map_err(|e| CliError::io(format!("cannot get cwd: {e}")))?
17911883
}
17921884
};
1793-
1794-
if !output_dir.exists() {
1795-
fs::create_dir_all(&output_dir).map_err(|e| {
1796-
CliError::io(format!("failed to create output directory {}: {e}", output_dir.display()))
1797-
})?;
1798-
}
1799-
1800-
Ok(output_dir)
1885+
let output_handle = open_or_create_output_dir(&output_dir)?;
1886+
Ok((output_dir, output_handle))
18011887
}
18021888

18031889
fn write_fetch_output(
@@ -1968,14 +2054,7 @@ fn cmd_fetch(
19682054
let url = parse_fetch_url(url)?;
19692055
let agent = http_agent();
19702056

1971-
let output_dir = fetch_output_dir(output)?;
1972-
let output_handle =
1973-
Dir::open_ambient_dir(&output_dir, ambient_authority()).map_err(|error| {
1974-
CliError::io(format!(
1975-
"failed to open output directory {}: {error}",
1976-
output_dir.display()
1977-
))
1978-
})?;
2057+
let (output_dir, output_handle) = fetch_output_dir(output)?;
19792058
let (bundle_filename, bundle_path, bundle_body, bundle_url) =
19802059
fetch_bundle(&agent, &url, &output_handle, &output_dir, allow_cross_origin)?;
19812060

@@ -2020,10 +2099,7 @@ fn extract_source_contents(
20202099
sm: &SourceMap,
20212100
output_dir: &Path,
20222101
) -> Result<(Vec<serde_json::Value>, Vec<String>), CliError> {
2023-
Dir::create_ambient_dir_all(output_dir, ambient_authority())
2024-
.map_err(|error| extraction_io("failed to create output directory", output_dir, &error))?;
2025-
let root = Dir::open_ambient_dir(output_dir, ambient_authority())
2026-
.map_err(|error| extraction_io("failed to open output directory", output_dir, &error))?;
2102+
let root = open_or_create_output_dir(output_dir)?;
20272103
let mut extracted = Vec::new();
20282104
let mut skipped = Vec::new();
20292105

crates/cli/tests/cli.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,65 @@ fn sources_extract_security_skips_existing_destination() {
693693
assert_eq!(result["skipped"], serde_json::json!(["existing.ts"]));
694694
}
695695

696+
#[cfg(unix)]
697+
#[test]
698+
fn sources_extract_security_rejects_symlink_output_root() {
699+
use std::os::unix::fs::symlink;
700+
701+
let dir = tempfile::tempdir().unwrap();
702+
let root = dir.path().canonicalize().unwrap();
703+
let outside_dir = root.join("outside");
704+
let output_dir = root.join("output");
705+
fs::create_dir(&outside_dir).unwrap();
706+
symlink(&outside_dir, &output_dir).unwrap();
707+
let map = write_sources_map(dir.path(), &["source.ts"]);
708+
709+
let out = srcmap()
710+
.args([
711+
"sources",
712+
map.to_str().unwrap(),
713+
"--extract",
714+
"-o",
715+
output_dir.to_str().unwrap(),
716+
"--json",
717+
])
718+
.output()
719+
.unwrap();
720+
721+
assert!(!out.status.success());
722+
assert!(!outside_dir.join("source.ts").exists());
723+
}
724+
725+
#[cfg(unix)]
726+
#[test]
727+
fn sources_extract_security_rejects_symlink_output_ancestor() {
728+
use std::os::unix::fs::symlink;
729+
730+
let dir = tempfile::tempdir().unwrap();
731+
let root = dir.path().canonicalize().unwrap();
732+
let outside_dir = root.join("outside");
733+
let linked_dir = root.join("linked");
734+
let output_dir = linked_dir.join("nested");
735+
fs::create_dir(&outside_dir).unwrap();
736+
symlink(&outside_dir, &linked_dir).unwrap();
737+
let map = write_sources_map(&root, &["source.ts"]);
738+
739+
let out = srcmap()
740+
.args([
741+
"sources",
742+
map.to_str().unwrap(),
743+
"--extract",
744+
"-o",
745+
output_dir.to_str().unwrap(),
746+
"--json",
747+
])
748+
.output()
749+
.unwrap();
750+
751+
assert!(!out.status.success());
752+
assert!(!outside_dir.join("nested/source.ts").exists());
753+
}
754+
696755
#[cfg(unix)]
697756
#[test]
698757
fn sources_extract_security_skips_parent_symlink() {
@@ -1028,6 +1087,61 @@ fn fetch_security_rejects_symlink_bundle_destination() {
10281087
assert_eq!(fs::read_to_string(outside.path()).unwrap(), "sentinel");
10291088
}
10301089

1090+
#[cfg(unix)]
1091+
#[test]
1092+
fn fetch_security_rejects_symlink_output_root() {
1093+
use std::os::unix::fs::symlink;
1094+
1095+
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1096+
let inline = "console.log(1);\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IiJ9";
1097+
let reply = response("200 OK", &[], inline);
1098+
let received = serve_once(listener.try_clone().unwrap(), reply, Duration::ZERO);
1099+
let dir = tempfile::tempdir().unwrap();
1100+
let root = dir.path().canonicalize().unwrap();
1101+
let outside_dir = root.join("outside");
1102+
let output_dir = root.join("output");
1103+
fs::create_dir(&outside_dir).unwrap();
1104+
symlink(&outside_dir, &output_dir).unwrap();
1105+
1106+
let out = srcmap()
1107+
.args(["fetch", &local_url(&listener, "/bundle.js"), "-o"])
1108+
.arg(&output_dir)
1109+
.output()
1110+
.unwrap();
1111+
1112+
assert!(received.recv_timeout(Duration::from_millis(100)).is_err());
1113+
assert!(!out.status.success());
1114+
assert!(!outside_dir.join("bundle.js").exists());
1115+
}
1116+
1117+
#[cfg(unix)]
1118+
#[test]
1119+
fn fetch_security_rejects_symlink_output_ancestor() {
1120+
use std::os::unix::fs::symlink;
1121+
1122+
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1123+
let inline = "console.log(1);\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IiJ9";
1124+
let reply = response("200 OK", &[], inline);
1125+
let received = serve_once(listener.try_clone().unwrap(), reply, Duration::ZERO);
1126+
let dir = tempfile::tempdir().unwrap();
1127+
let root = dir.path().canonicalize().unwrap();
1128+
let outside_dir = root.join("outside");
1129+
let linked_dir = root.join("linked");
1130+
let output_dir = linked_dir.join("nested");
1131+
fs::create_dir(&outside_dir).unwrap();
1132+
symlink(&outside_dir, &linked_dir).unwrap();
1133+
1134+
let out = srcmap()
1135+
.args(["fetch", &local_url(&listener, "/bundle.js"), "-o"])
1136+
.arg(&output_dir)
1137+
.output()
1138+
.unwrap();
1139+
1140+
assert!(received.recv_timeout(Duration::from_millis(100)).is_err());
1141+
assert!(!out.status.success());
1142+
assert!(!outside_dir.join("nested/bundle.js").exists());
1143+
}
1144+
10311145
#[cfg(unix)]
10321146
#[test]
10331147
fn fetch_security_rejects_symlink_source_map_destination() {

crates/codec/src/vlq.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const BASE64_DECODE: [u8; 128] = {
4141
/// Encode a single signed VLQ value directly into the buffer using unchecked writes.
4242
///
4343
/// # Safety
44-
/// Caller must ensure `out` has exactly [`MAX_VLQ_BYTES`] bytes of spare capacity
44+
/// Caller must ensure `out` has at least [`MAX_VLQ_BYTES`] bytes of spare capacity
4545
/// available for this write (`out.capacity() - out.len() >= MAX_VLQ_BYTES`).
4646
#[inline(always)]
4747
pub unsafe fn vlq_encode_unchecked(out: &mut Vec<u8>, value: i64) {
@@ -162,7 +162,7 @@ pub fn vlq_decode(input: &[u8], pos: usize) -> Result<(i64, usize), DecodeError>
162162
/// Encode a single unsigned VLQ value directly into the buffer using unchecked writes.
163163
///
164164
/// # Safety
165-
/// Caller must ensure `out` has exactly [`MAX_VLQ_BYTES`] bytes of spare capacity
165+
/// Caller must ensure `out` has at least [`MAX_VLQ_BYTES`] bytes of spare capacity
166166
/// available for this write (`out.capacity() - out.len() >= MAX_VLQ_BYTES`).
167167
#[inline(always)]
168168
pub unsafe fn vlq_encode_unsigned_unchecked(out: &mut Vec<u8>, value: u64) {

0 commit comments

Comments
 (0)