diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/mix/tasks/pleroma/emoji.ex | 15 | ||||
-rw-r--r-- | lib/pleroma/emoji/pack.ex | 101 | ||||
-rw-r--r-- | lib/pleroma/frontend.ex | 22 | ||||
-rw-r--r-- | lib/pleroma/safe_zip.ex | 216 | ||||
-rw-r--r-- | lib/pleroma/user/backup.ex | 15 | ||||
-rw-r--r-- | lib/pleroma/web/activity_pub/mrf/dnsrbl_policy.ex | 146 | ||||
-rw-r--r-- | lib/pleroma/web/activity_pub/mrf/fo_direct_reply.ex | 53 | ||||
-rw-r--r-- | lib/pleroma/web/activity_pub/mrf/quiet_reply.ex | 60 | ||||
-rw-r--r-- | lib/pleroma/web/activity_pub/transmogrifier.ex | 187 | ||||
-rw-r--r-- | lib/pleroma/web/metadata/providers/open_graph.ex | 26 | ||||
-rw-r--r-- | lib/pleroma/web/metadata/providers/twitter_card.ex | 54 | ||||
-rw-r--r-- | lib/pleroma/web/plugs/ap_client_api_enabled_plug.ex | 34 | ||||
-rw-r--r-- | lib/pleroma/web/plugs/http_signature_plug.ex | 12 | ||||
-rw-r--r-- | lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex | 2 | ||||
-rw-r--r-- | lib/pleroma/web/rich_media/parsers/twitter_card.ex | 11 | ||||
-rw-r--r-- | lib/pleroma/web/router.ex | 19 |
16 files changed, 514 insertions, 459 deletions
diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index 8b9c921c8..b656f161f 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -93,6 +93,7 @@ defmodule Mix.Tasks.Pleroma.Emoji do ) files = fetch_and_decode!(files_loc) + files_to_unzip = for({_, f} <- files, do: f) IO.puts(IO.ANSI.format(["Unpacking ", :bright, pack_name])) @@ -103,17 +104,7 @@ defmodule Mix.Tasks.Pleroma.Emoji do pack_name ]) - files_to_unzip = - Enum.map( - files, - fn {_, f} -> to_charlist(f) end - ) - - {:ok, _} = - :zip.unzip(binary_archive, - cwd: String.to_charlist(pack_path), - file_list: files_to_unzip - ) + {:ok, _} = Pleroma.SafeZip.unzip_data(binary_archive, pack_path, files_to_unzip) IO.puts(IO.ANSI.format(["Writing pack.json for ", :bright, pack_name])) @@ -201,7 +192,7 @@ defmodule Mix.Tasks.Pleroma.Emoji do tmp_pack_dir = Path.join(System.tmp_dir!(), "emoji-pack-#{name}") - {:ok, _} = :zip.unzip(binary_archive, cwd: String.to_charlist(tmp_pack_dir)) + {:ok, _} = Pleroma.SafeZip.unzip_data(binary_archive, tmp_pack_dir) emoji_map = Pleroma.Emoji.Loader.make_shortcode_to_file_map(tmp_pack_dir, exts) diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 785fdb8b2..c58748d3c 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -24,12 +24,13 @@ defmodule Pleroma.Emoji.Pack do alias Pleroma.Emoji alias Pleroma.Emoji.Pack + alias Pleroma.SafeZip alias Pleroma.Utils @spec create(String.t()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} def create(name) do with :ok <- validate_not_empty([name]), - dir <- Path.join(emoji_path(), name), + dir <- path_join_name_safe(emoji_path(), name), :ok <- File.mkdir(dir) do save_pack(%__MODULE__{pack_file: Path.join(dir, "pack.json")}) end @@ -65,43 +66,21 @@ defmodule Pleroma.Emoji.Pack do {:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values} def delete(name) do with :ok <- validate_not_empty([name]), - pack_path <- Path.join(emoji_path(), name) do + pack_path <- path_join_name_safe(emoji_path(), name) do File.rm_rf(pack_path) end end - @spec unpack_zip_emojies(list(tuple())) :: list(map()) - defp unpack_zip_emojies(zip_files) do - Enum.reduce(zip_files, [], fn - {_, path, s, _, _, _}, acc when elem(s, 2) == :regular -> - with( - filename <- Path.basename(path), - shortcode <- Path.basename(filename, Path.extname(filename)), - false <- Emoji.exist?(shortcode) - ) do - [%{path: path, filename: path, shortcode: shortcode} | acc] - else - _ -> acc - end - - _, acc -> - acc - end) - end - @spec add_file(t(), String.t(), Path.t(), Plug.Upload.t()) :: {:ok, t()} | {:error, File.posix() | atom()} def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"} = file) do - with {:ok, zip_files} <- :zip.table(to_charlist(file.path)), - [_ | _] = emojies <- unpack_zip_emojies(zip_files), + with {:ok, zip_files} <- SafeZip.list_dir_file(file.path), + [_ | _] = emojies <- map_zip_emojies(zip_files), {:ok, tmp_dir} <- Utils.tmp_dir("emoji") do try do {:ok, _emoji_files} = - :zip.unzip( - to_charlist(file.path), - [{:file_list, Enum.map(emojies, & &1[:path])}, {:cwd, String.to_charlist(tmp_dir)}] - ) + SafeZip.unzip_file(file.path, tmp_dir, Enum.map(emojies, & &1[:path])) {_, updated_pack} = Enum.map_reduce(emojies, pack, fn item, emoji_pack -> @@ -292,7 +271,7 @@ defmodule Pleroma.Emoji.Pack do @spec load_pack(String.t()) :: {:ok, t()} | {:error, :file.posix()} def load_pack(name) do name = Path.basename(name) - pack_file = Path.join([emoji_path(), name, "pack.json"]) + pack_file = path_join_name_safe(emoji_path(), name) |> Path.join("pack.json") with {:ok, _} <- File.stat(pack_file), {:ok, pack_data} <- File.read(pack_file) do @@ -416,10 +395,9 @@ defmodule Pleroma.Emoji.Pack do end defp create_archive_and_cache(pack, hash) do - files = [~c"pack.json" | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)] - - {:ok, {_, result}} = - :zip.zip(~c"#{pack.name}.zip", files, [:memory, cwd: to_charlist(pack.path)]) + pack_file_list = Enum.into(pack.files, [], fn {_, f} -> f end) + files = ["pack.json" | pack_file_list] + {:ok, {_, result}} = SafeZip.zip("#{pack.name}.zip", files, pack.path, true) ttl_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) overall_ttl = :timer.seconds(ttl_per_file * Enum.count(files)) @@ -478,7 +456,7 @@ defmodule Pleroma.Emoji.Pack do end defp save_file(%Plug.Upload{path: upload_path}, pack, filename) do - file_path = Path.join(pack.path, filename) + file_path = path_join_safe(pack.path, filename) create_subdirs(file_path) with {:ok, _} <- File.copy(upload_path, file_path) do @@ -497,8 +475,8 @@ defmodule Pleroma.Emoji.Pack do end defp rename_file(pack, filename, new_filename) do - old_path = Path.join(pack.path, filename) - new_path = Path.join(pack.path, new_filename) + old_path = path_join_safe(pack.path, filename) + new_path = path_join_safe(pack.path, new_filename) create_subdirs(new_path) with :ok <- File.rename(old_path, new_path) do @@ -516,7 +494,7 @@ defmodule Pleroma.Emoji.Pack do defp remove_file(pack, shortcode) do with {:ok, filename} <- get_filename(pack, shortcode), - emoji <- Path.join(pack.path, filename), + emoji <- path_join_safe(pack.path, filename), :ok <- File.rm(emoji) do remove_dir_if_empty(emoji, filename) end @@ -534,7 +512,7 @@ defmodule Pleroma.Emoji.Pack do defp get_filename(pack, shortcode) do with %{^shortcode => filename} when is_binary(filename) <- pack.files, - file_path <- Path.join(pack.path, filename), + file_path <- path_join_safe(pack.path, filename), {:ok, _} <- File.stat(file_path) do {:ok, filename} else @@ -584,11 +562,10 @@ defmodule Pleroma.Emoji.Pack do defp unzip(archive, pack_info, remote_pack, local_pack) do with :ok <- File.mkdir_p!(local_pack.path) do - files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end) + files = Enum.map(remote_pack["files"], fn {_, path} -> path end) # Fallback cannot contain a pack.json file - files = if pack_info[:fallback], do: files, else: [~c"pack.json" | files] - - :zip.unzip(archive, cwd: to_charlist(local_pack.path), file_list: files) + files = if pack_info[:fallback], do: files, else: ["pack.json" | files] + SafeZip.unzip_data(archive, local_pack.path, files) end end @@ -649,13 +626,43 @@ defmodule Pleroma.Emoji.Pack do end defp validate_has_all_files(pack, zip) do - with {:ok, f_list} <- :zip.unzip(zip, [:memory]) do - # Check if all files from the pack.json are in the archive - pack.files - |> Enum.all?(fn {_, from_manifest} -> - List.keyfind(f_list, to_charlist(from_manifest), 0) + # Check if all files from the pack.json are in the archive + eset = + Enum.reduce(pack.files, MapSet.new(), fn + {_, file}, s -> MapSet.put(s, to_charlist(file)) end) - |> if(do: :ok, else: {:error, :incomplete}) + + if SafeZip.contains_all_data?(zip, eset), + do: :ok, + else: {:error, :incomplete} + end + + defp path_join_name_safe(dir, name) do + if to_string(name) != Path.basename(name) or name in ["..", ".", ""] do + raise "Invalid or malicious pack name: #{name}" + else + Path.join(dir, name) end end + + defp path_join_safe(dir, path) do + {:ok, safe_path} = Path.safe_relative(path) + Path.join(dir, safe_path) + end + + defp map_zip_emojies(zip_files) do + Enum.reduce(zip_files, [], fn path, acc -> + with( + filename <- Path.basename(path), + shortcode <- Path.basename(filename, Path.extname(filename)), + # note: this only checks the shortcode, if an emoji already exists on the same path, but + # with a different shortcode, the existing one will be degraded to an alias of the new + false <- Emoji.exist?(shortcode) + ) do + [%{path: path, filename: path, shortcode: shortcode} | acc] + else + _ -> acc + end + end) + end end diff --git a/lib/pleroma/frontend.ex b/lib/pleroma/frontend.ex index a4f427ae5..fe7f525ea 100644 --- a/lib/pleroma/frontend.ex +++ b/lib/pleroma/frontend.ex @@ -65,24 +65,12 @@ defmodule Pleroma.Frontend do end def unzip(zip, dest) do - with {:ok, unzipped} <- :zip.unzip(zip, [:memory]) do - File.rm_rf!(dest) - File.mkdir_p!(dest) - - Enum.each(unzipped, fn {filename, data} -> - path = filename - - new_file_path = Path.join(dest, path) - - path - |> Path.dirname() - |> then(&Path.join(dest, &1)) - |> File.mkdir_p!() + File.rm_rf!(dest) + File.mkdir_p!(dest) - if not File.dir?(new_file_path) do - File.write!(new_file_path, data) - end - end) + case Pleroma.SafeZip.unzip_data(zip, dest) do + {:ok, _} -> :ok + error -> error end end diff --git a/lib/pleroma/safe_zip.ex b/lib/pleroma/safe_zip.ex new file mode 100644 index 000000000..25fe434d6 --- /dev/null +++ b/lib/pleroma/safe_zip.ex @@ -0,0 +1,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 diff --git a/lib/pleroma/user/backup.ex b/lib/pleroma/user/backup.ex index cdff297a9..244b08adb 100644 --- a/lib/pleroma/user/backup.ex +++ b/lib/pleroma/user/backup.ex @@ -16,6 +16,7 @@ defmodule Pleroma.User.Backup do alias Pleroma.Bookmark alias Pleroma.Config alias Pleroma.Repo + alias Pleroma.SafeZip alias Pleroma.Uploaders.Uploader alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -179,12 +180,12 @@ defmodule Pleroma.User.Backup do end @files [ - ~c"actor.json", - ~c"outbox.json", - ~c"likes.json", - ~c"bookmarks.json", - ~c"followers.json", - ~c"following.json" + "actor.json", + "outbox.json", + "likes.json", + "bookmarks.json", + "followers.json", + "following.json" ] @spec run(t()) :: {:ok, t()} | {:error, :failed} @@ -200,7 +201,7 @@ defmodule Pleroma.User.Backup do {_, :ok} <- {:followers, followers(backup.tempdir, backup.user)}, {_, :ok} <- {:following, following(backup.tempdir, backup.user)}, {_, {:ok, _zip_path}} <- - {:zip, :zip.create(to_charlist(tempfile), @files, cwd: to_charlist(backup.tempdir))}, + {:zip, SafeZip.zip(tempfile, @files, backup.tempdir)}, {_, {:ok, %File.Stat{size: zip_size}}} <- {:filestat, File.stat(tempfile)}, {:ok, updated_backup} <- update_record(backup, %{file_size: zip_size}) do {:ok, updated_backup} diff --git a/lib/pleroma/web/activity_pub/mrf/dnsrbl_policy.ex b/lib/pleroma/web/activity_pub/mrf/dnsrbl_policy.ex deleted file mode 100644 index ca41c464c..000000000 --- a/lib/pleroma/web/activity_pub/mrf/dnsrbl_policy.ex +++ /dev/null @@ -1,146 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.MRF.DNSRBLPolicy do - @moduledoc """ - Dynamic activity filtering based on an RBL database - - This MRF makes queries to a custom DNS server which will - respond with values indicating the classification of the domain - the activity originated from. This method has been widely used - in the email anti-spam industry for very fast reputation checks. - - e.g., if the DNS response is 127.0.0.1 or empty, the domain is OK - Other values such as 127.0.0.2 may be used for specific classifications. - - Information for why the host is blocked can be stored in a corresponding TXT record. - - This method is fail-open so if the queries fail the activites are accepted. - - An example of software meant for this purpsoe is rbldnsd which can be found - at http://www.corpit.ru/mjt/rbldnsd.html or mirrored at - https://git.pleroma.social/feld/rbldnsd - - It is highly recommended that you run your own copy of rbldnsd and use an - external mechanism to sync/share the contents of the zone file. This is - important to keep the latency on the queries as low as possible and prevent - your DNS server from being attacked so it fails and content is permitted. - """ - - @behaviour Pleroma.Web.ActivityPub.MRF.Policy - - alias Pleroma.Config - - require Logger - - @query_retries 1 - @query_timeout 500 - - @impl true - def filter(%{"actor" => actor} = activity) do - actor_info = URI.parse(actor) - - with {:ok, activity} <- check_rbl(actor_info, activity) do - {:ok, activity} - else - _ -> {:reject, "[DNSRBLPolicy]"} - end - end - - @impl true - def filter(activity), do: {:ok, activity} - - @impl true - def describe do - mrf_dnsrbl = - Config.get(:mrf_dnsrbl) - |> Enum.into(%{}) - - {:ok, %{mrf_dnsrbl: mrf_dnsrbl}} - end - - @impl true - def config_description do - %{ - key: :mrf_dnsrbl, - related_policy: "Pleroma.Web.ActivityPub.MRF.DNSRBLPolicy", - label: "MRF DNSRBL", - description: "DNS RealTime Blackhole Policy", - children: [ - %{ - key: :nameserver, - type: {:string}, - description: "DNSRBL Nameserver to Query (IP or hostame)", - suggestions: ["127.0.0.1"] - }, - %{ - key: :port, - type: {:string}, - description: "Nameserver port", - suggestions: ["53"] - }, - %{ - key: :zone, - type: {:string}, - description: "Root zone for querying", - suggestions: ["bl.pleroma.com"] - } - ] - } - end - - defp check_rbl(%{host: actor_host}, activity) do - with false <- match?(^actor_host, Pleroma.Web.Endpoint.host()), - zone when not is_nil(zone) <- Keyword.get(Config.get([:mrf_dnsrbl]), :zone) do - query = - Enum.join([actor_host, zone], ".") - |> String.to_charlist() - - rbl_response = rblquery(query) - - if Enum.empty?(rbl_response) do - {:ok, activity} - else - Task.start(fn -> - reason = - case rblquery(query, :txt) do - [[result]] -> result - _ -> "undefined" - end - - Logger.warning( - "DNSRBL Rejected activity from #{actor_host} for reason: #{inspect(reason)}" - ) - end) - - :error - end - else - _ -> {:ok, activity} - end - end - - defp get_rblhost_ip(rblhost) do - case rblhost |> String.to_charlist() |> :inet_parse.address() do - {:ok, _} -> rblhost |> String.to_charlist() |> :inet_parse.address() - _ -> {:ok, rblhost |> String.to_charlist() |> :inet_res.lookup(:in, :a) |> Enum.random()} - end - end - - defp rblquery(query, type \\ :a) do - config = Config.get([:mrf_dnsrbl]) - - case get_rblhost_ip(config[:nameserver]) do - {:ok, rblnsip} -> - :inet_res.lookup(query, :in, type, - nameservers: [{rblnsip, config[:port]}], - timeout: @query_timeout, - retry: @query_retries - ) - - _ -> - [] - end - end -end diff --git a/lib/pleroma/web/activity_pub/mrf/fo_direct_reply.ex b/lib/pleroma/web/activity_pub/mrf/fo_direct_reply.ex deleted file mode 100644 index 2cf22745a..000000000 --- a/lib/pleroma/web/activity_pub/mrf/fo_direct_reply.ex +++ /dev/null @@ -1,53 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.MRF.FODirectReply do - @moduledoc """ - FODirectReply alters the scope of replies to activities which are Followers Only to be Direct. The purpose of this policy is to prevent broken threads for followers of the reply author because their response was to a user that they are not also following. - """ - - alias Pleroma.Object - alias Pleroma.User - alias Pleroma.Web.ActivityPub.Visibility - - @behaviour Pleroma.Web.ActivityPub.MRF.Policy - - @impl true - def filter( - %{ - "type" => "Create", - "to" => to, - "object" => %{ - "actor" => actor, - "type" => "Note", - "inReplyTo" => in_reply_to - } - } = activity - ) do - with true <- is_binary(in_reply_to), - %User{follower_address: followers_collection, local: true} <- User.get_by_ap_id(actor), - %Object{} = in_reply_to_object <- Object.get_by_ap_id(in_reply_to), - "private" <- Visibility.get_visibility(in_reply_to_object) do - direct_to = to -- [followers_collection] - - updated_activity = - activity - |> Map.put("cc", []) - |> Map.put("to", direct_to) - |> Map.put("directMessage", true) - |> put_in(["object", "cc"], []) - |> put_in(["object", "to"], direct_to) - - {:ok, updated_activity} - else - _ -> {:ok, activity} - end - end - - @impl true - def filter(activity), do: {:ok, activity} - - @impl true - def describe, do: {:ok, %{}} -end diff --git a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex b/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex deleted file mode 100644 index b07dc3b56..000000000 --- a/lib/pleroma/web/activity_pub/mrf/quiet_reply.ex +++ /dev/null @@ -1,60 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.MRF.QuietReply do - @moduledoc """ - QuietReply alters the scope of activities from local users when replying by enforcing them to be "Unlisted" or "Quiet Public". This delivers the activity to all the expected recipients and instances, but it will not be published in the Federated / The Whole Known Network timelines. It will still be published to the Home timelines of the user's followers and visible to anyone who opens the thread. - """ - require Pleroma.Constants - - alias Pleroma.User - - @behaviour Pleroma.Web.ActivityPub.MRF.Policy - - @impl true - def history_awareness, do: :auto - - @impl true - def filter( - %{ - "type" => "Create", - "to" => to, - "cc" => cc, - "object" => %{ - "actor" => actor, - "type" => "Note", - "inReplyTo" => in_reply_to - } - } = activity - ) do - with true <- is_binary(in_reply_to), - false <- match?([], cc), - %User{follower_address: followers_collection, local: true} <- - User.get_by_ap_id(actor) do - updated_to = - to - |> Kernel.++([followers_collection]) - |> Kernel.--([Pleroma.Constants.as_public()]) - - updated_cc = [Pleroma.Constants.as_public()] - - updated_activity = - activity - |> Map.put("to", updated_to) - |> Map.put("cc", updated_cc) - |> put_in(["object", "to"], updated_to) - |> put_in(["object", "cc"], updated_cc) - - {:ok, updated_activity} - else - _ -> {:ok, activity} - end - end - - @impl true - def filter(activity), do: {:ok, activity} - - @impl true - def describe, do: {:ok, %{}} -end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 4c9956c7a..1e6ee7dc8 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -43,6 +43,38 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do |> fix_content_map() |> fix_addressing() |> fix_summary() + |> fix_history(&fix_object/1) + end + + defp maybe_fix_object(%{"attributedTo" => _} = object), do: fix_object(object) + defp maybe_fix_object(object), do: object + + defp fix_history(%{"formerRepresentations" => %{"orderedItems" => list}} = obj, fix_fun) + when is_list(list) do + update_in(obj["formerRepresentations"]["orderedItems"], fn h -> Enum.map(h, fix_fun) end) + end + + defp fix_history(obj, _), do: obj + + defp fix_recursive(obj, fun) do + # unlike Erlang, Elixir does not support recursive inline functions + # which would allow us to avoid reconstructing this on every recursion + rec_fun = fn + obj when is_map(obj) -> fix_recursive(obj, fun) + # there may be simple AP IDs in history (or object field) + obj -> obj + end + + obj + |> fun.() + |> fix_history(rec_fun) + |> then(fn + %{"object" => object} = doc when is_map(object) -> + update_in(doc["object"], rec_fun) + + apdoc -> + apdoc + end) end def fix_summary(%{"summary" => nil} = object) do @@ -375,11 +407,18 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end) end - def handle_incoming(data, options \\ []) + def handle_incoming(data, options \\ []) do + data + |> fix_recursive(&strip_internal_fields/1) + |> handle_incoming_normalized(options) + end # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them # with nil ID. - def handle_incoming(%{"type" => "Flag", "object" => objects, "actor" => actor} = data, _options) do + defp handle_incoming_normalized( + %{"type" => "Flag", "object" => objects, "actor" => actor} = data, + _options + ) do with context <- data["context"] || Utils.generate_context_id(), content <- data["content"] || "", %User{} = actor <- User.get_cached_by_ap_id(actor), @@ -400,16 +439,17 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end # disallow objects with bogus IDs - def handle_incoming(%{"id" => nil}, _options), do: :error - def handle_incoming(%{"id" => ""}, _options), do: :error + defp handle_incoming_normalized(%{"id" => nil}, _options), do: :error + defp handle_incoming_normalized(%{"id" => ""}, _options), do: :error # length of https:// = 8, should validate better, but good enough for now. - def handle_incoming(%{"id" => id}, _options) when is_binary(id) and byte_size(id) < 8, - do: :error - - def handle_incoming( - %{"type" => "Listen", "object" => %{"type" => "Audio"} = object} = data, - options - ) do + defp handle_incoming_normalized(%{"id" => id}, _options) + when is_binary(id) and byte_size(id) < 8, + do: :error + + defp handle_incoming_normalized( + %{"type" => "Listen", "object" => %{"type" => "Audio"} = object} = data, + options + ) do actor = Containment.get_actor(data) data = @@ -451,25 +491,25 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do "star" => "⭐" } - @doc "Rewrite misskey likes into EmojiReacts" - def handle_incoming( - %{ - "type" => "Like", - "_misskey_reaction" => reaction - } = data, - options - ) do + # Rewrite misskey likes into EmojiReacts + defp handle_incoming_normalized( + %{ + "type" => "Like", + "_misskey_reaction" => reaction + } = data, + options + ) do data |> Map.put("type", "EmojiReact") |> Map.put("content", @misskey_reactions[reaction] || reaction) - |> handle_incoming(options) + |> handle_incoming_normalized(options) end - def handle_incoming( - %{"type" => "Create", "object" => %{"type" => objtype, "id" => obj_id}} = data, - options - ) - when objtype in ~w{Question Answer ChatMessage Audio Video Event Article Note Page Image} do + defp handle_incoming_normalized( + %{"type" => "Create", "object" => %{"type" => objtype, "id" => obj_id}} = data, + options + ) + when objtype in ~w{Question Answer ChatMessage Audio Video Event Article Note Page Image} do fetch_options = Keyword.put(options, :depth, (options[:depth] || 0) + 1) object = @@ -492,8 +532,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end - def handle_incoming(%{"type" => type} = data, _options) - when type in ~w{Like EmojiReact Announce Add Remove} do + defp handle_incoming_normalized(%{"type" => type} = data, _options) + when type in ~w{Like EmojiReact Announce Add Remove} do with :ok <- ObjectValidator.fetch_actor_and_object(data), {:ok, activity, _meta} <- Pipeline.common_pipeline(data, local: false) do @@ -503,11 +543,14 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end - def handle_incoming( - %{"type" => type} = data, - _options - ) - when type in ~w{Update Block Follow Accept Reject} do + defp handle_incoming_normalized( + %{"type" => type} = data, + _options + ) + when type in ~w{Update Block Follow Accept Reject} do + fixed_obj = maybe_fix_object(data["object"]) + data = if fixed_obj != nil, do: %{data | "object" => fixed_obj}, else: data + with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do @@ -515,10 +558,10 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end - def handle_incoming( - %{"type" => "Delete"} = data, - _options - ) do + defp handle_incoming_normalized( + %{"type" => "Delete"} = data, + _options + ) do with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} @@ -541,15 +584,15 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end - def handle_incoming( - %{ - "type" => "Undo", - "object" => %{"type" => "Follow", "object" => followed}, - "actor" => follower, - "id" => id - } = _data, - _options - ) do + defp handle_incoming_normalized( + %{ + "type" => "Undo", + "object" => %{"type" => "Follow", "object" => followed}, + "actor" => follower, + "id" => id + } = _data, + _options + ) do with %User{local: true} = followed <- User.get_cached_by_ap_id(followed), {:ok, %User{} = follower} <- User.get_or_fetch_by_ap_id(follower), {:ok, activity} <- ActivityPub.unfollow(follower, followed, id, false) do @@ -560,46 +603,46 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end - def handle_incoming( - %{ - "type" => "Undo", - "object" => %{"type" => type} - } = data, - _options - ) - when type in ["Like", "EmojiReact", "Announce", "Block"] do + defp handle_incoming_normalized( + %{ + "type" => "Undo", + "object" => %{"type" => type} + } = data, + _options + ) + when type in ["Like", "EmojiReact", "Announce", "Block"] do with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do {:ok, activity} end end # For Undos that don't have the complete object attached, try to find it in our database. - def handle_incoming( - %{ - "type" => "Undo", - "object" => object - } = activity, - options - ) - when is_binary(object) do + defp handle_incoming_normalized( + %{ + "type" => "Undo", + "object" => object + } = activity, + options + ) + when is_binary(object) do with %Activity{data: data} <- Activity.get_by_ap_id(object) do activity |> Map.put("object", data) - |> handle_incoming(options) + |> handle_incoming_normalized(options) else _e -> :error end end - def handle_incoming( - %{ - "type" => "Move", - "actor" => origin_actor, - "object" => origin_actor, - "target" => target_actor - }, - _options - ) do + defp handle_incoming_normalized( + %{ + "type" => "Move", + "actor" => origin_actor, + "object" => origin_actor, + "target" => target_actor + }, + _options + ) do with %User{} = origin_user <- User.get_cached_by_ap_id(origin_actor), {:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_actor), true <- origin_actor in target_user.also_known_as do @@ -609,7 +652,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do end end - def handle_incoming(_, _), do: :error + defp handle_incoming_normalized(_, _), do: :error @spec get_obj_helper(String.t(), Keyword.t()) :: {:ok, Object.t()} | nil def get_obj_helper(id, options \\ []) do diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index fa5fbe553..604434df2 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -78,10 +78,10 @@ defmodule Pleroma.Web.Metadata.Providers.OpenGraph do # object when a Video or GIF is attached it will display that in Whatsapp Rich Preview. case Utils.fetch_media_type(@media_types, url["mediaType"]) do "audio" -> - [ - {:meta, [property: "og:audio", content: MediaProxy.url(url["href"])], []} - | acc - ] + acc ++ + [ + {:meta, [property: "og:audio", content: MediaProxy.url(url["href"])], []} + ] # Not using preview_url for this. It saves bandwidth, but the image dimensions will # be wrong. We generate it on the fly and have no way to capture or analyze the @@ -89,18 +89,18 @@ defmodule Pleroma.Web.Metadata.Providers.OpenGraph do # in timelines too, but you can get clever with the aspect ratio metadata as a # workaround. "image" -> - [ - {:meta, [property: "og:image", content: MediaProxy.url(url["href"])], []}, - {:meta, [property: "og:image:alt", content: attachment["name"]], []} - | acc - ] + (acc ++ + [ + {:meta, [property: "og:image", content: MediaProxy.url(url["href"])], []}, + {:meta, [property: "og:image:alt", content: attachment["name"]], []} + ]) |> maybe_add_dimensions(url) "video" -> - [ - {:meta, [property: "og:video", content: MediaProxy.url(url["href"])], []} - | acc - ] + (acc ++ + [ + {:meta, [property: "og:video", content: MediaProxy.url(url["href"])], []} + ]) |> maybe_add_dimensions(url) |> maybe_add_video_thumbnail(url) diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index 7f50877c3..212fa85ed 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -61,13 +61,13 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCard do Enum.reduce(attachment["url"], [], fn url, acc -> case Utils.fetch_media_type(@media_types, url["mediaType"]) do "audio" -> - [ - {:meta, [name: "twitter:card", content: "player"], []}, - {:meta, [name: "twitter:player:width", content: "480"], []}, - {:meta, [name: "twitter:player:height", content: "80"], []}, - {:meta, [name: "twitter:player", content: player_url(id)], []} - | acc - ] + acc ++ + [ + {:meta, [name: "twitter:card", content: "player"], []}, + {:meta, [name: "twitter:player:width", content: "480"], []}, + {:meta, [name: "twitter:player:height", content: "80"], []}, + {:meta, [name: "twitter:player", content: player_url(id)], []} + ] # Not using preview_url for this. It saves bandwidth, but the image dimensions will # be wrong. We generate it on the fly and have no way to capture or analyze the @@ -75,16 +75,16 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCard do # in timelines too, but you can get clever with the aspect ratio metadata as a # workaround. "image" -> - [ - {:meta, [name: "twitter:card", content: "summary_large_image"], []}, - {:meta, + (acc ++ [ - name: "twitter:image", - content: MediaProxy.url(url["href"]) - ], []}, - {:meta, [name: "twitter:image:alt", content: truncate(attachment["name"])], []} - | acc - ] + {:meta, [name: "twitter:card", content: "summary_large_image"], []}, + {:meta, + [ + name: "twitter:image", + content: MediaProxy.url(url["href"]) + ], []}, + {:meta, [name: "twitter:image:alt", content: truncate(attachment["name"])], []} + ]) |> maybe_add_dimensions(url) "video" -> @@ -92,17 +92,17 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCard do height = url["height"] || 480 width = url["width"] || 480 - [ - {:meta, [name: "twitter:card", content: "player"], []}, - {:meta, [name: "twitter:player", content: player_url(id)], []}, - {:meta, [name: "twitter:player:width", content: "#{width}"], []}, - {:meta, [name: "twitter:player:height", content: "#{height}"], []}, - {:meta, [name: "twitter:player:stream", content: MediaProxy.url(url["href"])], - []}, - {:meta, [name: "twitter:player:stream:content_type", content: url["mediaType"]], - []} - | acc - ] + acc ++ + [ + {:meta, [name: "twitter:card", content: "player"], []}, + {:meta, [name: "twitter:player", content: player_url(id)], []}, + {:meta, [name: "twitter:player:width", content: "#{width}"], []}, + {:meta, [name: "twitter:player:height", content: "#{height}"], []}, + {:meta, [name: "twitter:player:stream", content: MediaProxy.url(url["href"])], + []}, + {:meta, [name: "twitter:player:stream:content_type", content: url["mediaType"]], + []} + ] _ -> acc diff --git a/lib/pleroma/web/plugs/ap_client_api_enabled_plug.ex b/lib/pleroma/web/plugs/ap_client_api_enabled_plug.ex new file mode 100644 index 000000000..6807673f9 --- /dev/null +++ b/lib/pleroma/web/plugs/ap_client_api_enabled_plug.ex @@ -0,0 +1,34 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.APClientApiEnabledPlug do + import Plug.Conn + import Phoenix.Controller, only: [text: 2] + + @config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config) + @enabled_path [:activitypub, :client_api_enabled] + + def init(options \\ []), do: Map.new(options) + + def call(conn, %{allow_server: true}) do + if @config_impl.get(@enabled_path, false) do + conn + else + conn + |> assign(:user, nil) + |> assign(:token, nil) + end + end + + def call(conn, _) do + if @config_impl.get(@enabled_path, false) do + conn + else + conn + |> put_status(:forbidden) + |> text("C2S not enabled") + |> halt() + end + end +end diff --git a/lib/pleroma/web/plugs/http_signature_plug.ex b/lib/pleroma/web/plugs/http_signature_plug.ex index 67974599a..2e16212ce 100644 --- a/lib/pleroma/web/plugs/http_signature_plug.ex +++ b/lib/pleroma/web/plugs/http_signature_plug.ex @@ -19,8 +19,16 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do options end - def call(%{assigns: %{valid_signature: true}} = conn, _opts) do - conn + def call(%{assigns: %{valid_signature: true}} = conn, _opts), do: conn + + # skip for C2S requests from authenticated users + def call(%{assigns: %{user: %Pleroma.User{}}} = conn, _opts) do + if get_format(conn) in ["json", "activity+json"] do + # ensure access token is provided for 2FA + Pleroma.Web.Plugs.EnsureAuthenticatedPlug.call(conn, %{}) + else + conn + end end def call(conn, _opts) do diff --git a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex index 320a5f515..c42e2c96b 100644 --- a/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex +++ b/lib/pleroma/web/rich_media/parsers/meta_tags_parser.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.RichMedia.Parsers.MetaTagsParser do |> Enum.reduce(data, fn el, acc -> attributes = normalize_attributes(el, prefix, key_name, value_name) - Map.merge(acc, attributes) + Map.merge(attributes, acc) end) |> maybe_put_title(html) end diff --git a/lib/pleroma/web/rich_media/parsers/twitter_card.ex b/lib/pleroma/web/rich_media/parsers/twitter_card.ex index cc653729d..6f6f8b2ae 100644 --- a/lib/pleroma/web/rich_media/parsers/twitter_card.ex +++ b/lib/pleroma/web/rich_media/parsers/twitter_card.ex @@ -11,5 +11,16 @@ defmodule Pleroma.Web.RichMedia.Parsers.TwitterCard do |> MetaTagsParser.parse(html, "og", "property") |> MetaTagsParser.parse(html, "twitter", "name") |> MetaTagsParser.parse(html, "twitter", "property") + |> filter_tags() + end + + defp filter_tags(tags) do + Map.filter(tags, fn {k, _v} -> + cond do + k in ["card", "description", "image", "title", "ttl", "type", "url"] -> true + String.starts_with?(k, "image:") -> true + true -> false + end + end) end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index ca76427ac..bf8ebf3e4 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -907,22 +907,37 @@ defmodule Pleroma.Web.Router do # Client to Server (C2S) AP interactions pipeline :activitypub_client do plug(:ap_service_actor) + plug(Pleroma.Web.Plugs.APClientApiEnabledPlug) plug(:fetch_session) plug(:authenticate) plug(:after_auth) end + # AP interactions used by both S2S and C2S + pipeline :activitypub_server_or_client do + plug(:ap_service_actor) + plug(:fetch_session) + plug(:authenticate) + plug(Pleroma.Web.Plugs.APClientApiEnabledPlug, allow_server: true) + plug(:after_auth) + plug(:http_signature) + end + scope "/", Pleroma.Web.ActivityPub do pipe_through([:activitypub_client]) get("/api/ap/whoami", ActivityPubController, :whoami) get("/users/:nickname/inbox", ActivityPubController, :read_inbox) - get("/users/:nickname/outbox", ActivityPubController, :outbox) post("/users/:nickname/outbox", ActivityPubController, :update_outbox) post("/api/ap/upload_media", ActivityPubController, :upload_media) + end + + scope "/", Pleroma.Web.ActivityPub do + pipe_through([:activitypub_server_or_client]) + + get("/users/:nickname/outbox", ActivityPubController, :outbox) - # The following two are S2S as well, see `ActivityPub.fetch_follow_information_for_user/1`: get("/users/:nickname/followers", ActivityPubController, :followers) get("/users/:nickname/following", ActivityPubController, :following) get("/users/:nickname/collections/featured", ActivityPubController, :pinned) |