diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ed6653a..017ec6a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Regular task to prune local transient activities - Task to manually run the transient prune job (pleroma.database prune\_task) - Ability to follow hashtags +- Option to extend `reject` in MRF-Simple to apply to entire threads, where the originating instance is rejected ## Changed - MastoAPI: Accept BooleanLike input on `/api/v1/accounts/:id/follow` (fixes follows with mastodon.py) diff --git a/config/config.exs b/config/config.exs index 8e0104400..4d6150634 100644 --- a/config/config.exs +++ b/config/config.exs @@ -391,7 +391,8 @@ accept: [], avatar_removal: [], banner_removal: [], - reject_deletes: [] + reject_deletes: [], + handle_threads: true config :pleroma, :mrf_keyword, reject: [], diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 8a336c35a..73ef41145 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -25,7 +25,7 @@ defmodule Pleroma.Config.DeprecationWarnings do def check_simple_policy_tuples do has_strings = Config.get([:mrf_simple]) - |> Enum.any?(fn {_, v} -> Enum.any?(v, &is_binary/1) end) + |> Enum.any?(fn {_, v} -> is_list(v) and Enum.any?(v, &is_binary/1) end) if has_strings do Logger.warn(""" @@ -66,6 +66,7 @@ def check_simple_policy_tuples do new_config = Config.get([:mrf_simple]) + |> Enum.filter(fn {k, v} -> not is_atom(v) end) |> Enum.map(fn {k, v} -> {k, Enum.map(v, fn diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index a59212db4..f7eb0f159 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -13,20 +13,20 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do require Pleroma.Constants - defp check_accept(%{host: actor_host} = _actor_info, object) do + defp check_accept(%{host: actor_host} = _actor_info) do accepts = instance_list(:accept) |> MRF.subdomains_regex() cond do - accepts == [] -> {:ok, object} - actor_host == Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, object} - MRF.subdomain_match?(accepts, actor_host) -> {:ok, object} + accepts == [] -> {:ok, nil} + actor_host == Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, nil} + MRF.subdomain_match?(accepts, actor_host) -> {:ok, nil} true -> {:reject, "[SimplePolicy] host not in accept list"} end end - defp check_reject(%{host: actor_host} = _actor_info, object) do + defp check_reject(%{host: actor_host} = _actor_info) do rejects = instance_list(:reject) |> MRF.subdomains_regex() @@ -34,7 +34,7 @@ defp check_reject(%{host: actor_host} = _actor_info, object) do if MRF.subdomain_match?(rejects, actor_host) do {:reject, "[SimplePolicy] host in reject list"} else - {:ok, object} + {:ok, nil} end end @@ -178,6 +178,55 @@ defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image defp check_banner_removal(_actor_info, object), do: {:ok, object} + defp extract_context_uri(%{"conversation" => "tag:" <> rest}) do + rest + |> String.split(",", parts: 2, trim: true) + |> hd() + |> case do + nil -> nil + hostname -> URI.parse("//" <> hostname) + end + end + + defp extract_context_uri(%{"context" => "http" <> _ = context}), do: URI.parse(context) + + defp extract_context_uri(_), do: nil + + defp check_context(activity) do + uri = extract_context_uri(activity) + + with {:uri, true} <- {:uri, Kernel.match?(%URI{}, uri)}, + {:ok, _} <- check_accept(uri), + {:ok, _} <- check_reject(uri) do + {:ok, activity} + else + # Can't check. + {:uri, false} -> {:ok, activity} + {:reject, nil} -> {:reject, "[SimplePolicy]"} + {:reject, _} = e -> e + _ -> {:reject, "[SimplePolicy]"} + end + end + + defp check_reply_to(%{"object" => %{"inReplyTo" => in_reply_to}} = activity) do + with {:ok, _} <- filter(in_reply_to) do + {:ok, activity} + end + end + + defp check_reply_to(activity), do: {:ok, activity} + + defp maybe_check_thread(activity) do + if Config.get([:mrf_simple, :handle_threads], true) do + with {:ok, _} <- check_context(activity), + {:ok, _} <- check_reply_to(activity) do + {:ok, activity} + end + else + {:ok, activity} + end + end + defp check_object(%{"object" => object} = activity) do with {:ok, _object} <- filter(object) do {:ok, activity} @@ -210,13 +259,14 @@ def filter(%{"type" => "Delete", "actor" => actor} = object) do def filter(%{"actor" => actor} = object) do actor_info = URI.parse(actor) - with {:ok, object} <- check_accept(actor_info, object), - {:ok, object} <- check_reject(actor_info, object), + with {:ok, _} <- check_accept(actor_info), + {:ok, _} <- check_reject(actor_info), {:ok, object} <- check_media_removal(actor_info, object), {:ok, object} <- check_media_nsfw(actor_info, object), {:ok, object} <- check_ftl_removal(actor_info, object), {:ok, object} <- check_followers_only(actor_info, object), {:ok, object} <- check_report_removal(actor_info, object), + {:ok, object} <- maybe_check_thread(object), {:ok, object} <- check_object(object) do {:ok, object} else @@ -230,8 +280,8 @@ def filter(%{"id" => actor, "type" => obj_type} = object) when obj_type in ["Application", "Group", "Organization", "Person", "Service"] do actor_info = URI.parse(actor) - with {:ok, object} <- check_accept(actor_info, object), - {:ok, object} <- check_reject(actor_info, object), + with {:ok, _} <- check_accept(actor_info), + {:ok, _} <- check_reject(actor_info), {:ok, object} <- check_avatar_removal(actor_info, object), {:ok, object} <- check_banner_removal(actor_info, object) do {:ok, object} @@ -242,11 +292,17 @@ def filter(%{"id" => actor, "type" => obj_type} = object) end end + def filter(%{"id" => id} = object) do + with {:ok, _} <- filter(id) do + {:ok, object} + end + end + def filter(object) when is_binary(object) do uri = URI.parse(object) - with {:ok, object} <- check_accept(uri, object), - {:ok, object} <- check_reject(uri, object) do + with {:ok, _} <- check_accept(uri), + {:ok, _} <- check_reject(uri) do {:ok, object} else {:reject, nil} -> {:reject, "[SimplePolicy]"} @@ -288,6 +344,7 @@ def describe do mrf_simple_excluded = Config.get(:mrf_simple) + |> Enum.filter(fn {_, v} -> is_list(v) end) |> Enum.map(fn {rule, instances} -> {rule, Enum.reject(instances, fn {host, _} -> host in exclusions end)} end) @@ -332,66 +389,78 @@ def config_description do label: "MRF Simple", description: "Simple ingress policies", children: - [ - %{ - key: :media_removal, - description: - "List of instances to strip media attachments from and the reason for doing so" - }, - %{ - key: :media_nsfw, - label: "Media NSFW", - description: - "List of instances to tag all media as NSFW (sensitive) from and the reason for doing so" - }, - %{ - key: :federated_timeline_removal, - description: - "List of instances to remove from the Federated (aka The Whole Known Network) Timeline and the reason for doing so" - }, - %{ - key: :reject, - description: - "List of instances to reject activities from (except deletes) and the reason for doing so" - }, - %{ - key: :accept, - description: - "List of instances to only accept activities from (except deletes) and the reason for doing so" - }, - %{ - key: :followers_only, - description: - "Force posts from the given instances to be visible by followers only and the reason for doing so" - }, - %{ - key: :report_removal, - description: "List of instances to reject reports from and the reason for doing so" - }, - %{ - key: :avatar_removal, - description: "List of instances to strip avatars from and the reason for doing so" - }, - %{ - key: :banner_removal, - description: "List of instances to strip banners from and the reason for doing so" - }, - %{ - key: :reject_deletes, - description: "List of instances to reject deletions from and the reason for doing so" - } - ] - |> Enum.map(fn setting -> - Map.merge( - setting, + ([ + %{ + key: :media_removal, + description: + "List of instances to strip media attachments from and the reason for doing so" + }, + %{ + key: :media_nsfw, + label: "Media NSFW", + description: + "List of instances to tag all media as NSFW (sensitive) from and the reason for doing so" + }, + %{ + key: :federated_timeline_removal, + description: + "List of instances to remove from the Federated (aka The Whole Known Network) Timeline and the reason for doing so" + }, + %{ + key: :reject, + description: + "List of instances to reject activities from (except deletes) and the reason for doing so" + }, + %{ + key: :accept, + description: + "List of instances to only accept activities from (except deletes) and the reason for doing so" + }, + %{ + key: :followers_only, + description: + "Force posts from the given instances to be visible by followers only and the reason for doing so" + }, + %{ + key: :report_removal, + description: "List of instances to reject reports from and the reason for doing so" + }, + %{ + key: :avatar_removal, + description: "List of instances to strip avatars from and the reason for doing so" + }, + %{ + key: :banner_removal, + description: "List of instances to strip banners from and the reason for doing so" + }, + %{ + key: :reject_deletes, + description: "List of instances to reject deletions from and the reason for doing so" + } + ] + |> Enum.map(fn setting -> + Map.merge( + setting, + %{ + type: {:list, :tuple}, + key_placeholder: "instance", + value_placeholder: "reason", + suggestions: [ + {"example.com", "Some reason"}, + {"*.example.com", "Another reason"} + ] + } + ) + end)) ++ + [ %{ - type: {:list, :tuple}, - key_placeholder: "instance", - value_placeholder: "reason", - suggestions: [{"example.com", "Some reason"}, {"*.example.com", "Another reason"}] + key: :handle_threads, + label: "Apply to entire threads", + type: :boolean, + description: + "Enable to filter replies to threads based from their originating instance, using the reject and accept rules" } - ) - end) + ] } end end diff --git a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs index 036573171..875cf8f43 100644 --- a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs @@ -356,6 +356,86 @@ test "reject by URI object" do assert {:reject, _} = SimplePolicy.filter(announce) end + + test "accept by matching context URI if :handle_threads is disabled" do + clear_config([:mrf_simple, :reject], [{"blocked.tld", ""}]) + clear_config([:mrf_simple, :handle_threads], false) + + remote_message = + build_remote_message() + |> Map.put("context", "https://blocked.tld/contexts/abc") + + assert {:ok, _} = SimplePolicy.filter(remote_message) + end + + test "accept by matching conversation field if :handle_threads is disabled" do + clear_config([:mrf_simple, :reject], [{"blocked.tld", ""}]) + clear_config([:mrf_simple, :handle_threads], false) + + remote_message = + build_remote_message() + |> Map.put( + "conversation", + "tag:blocked.tld,1997-06-25:objectId=12345:objectType=Conversation" + ) + + assert {:ok, _} = SimplePolicy.filter(remote_message) + end + + test "accept by matching reply ID if :handle_threads is disabled" do + clear_config([:mrf_simple, :reject], [{"blocked.tld", ""}]) + clear_config([:mrf_simple, :handle_threads], false) + + remote_message = + build_remote_message() + |> Map.put("type", "Create") + |> Map.put("object", %{ + "type" => "Note", + "inReplyTo" => "https://blocked.tld/objects/1" + }) + + assert {:ok, _} = SimplePolicy.filter(remote_message) + end + + test "reject by matching context URI if :handle_threads is enabled" do + clear_config([:mrf_simple, :reject], [{"blocked.tld", ""}]) + clear_config([:mrf_simple, :handle_threads], true) + + remote_message = + build_remote_message() + |> Map.put("context", "https://blocked.tld/contexts/abc") + + assert {:reject, _} = SimplePolicy.filter(remote_message) + end + + test "reject by matching conversation field if :handle_threads is enabled" do + clear_config([:mrf_simple, :reject], [{"blocked.tld", ""}]) + clear_config([:mrf_simple, :handle_threads], true) + + remote_message = + build_remote_message() + |> Map.put( + "conversation", + "tag:blocked.tld,1997-06-25:objectId=12345:objectType=Conversation" + ) + + assert {:reject, _} = SimplePolicy.filter(remote_message) + end + + test "reject by matching reply ID if :handle_threads is enabled" do + clear_config([:mrf_simple, :reject], [{"blocked.tld", ""}]) + clear_config([:mrf_simple, :handle_threads], true) + + remote_message = + build_remote_message() + |> Map.put("type", "Create") + |> Map.put("object", %{ + "type" => "Note", + "inReplyTo" => "https://blocked.tld/objects/1" + }) + + assert {:reject, _} = SimplePolicy.filter(remote_message) + end end describe "when :followers_only" do