StealEmoji: check remote size before downloading

To save on bandwith and avoid OOMs with large files.
Ofc, this relies on the remote server
 (a) sending a content-length header and
 (b) being honest about the size.

Common fedi servers seem to provide the header and (b) at least raises
the required privilege of an malicious actor to a server infrastructure
admin of an explicitly allowed host.

A more complete defense which still works when faced with
a malicious server requires changes in upstream Finch;
see https://github.com/sneako/finch/issues/224
This commit is contained in:
Oneric 2024-03-10 01:35:35 +01:00
parent 6d003e1acd
commit d6d838cbe8
3 changed files with 75 additions and 4 deletions

View file

@ -236,7 +236,9 @@ config :pleroma, :mrf_user_allowlist, %{
#### :mrf_steal_emoji #### :mrf_steal_emoji
* `hosts`: List of hosts to steal emojis from * `hosts`: List of hosts to steal emojis from
* `rejected_shortcodes`: Regex-list of shortcodes to reject * `rejected_shortcodes`: Regex-list of shortcodes to reject
* `size_limit`: File size limit (in bytes), checked before an emoji is saved to the disk * `size_limit`: File size limit (in bytes), checked before download if possible (and remote server honest),
otherwise or again checked before saving emoji to the disk
* `download_unknown_size`: whether to download an emoji when the remote server doesnt report its size in advance
#### :mrf_activity_expiration #### :mrf_activity_expiration

View file

@ -13,6 +13,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do
@pack_name "stolen" @pack_name "stolen"
# Config defaults
@size_limit 50_000
@download_unknown_size false
defp create_pack() do defp create_pack() do
with {:ok, pack} = Pack.create(@pack_name) do with {:ok, pack} = Pack.create(@pack_name) do
Pack.save_metadata( Pack.save_metadata(
@ -97,11 +101,28 @@ defp get_extension_if_safe(response) do
end end
end end
defp is_remote_size_within_limit?(url) do
with {:ok, %{status: status, headers: headers} = _response} when status in 200..299 <-
Pleroma.HTTP.request(:head, url, nil, [], []) do
content_length = :proplists.get_value("content-length", headers, nil)
size_limit = Config.get([:mrf_steal_emoji, :size_limit], @size_limit)
accept_unknown =
Config.get([:mrf_steal_emoji, :download_unknown_size], @download_unknown_size)
content_length <= size_limit or
(content_length == nil and accept_unknown)
else
_ -> false
end
end
defp maybe_steal_emoji({shortcode, url}) do defp maybe_steal_emoji({shortcode, url}) do
url = Pleroma.Web.MediaProxy.url(url) url = Pleroma.Web.MediaProxy.url(url)
with {:ok, %{status: status} = response} when status in 200..299 <- Pleroma.HTTP.get(url) do with {:remote_size, true} <- {:remote_size, is_remote_size_within_limit?(url)},
size_limit = Config.get([:mrf_steal_emoji, :size_limit], 50_000) {:ok, %{status: status} = response} when status in 200..299 <- Pleroma.HTTP.get(url) do
size_limit = Config.get([:mrf_steal_emoji, :size_limit], @size_limit)
extension = get_extension_if_safe(response) extension = get_extension_if_safe(response)
if byte_size(response.body) <= size_limit and extension do if byte_size(response.body) <= size_limit and extension do

View file

@ -32,6 +32,14 @@ defmacro mock_tesla(
) do ) do
quote do quote do
Tesla.Mock.mock(fn Tesla.Mock.mock(fn
%{method: :head, url: unquote(url)} ->
%Tesla.Env{
status: unquote(status),
body: nil,
url: unquote(url),
headers: unquote(headers)
}
%{method: :get, url: unquote(url)} -> %{method: :get, url: unquote(url)} ->
%Tesla.Env{ %Tesla.Env{
status: unquote(status), status: unquote(status),
@ -46,7 +54,8 @@ defmacro mock_tesla(
setup do setup do
clear_config(:mrf_steal_emoji, clear_config(:mrf_steal_emoji,
hosts: ["example.org"], hosts: ["example.org"],
size_limit: 284_468 size_limit: 284_468,
download_unknown_size: true
) )
emoji_path = [:instance, :static_dir] |> Config.get() |> Path.join("emoji/stolen") emoji_path = [:instance, :static_dir] |> Config.get() |> Path.join("emoji/stolen")
@ -174,5 +183,44 @@ test "reject if host returns error", %{message: message} do
refute "firedfox" in installed() refute "firedfox" in installed()
end end
test "reject unknown size", %{message: message} do
clear_config([:mrf_steal_emoji, :download_unknown_size], false)
mock_tesla()
refute "firedfox" in installed()
ExUnit.CaptureLog.capture_log(fn ->
assert {:ok, _message} = StealEmojiPolicy.filter(message)
end) =~
"MRF.StealEmojiPolicy: Failed to fetch https://example.org/emoji/firedfox.png: {:remote_size, false}"
refute "firedfox" in installed()
end
test "reject too large content-size before download", %{message: message} do
clear_config([:mrf_steal_emoji, :download_unknown_size], false)
mock_tesla("https://example.org/emoji/firedfox.png", 200, [{"content-length", 2 ** 30}])
refute "firedfox" in installed()
ExUnit.CaptureLog.capture_log(fn ->
assert {:ok, _message} = StealEmojiPolicy.filter(message)
end) =~
"MRF.StealEmojiPolicy: Failed to fetch https://example.org/emoji/firedfox.png: {:remote_size, false}"
refute "firedfox" in installed()
end
test "accepts content-size below limit", %{message: message} do
clear_config([:mrf_steal_emoji, :download_unknown_size], false)
mock_tesla("https://example.org/emoji/firedfox.png", 200, [{"content-length", 2}])
refute "firedfox" in installed()
assert {:ok, _message} = StealEmojiPolicy.filter(message)
assert "firedfox" in installed()
end
defp installed, do: Emoji.get_all() |> Enum.map(fn {k, _} -> k end) defp installed, do: Emoji.get_all() |> Enum.map(fn {k, _} -> k end)
end end