summaryrefslogtreecommitdiff
path: root/lib/pleroma/safe_zip.ex
blob: 25fe434d600a563d4bd00c2b1de4ce8286604d92 (plain)
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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# Akkoma: Magically expressive social media
# Copyright © 2024 Akkoma Authors <https://akkoma.dev/>
# SPDX-License-Identifier: AGPL-3.0-only

defmodule Pleroma.SafeZip do
  @moduledoc """
  Wraps the subset of Erlang's zip module we’d like to use
  but enforces path-traversal safety everywhere and other checks.

  For convenience almost all functions accept both elixir strings and charlists,
  but output elixir strings themselves. However, this means the input parameter type
  can no longer be used to distinguish archive file paths from archive binary data in memory,
  thus where needed both a _data and _file variant are provided.
  """

  @type text() :: String.t() | [char()]

  defp safe_path?(path) do
    # Path accepts elixir’s chardata()
    case Path.safe_relative(path) do
      {:ok, _} -> true
      _ -> false
    end
  end

  defp safe_type?(file_type) do
    if file_type in [:regular, :directory] do
      true
    else
      false
    end
  end

  defp maybe_add_file(_type, _path_charlist, nil), do: nil

  defp maybe_add_file(:regular, path_charlist, file_list),
    do: [to_string(path_charlist) | file_list]

  defp maybe_add_file(_type, _path_charlist, file_list), do: file_list

  @spec check_safe_archive_and_maybe_list_files(binary() | [char()], [term()], boolean()) ::
          {:ok, [String.t()]} | {:error, reason :: term()}
  defp check_safe_archive_and_maybe_list_files(archive, opts, list) do
    acc = if list, do: [], else: nil

    with {:ok, table} <- :zip.table(archive, opts) do
      Enum.reduce_while(table, {:ok, acc}, fn
        # ZIP comment
        {:zip_comment, _}, acc ->
          {:cont, acc}

        # File entry
        {:zip_file, path, info, _comment, _offset, _comp_size}, {:ok, fl} ->
          with {_, type} <- {:get_type, elem(info, 2)},
               {_, true} <- {:type, safe_type?(type)},
               {_, true} <- {:safe_path, safe_path?(path)} do
            {:cont, {:ok, maybe_add_file(type, path, fl)}}
          else
            {:get_type, e} ->
              {:halt,
               {:error, "Couldn't determine file type of ZIP entry at #{path} (#{inspect(e)})"}}

            {:type, _} ->
              {:halt, {:error, "Potentially unsafe file type in ZIP at: #{path}"}}

            {:safe_path, _} ->
              {:halt, {:error, "Unsafe path in ZIP: #{path}"}}
          end

        # new OTP version?
        _, _acc ->
          {:halt, {:error, "Unknown ZIP record type"}}
      end)
    end
  end

  @spec check_safe_archive_and_list_files(binary() | [char()], [term()]) ::
          {:ok, [String.t()]} | {:error, reason :: term()}
  defp check_safe_archive_and_list_files(archive, opts \\ []) do
    check_safe_archive_and_maybe_list_files(archive, opts, true)
  end

  @spec check_safe_archive(binary() | [char()], [term()]) :: :ok | {:error, reason :: term()}
  defp check_safe_archive(archive, opts \\ []) do
    case check_safe_archive_and_maybe_list_files(archive, opts, false) do
      {:ok, _} -> :ok
      error -> error
    end
  end

  @spec check_safe_file_list([text()], text()) :: :ok | {:error, term()}
  defp check_safe_file_list([], _), do: :ok

  defp check_safe_file_list([path | tail], cwd) do
    with {_, true} <- {:path, safe_path?(path)},
         {_, {:ok, fstat}} <- {:stat, File.stat(Path.expand(path, cwd))},
         {_, true} <- {:type, safe_type?(fstat.type)} do
      check_safe_file_list(tail, cwd)
    else
      {:path, _} ->
        {:error, "Unsafe path escaping cwd: #{path}"}

      {:stat, e} ->
        {:error, "Unable to check file type of #{path}: #{inspect(e)}"}

      {:type, _} ->
        {:error, "Unsafe type at #{path}"}
    end
  end

  defp check_safe_file_list(_, _), do: {:error, "Malformed file_list"}

  @doc """
  Checks whether the archive data contais file entries for all paths from fset

  Note this really only accepts entries corresponding to regular _files_,
  if a path is contained as for example an directory, this does not count as a match.
  """
  @spec contains_all_data?(binary(), MapSet.t()) :: true | false
  def contains_all_data?(archive_data, fset) do
    with {:ok, table} <- :zip.table(archive_data) do
      remaining =
        Enum.reduce(table, fset, fn
          {:zip_file, path, info, _comment, _offset, _comp_size}, fset ->
            if elem(info, 2) == :regular do
              MapSet.delete(fset, path)
            else
              fset
            end

          _, _ ->
            fset
        end)
        |> MapSet.size()

      if remaining == 0, do: true, else: false
    else
      _ -> false
    end
  end

  @doc """
  List all file entries in ZIP, or error if invalid or unsafe.

  Note this really only lists regular files, no directories, ZIP comments or other types!
  """
  @spec list_dir_file(text()) :: {:ok, [String.t()]} | {:error, reason :: term()}
  def list_dir_file(archive) do
    path = to_charlist(archive)
    check_safe_archive_and_list_files(path)
  end

  defp stringify_zip({:ok, {fname, data}}), do: {:ok, {to_string(fname), data}}
  defp stringify_zip({:ok, fname}), do: {:ok, to_string(fname)}
  defp stringify_zip(ret), do: ret

  @spec zip(text(), text(), [text()], boolean()) ::
          {:ok, file_name :: String.t()}
          | {:ok, {file_name :: String.t(), file_data :: binary()}}
          | {:error, reason :: term()}
  def zip(name, file_list, cwd, memory \\ false) do
    opts = [{:cwd, to_charlist(cwd)}]
    opts = if memory, do: [:memory | opts], else: opts

    with :ok <- check_safe_file_list(file_list, cwd) do
      file_list = for f <- file_list, do: to_charlist(f)
      name = to_charlist(name)
      stringify_zip(:zip.zip(name, file_list, opts))
    end
  end

  @spec unzip_file(text(), text(), [text()] | nil) ::
          {:ok, [String.t()]}
          | {:error, reason :: term()}
          | {:error, {name :: text(), reason :: term()}}
  def unzip_file(archive, target_dir, file_list \\ nil) do
    do_unzip(to_charlist(archive), to_charlist(target_dir), file_list)
  end

  @spec unzip_data(binary(), text(), [text()] | nil) ::
          {:ok, [String.t()]}
          | {:error, reason :: term()}
          | {:error, {name :: text(), reason :: term()}}
  def unzip_data(archive, target_dir, file_list \\ nil) do
    do_unzip(archive, to_charlist(target_dir), file_list)
  end

  defp stringify_unzip({:ok, [{_fname, _data} | _] = filebinlist}),
    do: {:ok, Enum.map(filebinlist, fn {fname, data} -> {to_string(fname), data} end)}

  defp stringify_unzip({:ok, [_fname | _] = filelist}),
    do: {:ok, Enum.map(filelist, fn fname -> to_string(fname) end)}

  defp stringify_unzip({:error, {fname, term}}), do: {:error, {to_string(fname), term}}
  defp stringify_unzip(ret), do: ret

  @spec do_unzip(binary() | [char()], text(), [text()] | nil) ::
          {:ok, [String.t()]}
          | {:error, reason :: term()}
          | {:error, {name :: text(), reason :: term()}}
  defp do_unzip(archive, target_dir, file_list) do
    opts =
      if file_list != nil do
        [
          file_list: for(f <- file_list, do: to_charlist(f)),
          cwd: target_dir
        ]
      else
        [cwd: target_dir]
      end

    with :ok <- check_safe_archive(archive) do
      stringify_unzip(:zip.unzip(archive, opts))
    end
  end
end