summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/mix/tasks/pleroma/emoji.ex15
-rw-r--r--lib/pleroma/emoji/pack.ex101
-rw-r--r--lib/pleroma/frontend.ex22
-rw-r--r--lib/pleroma/safe_zip.ex216
-rw-r--r--lib/pleroma/user/backup.ex15
-rw-r--r--lib/pleroma/web/activity_pub/transmogrifier.ex187
-rw-r--r--lib/pleroma/web/plugs/ap_client_api_enabled_plug.ex34
-rw-r--r--lib/pleroma/web/plugs/http_signature_plug.ex12
-rw-r--r--lib/pleroma/web/router.ex19
9 files changed, 462 insertions, 159 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..35fe2be19
--- /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 is_safe_path?(path) do
+ # Path accepts elixir’s chardata()
+ case Path.safe_relative(path) do
+ {:ok, _} -> true
+ _ -> false
+ end
+ end
+
+ defp is_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, is_safe_type?(type)},
+ {_, true} <- {:safe_path, is_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, is_safe_path?(path)},
+ {_, {:ok, fstat}} <- {:stat, File.stat(Path.expand(path, cwd))},
+ {_, true} <- {:type, is_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/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/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/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)