1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
|
From 1c693064220db179fb9cdca8bc17326039fdd662 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20Valim?= <jose.valim@gmail.com>
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 19814f1c8..305c5d7e5 100644
--- a/lib/mix/lib/mix/tasks/archive.install.ex
+++ b/lib/mix/lib/mix/tasks/archive.install.ex
@@ -119,7 +119,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)
@@ -149,17 +150,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 88c306051..e7a0af9e6 100644
--- a/lib/mix/test/mix/tasks/archive_test.exs
+++ b/lib/mix/test/mix/tasks/archive_test.exs
@@ -119,6 +119,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
|