From ac06baa67a135d162d0489c2614e6c9eefb6f42e Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Tue, 5 May 2026 13:34:49 +0100 Subject: [PATCH] Restrict tarball creation --- CHANGELOG.md | 9 ++++++++- compiler-cli/src/fs.rs | 2 +- compiler-cli/src/publish.rs | 40 ++++++++++++++++++++++++++++++------- compiler-core/src/error.rs | 18 +++++++++++++++++ 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d8fedc29..43e129e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -514,4 +514,11 @@ - Restrict custom documentation page `path` and `source` values so `gleam docs build` cannot escape the docs output directory or project root. - ([evipepota](https://github.com/evipepota)) + ([evipepota](https://github.com/evipepota) and + ([Louis Pilfold](https://github.com/lpil)) + +- Restrict publication tarball creation so they cannot contain files from + outside the project root. + ([Abdelrahman Ahmed Aboelkasem](https://github.com/0x2face), + ([Aly](https://github.com/spect3r1), and + ([Louis Pilfold](https://github.com/lpil)) diff --git a/compiler-cli/src/fs.rs b/compiler-cli/src/fs.rs index 492d4fe21..198b6bfc2 100644 --- a/compiler-cli/src/fs.rs +++ b/compiler-cli/src/fs.rs @@ -477,7 +477,7 @@ pub fn native_files(dir: &Utf8Path) -> impl Iterator + '_ { } /// Walks through all files in the directory, even if ignored. -pub fn private_files(dir: &Utf8Path) -> impl Iterator + '_ { +pub fn priv_directory_files(dir: &Utf8Path) -> impl Iterator + '_ { ignore::WalkBuilder::new(dir) .follow_links(true) .standard_filters(false) diff --git a/compiler-cli/src/publish.rs b/compiler-cli/src/publish.rs index 9b84ff8a9..3e431b217 100644 --- a/compiler-cli/src/publish.rs +++ b/compiler-cli/src/publish.rs @@ -544,7 +544,7 @@ fn do_build_hex_tarball(paths: &ProjectPaths, config: &mut PackageConfig) -> Res Target::JavaScript => vec![], }; let src_files = project_files(Utf8Path::new(""))?; - let contents_tar_gz = contents_tarball(&src_files, &generated_files)?; + let contents_tar_gz = contents_tarball(&paths, &src_files, &generated_files)?; let version = "3"; let metadata = metadata_config( &built.root_package.config, @@ -648,6 +648,7 @@ fn metadata_config<'a>( } fn contents_tarball( + paths: &ProjectPaths, files: &[Utf8PathBuf], data_files: &[(Utf8PathBuf, String)], ) -> Result, Error> { @@ -656,7 +657,7 @@ fn contents_tarball( let mut tarball = tar::Builder::new(GzEncoder::new(&mut contents_tar_gz, Compression::default())); for path in files { - add_path_to_tar(&mut tarball, path)?; + add_path_to_tar(&mut tarball, paths, path)?; } for (path, contents) in data_files { add_to_tar(&mut tarball, path, contents.as_bytes())?; @@ -673,7 +674,7 @@ fn project_files(base_path: &Utf8Path) -> Result> { .chain(fs::native_files(&src)) .collect(); let private = base_path.join(Utf8Path::new("priv")); - let mut private_files: Vec = fs::private_files(&private).collect(); + let mut private_files: Vec = fs::priv_directory_files(&private).collect(); files.append(&mut private_files); let mut add = |path| { let path = base_path.join(path); @@ -755,16 +756,41 @@ where .map_err(|e| Error::add_tar(path, e)) } -fn add_path_to_tar(tarball: &mut tar::Builder, path: P) -> Result<()> +fn add_path_to_tar(tarball: &mut tar::Builder, paths: &ProjectPaths, path: P) -> Result<()> where P: AsRef, W: Write, { let path = path.as_ref(); - tracing::info!(file=?path, "Adding file to tarball"); + let path = fs::canonicalise(path)?; + + if !path.starts_with(paths.root()) { + return Err(Error::TarPathOutsideOfProjectRoot { path }); + } + + tracing::info!(file=?&path, "Adding file to tarball"); tarball - .append_path(path) - .map_err(|e| Error::add_tar(path, e)) + .append_path(&path) + .map_err(|e| Error::add_tar(&path, e)) +} + +#[test] +fn add_to_tar_symlink_rejection_test() { + let tmp_dir = tempfile::tempdir().unwrap(); + let path = std::fs::canonicalize(tmp_dir.path()).unwrap(); + let path = Utf8Path::from_path(&path).expect("Non Utf-8 Path"); + let paths = ProjectPaths::new(path.join("package")); + let mut contents_tar_gz = Vec::new(); + let mut tarball = tar::Builder::new(&mut contents_tar_gz); + + // This file is outside the root of the project, so it should + // not be possible to add it to the tar archive. + let outside_path = path.join("outside.txt"); + std::fs::write(&outside_path, "Hello").unwrap(); + match add_path_to_tar(&mut tarball, &paths, &outside_path).unwrap_err() { + Error::TarPathOutsideOfProjectRoot { path } => assert_eq!(path, outside_path), + other => panic!("Unexpected error {other:?}"), + } } #[derive(Debug, Clone)] diff --git a/compiler-core/src/error.rs b/compiler-core/src/error.rs index 7ded0c72b..2d0bc973b 100644 --- a/compiler-core/src/error.rs +++ b/compiler-core/src/error.rs @@ -418,6 +418,9 @@ file_names.iter().map(|x| x.as_str()).join(", "))] #[error("Incorrect Hex one-time-password")] IncorrectHexOneTimePassword, + + #[error("{path} could not be added to the tarball as it is outside the project root")] + TarPathOutsideOfProjectRoot { path: Utf8PathBuf }, } #[derive(Debug, Eq, PartialEq, Clone, Copy)] @@ -992,6 +995,21 @@ You'll need to re-authenticate to continue using Hex." location: None, }], + Error::TarPathOutsideOfProjectRoot { path } => vec![Diagnostic { + title: "Cannot add path to tar archive".into(), + text: wrap(&format!( + "The path {path} is outside this Gleam project, \ +so we cannot safely add it to the archive for publishing. If we permitted this \ +then malicious actors could abuse this functionality to trick you into sharing \ +your private information. + +Move the file into your Gleam project and try again." + )), + hint: None, + level: Level::Error, + location: None, + }], + Error::IncorrectHexOneTimePassword => { let text = "That two-factor authentication code was rejected by Hex, please try again. -- 2.51.2