upgrade-datapack: refuse pack entries that leave the pack

A zip entry named `../x` or `/x` (or `C:\x` on Windows) was written as it
was, outside `<world>/datapacks/<name>-26.3/`. `read_pack` now takes each
entry's name from `enclosed_name()` and refuses a pack with one that has
anything but plain components, and `upgrade_into` refuses to write such a
path. Review on #3369.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ThatCakeIsAPieandClaude Opus 5.5 committed 2026-10-29 09:08:46 -05:00
1 parent 81b3803017
commit 0771f59bca
1 file changed
+47 -1
+47 -1
View File
@@ -231,9 +231,14 @@ pub fn read_pack(path: &Path) -> Result<BTreeMap<String, Vec<u8>>, String> {
if entry.is_dir() {
continue;
}
// A pack from the internet may name entries `../x` or `/x`.
let name = entry
.enclosed_name()
.filter(|name| is_inside(name))
.ok_or_else(|| at(&format!("entry {:?} leaves the pack", entry.name())))?;
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).map_err(|e| at(&e))?;
files.insert(entry.name().replace('\\', "/"), bytes);
files.insert(name.to_string_lossy().replace('\\', "/"), bytes);
}
}
if !files.contains_key("pack.mcmeta") {
@@ -263,6 +268,9 @@ pub fn upgrade_into(input: &Path, out: &Path) -> Result<Report, String> {
let files = read_pack(input)?;
let (upgraded, report) = upgrade_pack(&files);
for (path, bytes) in upgraded {
if !is_inside(Path::new(&path)) {
return Err(format!("{path}: leaves {}", out.display()));
}
let target = out.join(&path);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
@@ -272,6 +280,14 @@ pub fn upgrade_into(input: &Path, out: &Path) -> Result<Report, String> {
Ok(report)
}
/// Whether `path` is a plain relative path: no root, drive, `.` or `..`.
fn is_inside(path: &Path) -> bool {
path.components().next().is_some()
&& path
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)))
}
impl Report {
/// The report as lines for the terminal.
pub fn lines(&self) -> Vec<String> {
@@ -357,6 +373,36 @@ mod tests {
assert!(v.get("spawners").is_none());
}
#[test]
fn entries_outside_the_pack_are_refused() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("upgrade-escape-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
for (i, name) in ["../escape.txt", "data/../../escape.txt", "/escape.txt"]
.into_iter()
.enumerate()
{
let zip_path = dir.join(format!("p{i}.zip"));
let mut zip = zip::ZipWriter::new(fs::File::create(&zip_path).unwrap());
for (file, bytes) in [("pack.mcmeta", &b"{}"[..]), (name, b"x")] {
zip.start_file(file, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
let out = dir.join(format!("out{i}/datapacks/p-26.3"));
let err = upgrade_into(&zip_path, &out).unwrap_err();
assert!(err.contains("leaves the pack"), "{name}: {err}");
assert!(!out.exists(), "{name}");
}
assert!(!dir.join("escape.txt").exists());
assert!(is_inside(Path::new("data/x/worldgen/biome/b.json")));
assert!(!is_inside(Path::new("data/../x.json")));
assert!(!is_inside(Path::new("")));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn carvers_move_and_spell_out_their_hard_coded_shape() {
let files = BTreeMap::from([(