extend reject MRF to check if originating instance is blocked
[akkoma] / lib / pleroma / web / activity_pub / mrf / simple_policy.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do
6 @moduledoc "Filter activities depending on their origin instance"
7 @behaviour Pleroma.Web.ActivityPub.MRF.Policy
8
9 alias Pleroma.Config
10 alias Pleroma.FollowingRelationship
11 alias Pleroma.User
12 alias Pleroma.Web.ActivityPub.MRF
13
14 require Pleroma.Constants
15
16 defp check_accept(%{host: actor_host} = _actor_info) do
17 accepts =
18 instance_list(:accept)
19 |> MRF.subdomains_regex()
20
21 cond do
22 accepts == [] -> {:ok, nil}
23 actor_host == Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, nil}
24 MRF.subdomain_match?(accepts, actor_host) -> {:ok, nil}
25 true -> {:reject, "[SimplePolicy] host not in accept list"}
26 end
27 end
28
29 defp check_reject(%{host: actor_host} = _actor_info) do
30 rejects =
31 instance_list(:reject)
32 |> MRF.subdomains_regex()
33
34 if MRF.subdomain_match?(rejects, actor_host) do
35 {:reject, "[SimplePolicy] host in reject list"}
36 else
37 {:ok, nil}
38 end
39 end
40
41 defp check_media_removal(
42 %{host: actor_host} = _actor_info,
43 %{"type" => type, "object" => %{"attachment" => child_attachment}} = object
44 )
45 when type in ["Create", "Update"] and length(child_attachment) > 0 do
46 media_removal =
47 instance_list(:media_removal)
48 |> MRF.subdomains_regex()
49
50 object =
51 if MRF.subdomain_match?(media_removal, actor_host) do
52 child_object = Map.delete(object["object"], "attachment")
53 Map.put(object, "object", child_object)
54 else
55 object
56 end
57
58 {:ok, object}
59 end
60
61 defp check_media_removal(_actor_info, object), do: {:ok, object}
62
63 defp check_media_nsfw(
64 %{host: actor_host} = _actor_info,
65 %{
66 "type" => type,
67 "object" => %{} = _child_object
68 } = object
69 )
70 when type in ["Create", "Update"] do
71 media_nsfw =
72 instance_list(:media_nsfw)
73 |> MRF.subdomains_regex()
74
75 object =
76 if MRF.subdomain_match?(media_nsfw, actor_host) do
77 Kernel.put_in(object, ["object", "sensitive"], true)
78 else
79 object
80 end
81
82 {:ok, object}
83 end
84
85 defp check_media_nsfw(_actor_info, object), do: {:ok, object}
86
87 defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do
88 timeline_removal =
89 instance_list(:federated_timeline_removal)
90 |> MRF.subdomains_regex()
91
92 object =
93 with true <- MRF.subdomain_match?(timeline_removal, actor_host),
94 user <- User.get_cached_by_ap_id(object["actor"]),
95 true <- Pleroma.Constants.as_public() in object["to"] do
96 to = List.delete(object["to"], Pleroma.Constants.as_public()) ++ [user.follower_address]
97
98 cc = List.delete(object["cc"], user.follower_address) ++ [Pleroma.Constants.as_public()]
99
100 object
101 |> Map.put("to", to)
102 |> Map.put("cc", cc)
103 else
104 _ -> object
105 end
106
107 {:ok, object}
108 end
109
110 defp intersection(list1, list2) do
111 list1 -- list1 -- list2
112 end
113
114 defp check_followers_only(%{host: actor_host} = _actor_info, object) do
115 followers_only =
116 instance_list(:followers_only)
117 |> MRF.subdomains_regex()
118
119 object =
120 with true <- MRF.subdomain_match?(followers_only, actor_host),
121 user <- User.get_cached_by_ap_id(object["actor"]) do
122 # Don't use Map.get/3 intentionally, these must not be nil
123 fixed_to = object["to"] || []
124 fixed_cc = object["cc"] || []
125
126 to = FollowingRelationship.followers_ap_ids(user, fixed_to)
127 cc = FollowingRelationship.followers_ap_ids(user, fixed_cc)
128
129 object
130 |> Map.put("to", intersection([user.follower_address | to], fixed_to))
131 |> Map.put("cc", intersection([user.follower_address | cc], fixed_cc))
132 else
133 _ -> object
134 end
135
136 {:ok, object}
137 end
138
139 defp check_report_removal(%{host: actor_host} = _actor_info, %{"type" => "Flag"} = object) do
140 report_removal =
141 instance_list(:report_removal)
142 |> MRF.subdomains_regex()
143
144 if MRF.subdomain_match?(report_removal, actor_host) do
145 {:reject, "[SimplePolicy] host in report_removal list"}
146 else
147 {:ok, object}
148 end
149 end
150
151 defp check_report_removal(_actor_info, object), do: {:ok, object}
152
153 defp check_avatar_removal(%{host: actor_host} = _actor_info, %{"icon" => _icon} = object) do
154 avatar_removal =
155 instance_list(:avatar_removal)
156 |> MRF.subdomains_regex()
157
158 if MRF.subdomain_match?(avatar_removal, actor_host) do
159 {:ok, Map.delete(object, "icon")}
160 else
161 {:ok, object}
162 end
163 end
164
165 defp check_avatar_removal(_actor_info, object), do: {:ok, object}
166
167 defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image} = object) do
168 banner_removal =
169 instance_list(:banner_removal)
170 |> MRF.subdomains_regex()
171
172 if MRF.subdomain_match?(banner_removal, actor_host) do
173 {:ok, Map.delete(object, "image")}
174 else
175 {:ok, object}
176 end
177 end
178
179 defp check_banner_removal(_actor_info, object), do: {:ok, object}
180
181 defp extract_context_uri(%{"conversation" => "tag:" <> rest}) do
182 rest
183 |> String.split(",", parts: 2, trim: true)
184 |> hd()
185 |> case do
186 nil -> nil
187 hostname -> URI.parse("//" <> hostname)
188 end
189 end
190
191 defp extract_context_uri(%{"context" => "http" <> _ = context}), do: URI.parse(context)
192
193 defp extract_context_uri(_), do: nil
194
195 defp check_context(activity) do
196 uri = extract_context_uri(activity)
197
198 with {:uri, true} <- {:uri, Kernel.match?(%URI{}, uri)},
199 {:ok, _} <- check_accept(uri),
200 {:ok, _} <- check_reject(uri) do
201 {:ok, activity}
202 else
203 # Can't check.
204 {:uri, false} -> {:ok, activity}
205 {:reject, nil} -> {:reject, "[SimplePolicy]"}
206 {:reject, _} = e -> e
207 _ -> {:reject, "[SimplePolicy]"}
208 end
209 end
210
211 defp check_reply_to(%{"object" => %{"inReplyTo" => in_reply_to}} = activity) do
212 with {:ok, _} <- filter(in_reply_to) do
213 {:ok, activity}
214 end
215 end
216
217 defp check_reply_to(activity), do: {:ok, activity}
218
219 defp maybe_check_thread(activity) do
220 if Config.get([:mrf_simple, :handle_threads], true) do
221 with {:ok, _} <- check_context(activity),
222 {:ok, _} <- check_reply_to(activity) do
223 {:ok, activity}
224 end
225 else
226 {:ok, activity}
227 end
228 end
229
230 defp check_object(%{"object" => object} = activity) do
231 with {:ok, _object} <- filter(object) do
232 {:ok, activity}
233 end
234 end
235
236 defp check_object(object), do: {:ok, object}
237
238 defp instance_list(config_key) do
239 Config.get([:mrf_simple, config_key])
240 |> MRF.instance_list_from_tuples()
241 end
242
243 @impl true
244 def filter(%{"type" => "Delete", "actor" => actor} = object) do
245 %{host: actor_host} = URI.parse(actor)
246
247 reject_deletes =
248 instance_list(:reject_deletes)
249 |> MRF.subdomains_regex()
250
251 if MRF.subdomain_match?(reject_deletes, actor_host) do
252 {:reject, "[SimplePolicy] host in reject_deletes list"}
253 else
254 {:ok, object}
255 end
256 end
257
258 @impl true
259 def filter(%{"actor" => actor} = object) do
260 actor_info = URI.parse(actor)
261
262 with {:ok, _} <- check_accept(actor_info),
263 {:ok, _} <- check_reject(actor_info),
264 {:ok, object} <- check_media_removal(actor_info, object),
265 {:ok, object} <- check_media_nsfw(actor_info, object),
266 {:ok, object} <- check_ftl_removal(actor_info, object),
267 {:ok, object} <- check_followers_only(actor_info, object),
268 {:ok, object} <- check_report_removal(actor_info, object),
269 {:ok, object} <- maybe_check_thread(object),
270 {:ok, object} <- check_object(object) do
271 {:ok, object}
272 else
273 {:reject, nil} -> {:reject, "[SimplePolicy]"}
274 {:reject, _} = e -> e
275 _ -> {:reject, "[SimplePolicy]"}
276 end
277 end
278
279 def filter(%{"id" => actor, "type" => obj_type} = object)
280 when obj_type in ["Application", "Group", "Organization", "Person", "Service"] do
281 actor_info = URI.parse(actor)
282
283 with {:ok, _} <- check_accept(actor_info),
284 {:ok, _} <- check_reject(actor_info),
285 {:ok, object} <- check_avatar_removal(actor_info, object),
286 {:ok, object} <- check_banner_removal(actor_info, object) do
287 {:ok, object}
288 else
289 {:reject, nil} -> {:reject, "[SimplePolicy]"}
290 {:reject, _} = e -> e
291 _ -> {:reject, "[SimplePolicy]"}
292 end
293 end
294
295 def filter(%{"id" => id} = object) do
296 with {:ok, _} <- filter(id) do
297 {:ok, object}
298 end
299 end
300
301 def filter(object) when is_binary(object) do
302 uri = URI.parse(object)
303
304 with {:ok, _} <- check_accept(uri),
305 {:ok, _} <- check_reject(uri) do
306 {:ok, object}
307 else
308 {:reject, nil} -> {:reject, "[SimplePolicy]"}
309 {:reject, _} = e -> e
310 _ -> {:reject, "[SimplePolicy]"}
311 end
312 end
313
314 def filter(object), do: {:ok, object}
315
316 defp obfuscate(string) when is_binary(string) do
317 string
318 |> to_charlist()
319 |> Enum.with_index()
320 |> Enum.map(fn
321 {?., _index} ->
322 ?.
323
324 {char, index} ->
325 if 3 <= index && index < String.length(string) - 3, do: ?*, else: char
326 end)
327 |> to_string()
328 end
329
330 defp maybe_obfuscate(host, obfuscations) do
331 if MRF.subdomain_match?(obfuscations, host) do
332 obfuscate(host)
333 else
334 host
335 end
336 end
337
338 @impl true
339 def describe do
340 exclusions = Config.get([:mrf, :transparency_exclusions]) |> MRF.instance_list_from_tuples()
341
342 obfuscations =
343 Config.get([:mrf, :transparency_obfuscate_domains], []) |> MRF.subdomains_regex()
344
345 mrf_simple_excluded =
346 Config.get(:mrf_simple)
347 |> Enum.filter(fn {_, v} -> is_list(v) end)
348 |> Enum.map(fn {rule, instances} ->
349 {rule, Enum.reject(instances, fn {host, _} -> host in exclusions end)}
350 end)
351
352 mrf_simple =
353 mrf_simple_excluded
354 |> Enum.map(fn {rule, instances} ->
355 {rule, Enum.map(instances, fn {host, _} -> maybe_obfuscate(host, obfuscations) end)}
356 end)
357 |> Map.new()
358
359 # This is for backwards compatibility. We originally didn't sent
360 # extra info like a reason why an instance was rejected/quarantined/etc.
361 # Because we didn't want to break backwards compatibility it was decided
362 # to add an extra "info" key.
363 mrf_simple_info =
364 mrf_simple_excluded
365 |> Enum.map(fn {rule, instances} ->
366 {rule, Enum.reject(instances, fn {_, reason} -> reason == "" end)}
367 end)
368 |> Enum.reject(fn {_, instances} -> instances == [] end)
369 |> Enum.map(fn {rule, instances} ->
370 instances =
371 instances
372 |> Enum.map(fn {host, reason} ->
373 {maybe_obfuscate(host, obfuscations), %{"reason" => reason}}
374 end)
375 |> Map.new()
376
377 {rule, instances}
378 end)
379 |> Map.new()
380
381 {:ok, %{mrf_simple: mrf_simple, mrf_simple_info: mrf_simple_info}}
382 end
383
384 @impl true
385 def config_description do
386 %{
387 key: :mrf_simple,
388 related_policy: "Pleroma.Web.ActivityPub.MRF.SimplePolicy",
389 label: "MRF Simple",
390 description: "Simple ingress policies",
391 children:
392 ([
393 %{
394 key: :media_removal,
395 description:
396 "List of instances to strip media attachments from and the reason for doing so"
397 },
398 %{
399 key: :media_nsfw,
400 label: "Media NSFW",
401 description:
402 "List of instances to tag all media as NSFW (sensitive) from and the reason for doing so"
403 },
404 %{
405 key: :federated_timeline_removal,
406 description:
407 "List of instances to remove from the Federated (aka The Whole Known Network) Timeline and the reason for doing so"
408 },
409 %{
410 key: :reject,
411 description:
412 "List of instances to reject activities from (except deletes) and the reason for doing so"
413 },
414 %{
415 key: :accept,
416 description:
417 "List of instances to only accept activities from (except deletes) and the reason for doing so"
418 },
419 %{
420 key: :followers_only,
421 description:
422 "Force posts from the given instances to be visible by followers only and the reason for doing so"
423 },
424 %{
425 key: :report_removal,
426 description: "List of instances to reject reports from and the reason for doing so"
427 },
428 %{
429 key: :avatar_removal,
430 description: "List of instances to strip avatars from and the reason for doing so"
431 },
432 %{
433 key: :banner_removal,
434 description: "List of instances to strip banners from and the reason for doing so"
435 },
436 %{
437 key: :reject_deletes,
438 description: "List of instances to reject deletions from and the reason for doing so"
439 }
440 ]
441 |> Enum.map(fn setting ->
442 Map.merge(
443 setting,
444 %{
445 type: {:list, :tuple},
446 key_placeholder: "instance",
447 value_placeholder: "reason",
448 suggestions: [
449 {"example.com", "Some reason"},
450 {"*.example.com", "Another reason"}
451 ]
452 }
453 )
454 end)) ++
455 [
456 %{
457 key: :handle_threads,
458 label: "Apply to entire threads",
459 type: :boolean,
460 description:
461 "Enable to filter replies to threads based from their originating instance, using the reject and accept rules"
462 }
463 ]
464 }
465 end
466 end