From a76a260a65db1c0a0a9e8a2d20ac82681c921ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Tue, 9 Jun 2026 14:15:27 +0200 Subject: [PATCH 2/3] Validate paths and files when extracting archives --- lib/mix/lib/mix/tasks/archive.install.ex | 60 +++++++++++++++++++++--- lib/mix/test/mix/tasks/archive_test.exs | 39 +++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/lib/mix/lib/mix/tasks/archive.install.ex b/lib/mix/lib/mix/tasks/archive.install.ex index 39abd5dd5..5b8e1e77e 100644 --- a/lib/mix/lib/mix/tasks/archive.install.ex +++ b/lib/mix/lib/mix/tasks/archive.install.ex @@ -115,7 +115,8 @@ def find_previous_versions(src) do @impl true def install(basename, contents, previous) do ez_path = Path.join(Mix.path_for(:archives), basename) - dir_dest = resolve_destination(ez_path, contents) + archive_name = archive_name!(contents) + dir_dest = Path.join(Path.dirname(ez_path), archive_name) remove_previous_versions(previous) @@ -145,17 +146,62 @@ def build(_install_spec, _opts) do ### Private helpers - defp resolve_destination(ez_path, contents) do - with {:ok, [_comment, zip_first_file | _]} <- :zip.list_dir(contents), - {:zip_file, zip_first_path, _, _, _, _} = zip_first_file, - [zip_root_dir | _] = Path.split(zip_first_path) do - Path.join(Path.dirname(ez_path), zip_root_dir) + defp archive_name!(contents) do + with {:ok, files} <- :zip.list_dir(contents), + zip_files = Enum.filter(files, &match?({:zip_file, _, _, _, _, _}, &1)), + true <- zip_files != [] do + Enum.reduce(zip_files, nil, fn zip_file, root -> + validate_archive_path!(zip_file, root) + end) else _ -> - Mix.raise("Installation failed: invalid archive file") + Mix.raise("Installation failed: invalid archive file, no files found") end end + defp validate_archive_path!({:zip_file, path, file_info, _, _, _}, root) do + type = elem(file_info, 2) + path = zip_path_to_string(path) + + unless type in [:regular, :directory] do + Mix.raise( + "Installation failed: invalid archive file, #{inspect(path)} is not a regular file or directory" + ) + end + + cond do + Path.type(path) != :relative -> + Mix.raise( + "Installation failed: invalid archive file, #{inspect(path)} is an absolute path" + ) + + String.contains?(path, ["..", "\\", <<0>>]) -> + Mix.raise("Installation failed: invalid archive file, #{inspect(path)} is an unsafe path") + + true -> + :ok + end + + case String.split(path, "/", trim: true) do + [new_root | _] -> + cond do + root && root != new_root -> + Mix.raise( + "Installation failed: invalid archive file, #{inspect(path)} is outside archive root #{inspect(root)}" + ) + + true -> + new_root + end + + [] -> + Mix.raise("Installation failed: invalid archive file, #{inspect(path)} is empty") + end + end + + defp zip_path_to_string(path) when is_list(path), do: List.to_string(path) + defp zip_path_to_string(path) when is_binary(path), do: path + defp archives(name) do Mix.path_for(:archives) |> Path.join(name) diff --git a/lib/mix/test/mix/tasks/archive_test.exs b/lib/mix/test/mix/tasks/archive_test.exs index 6dc2a59ef..25c5fe590 100644 --- a/lib/mix/test/mix/tasks/archive_test.exs +++ b/lib/mix/test/mix/tasks/archive_test.exs @@ -115,6 +115,45 @@ test "archive install invalid file" do end) end + test "archive install rejects parent directory entries" do + in_tmp("archive install rejects parent directory entries", fn -> + assert {:ok, _} = + :zip.create(~c"bad-0.1.0.ez", [ + {~c"../outside", "bad"}, + {~c"bad-0.1.0/ebin/bad", "bad"} + ]) + + send(self(), {:mix_shell_input, :yes?, true}) + + assert_raise Mix.Error, ~r/invalid archive file/, fn -> + Mix.Tasks.Archive.Install.run(["bad-0.1.0.ez"]) + end + + refute File.exists?(tmp_path("userhome/outside")) + refute File.exists?(tmp_path("userhome/.mix/outside")) + refute File.dir?(tmp_path("userhome/.mix/archives/bad-0.1.0")) + end) + end + + test "archive install rejects entries outside the archive root" do + in_tmp("archive install rejects entries outside the archive root", fn -> + assert {:ok, _} = + :zip.create(~c"bad-0.1.0.ez", [ + {~c"bad-0.1.0/ebin/bad", "bad"}, + {~c"other-0.1.0/ebin/bad", "bad"} + ]) + + send(self(), {:mix_shell_input, :yes?, true}) + + assert_raise Mix.Error, ~r/invalid archive file/, fn -> + Mix.Tasks.Archive.Install.run(["bad-0.1.0.ez"]) + end + + refute File.dir?(tmp_path("userhome/.mix/archives/bad-0.1.0")) + refute File.dir?(tmp_path("userhome/.mix/archives/other-0.1.0")) + end) + end + test "archive install missing file" do message = ~r[Expected "./unlikely-to-exist-0.1.0.ez" to be a local file path] -- 2.52.0