Skip to content

Commit a0f841a

Browse files
Haofeiclaude
andcommitted
Reject symlinks in package source collection (audit 5.2)
`collect_rsscript_files_excluding` used `Path::is_dir`/`is_file`, which follow symlinks, so a package could place a symlinked `.rss`/`.rssi` (or symlinked dir) under a source root pointing outside the package and have review/lock/metadata read it — an info-leak / nondeterminism vector in CI on untrusted packages. Use `DirEntry::file_type()` (does not dereference) and fail closed on any symlinked entry in the source tree. (A symlinked source *root* was already caught by canonicalization in `confined_manifest_path`.) Add a unix test that a symlinked source file is rejected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent abb953d commit a0f841a

2 files changed

Lines changed: 46 additions & 2 deletions

File tree

src/package/source_set.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -542,10 +542,22 @@ fn collect_rsscript_files_excluding(
542542
for entry in entries {
543543
let entry = entry
544544
.map_err(|error| format!("failed to read entry in {}: {error}", path.display()))?;
545+
// `file_type()` does NOT follow symlinks (unlike `Path::is_dir`/`is_file`).
546+
// Reject symlinked entries so a package cannot point review/lock/metadata
547+
// at files outside its own root (symlink escape).
548+
let file_type = entry
549+
.file_type()
550+
.map_err(|error| format!("failed to stat entry in {}: {error}", path.display()))?;
551+
if file_type.is_symlink() {
552+
return Err(format!(
553+
"refusing to follow symlink in package source tree: {}",
554+
entry.path().display()
555+
));
556+
}
545557
let path = entry.path();
546-
if path.is_dir() {
558+
if file_type.is_dir() {
547559
collect_rsscript_files_excluding(&path, excluded_roots, files)?;
548-
} else if super::is_rsscript_source_path(&path) {
560+
} else if file_type.is_file() && super::is_rsscript_source_path(&path) {
549561
files.push(path);
550562
}
551563
}

tests/checker_package.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8354,3 +8354,35 @@ platform-env = {{ path = "{}", platform_provided = true }}
83548354
})
83558355
}));
83568356
}
8357+
8358+
#[cfg(unix)]
8359+
#[test]
8360+
fn package_source_collection_rejects_symlink_escape() {
8361+
use std::os::unix::fs::symlink;
8362+
8363+
let dir = common::unique_temp_dir("rsscript-package-symlink");
8364+
fs::create_dir_all(dir.join("interface")).expect("interface dir");
8365+
fs::write(
8366+
dir.join("rsspkg.toml"),
8367+
"[package]\nname = \"sym\"\nversion = \"0.1.0\"\nedition = \"2026\"\n\n[interfaces]\npaths = [\"interface\"]\n",
8368+
)
8369+
.expect("manifest");
8370+
fs::write(dir.join("interface/lib.rssi"), "pub fn Sym.ok() -> Unit\n").expect("interface");
8371+
8372+
// A secret outside the package the symlink will try to pull into review.
8373+
let outside = common::unique_temp_dir("rsscript-package-symlink-outside");
8374+
fs::create_dir_all(&outside).expect("outside dir");
8375+
let secret = outside.join("secret.rssi");
8376+
fs::write(&secret, "pub fn Secret.leak() -> Unit\n").expect("secret");
8377+
symlink(&secret, dir.join("interface/evil.rssi")).expect("symlink");
8378+
8379+
let result = review_package_dir(&dir);
8380+
let _ = fs::remove_dir_all(&dir);
8381+
let _ = fs::remove_dir_all(&outside);
8382+
8383+
let error = result.expect_err("a symlinked source file must be rejected, not followed");
8384+
assert!(
8385+
error.contains("symlink"),
8386+
"expected a symlink rejection error, got: {error}"
8387+
);
8388+
}

0 commit comments

Comments
 (0)