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/transmogrifier.ex | 187 | ||||
| -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/router.ex | 19 | 
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)  | 
