fix(media-proxy): safely detect extensionless images

This commit is contained in:
Lain Soykaf 2026-07-25 00:06:03 +04:00
commit d5f48101b8
No known key found for this signature in database
11 changed files with 211 additions and 609 deletions

View file

@ -0,0 +1 @@
Detect image content types for extensionless MediaProxy responses.

View file

@ -17,6 +17,7 @@ defmodule Pleroma.ReverseProxy do
@max_read_duration :timer.seconds(30) @max_read_duration :timer.seconds(30)
@max_body_length :infinity @max_body_length :infinity
@failed_request_ttl :timer.seconds(60) @failed_request_ttl :timer.seconds(60)
@sniff_bytes 8 * 1024
@methods ~w(GET HEAD) @methods ~w(GET HEAD)
@allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], []) @allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], [])
@ -63,6 +64,9 @@ defmodule Pleroma.ReverseProxy do
is always set (overriding any upstream header) so the media can be embedded is always set (overriding any upstream header) so the media can be embedded
in pages; the filename is derived from the content type in pages; the filename is derived from the content type
* `sniff_content_type` (default `false`): detects an image MIME type from the first
response chunk when the upstream type is missing or `application/octet-stream`.
* `req_headers`, `resp_headers` additional headers. * `req_headers`, `resp_headers` additional headers.
* `http`: options for [hackney](https://github.com/benoitc/hackney) or [gun](https://github.com/ninenines/gun). * `http`: options for [hackney](https://github.com/benoitc/hackney) or [gun](https://github.com/ninenines/gun).
@ -71,11 +75,15 @@ defmodule Pleroma.ReverseProxy do
@default_options [pool: :media] @default_options [pool: :media]
@inline_content_types [ @inline_content_types [
"image/apng",
"image/avif",
"image/bmp",
"image/gif", "image/gif",
"image/jpeg", "image/jpeg",
"image/jpg", "image/jpg",
"image/png", "image/png",
"image/svg+xml", "image/svg+xml",
"image/webp",
"audio/mpeg", "audio/mpeg",
"audio/mp3", "audio/mp3",
"video/webm", "video/webm",
@ -94,6 +102,7 @@ defmodule Pleroma.ReverseProxy do
| {:req_headers, [{String.t(), String.t()}]} | {:req_headers, [{String.t(), String.t()}]}
| {:resp_headers, [{String.t(), String.t()}]} | {:resp_headers, [{String.t(), String.t()}]}
| {:inline_content_types, boolean() | list(String.t())} | {:inline_content_types, boolean() | list(String.t())}
| {:sniff_content_type, boolean()}
| {:redirect_on_failure, boolean()} | {:redirect_on_failure, boolean()}
@spec call(Plug.Conn.t(), String.t(), list(option())) :: Plug.Conn.t() @spec call(Plug.Conn.t(), String.t(), list(option())) :: Plug.Conn.t()
@ -186,12 +195,14 @@ defmodule Pleroma.ReverseProxy do
defp response(conn, client, url, status, headers, opts) do defp response(conn, client, url, status, headers, opts) do
Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}") Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}")
{headers, client, prefetched} = maybe_prefetch_for_content_type(headers, client, opts)
result = result =
conn conn
|> put_resp_headers(build_resp_headers(headers, opts)) |> put_resp_headers(build_resp_headers(headers, opts))
|> streaming_compat |> streaming_compat
|> send_chunked(status) |> send_chunked(status)
|> chunk_reply(client, opts) |> chunk_reply(client, opts, prefetched)
case result do case result do
{:ok, conn} -> {:ok, conn} ->
@ -211,18 +222,28 @@ defmodule Pleroma.ReverseProxy do
end end
end end
defp chunk_reply(conn, client, opts) do defp chunk_reply(conn, client, opts, :none) do
chunk_reply(conn, client, opts, 0, 0) chunk_reply(conn, client, opts, 0, 0)
end end
defp chunk_reply(conn, client, opts, sent_so_far, duration) do defp chunk_reply(conn, _client, _opts, :done), do: {:ok, conn}
with {:ok, duration} <- defp chunk_reply(conn, _client, _opts, {:error, error}), do: {:error, error, conn}
check_read_duration(
duration, defp chunk_reply(conn, client, opts, {:ok, data, duration}) do
Keyword.get(opts, :max_read_duration, @max_read_duration) with :ok <-
body_size_constraint(
byte_size(data),
Keyword.get(opts, :max_body_length, @max_body_length)
), ),
{:ok, data, client} <- client().stream_body(client), {:ok, conn} <- chunk(conn, data) do
{:ok, duration} <- increase_read_duration(duration), chunk_reply(conn, client, opts, byte_size(data), duration)
else
{:error, error} -> {:error, error, conn}
end
end
defp chunk_reply(conn, client, opts, sent_so_far, duration) do
with {:ok, data, client, duration} <- read_chunk(client, duration, opts),
sent_so_far = sent_so_far + byte_size(data), sent_so_far = sent_so_far + byte_size(data),
:ok <- :ok <-
body_size_constraint( body_size_constraint(
@ -237,6 +258,66 @@ defmodule Pleroma.ReverseProxy do
end end
end end
defp read_chunk(client, duration, opts) do
with {:ok, timer} <-
check_read_duration(
duration,
Keyword.get(opts, :max_read_duration, @max_read_duration)
),
{:ok, data, client} <- client().stream_body(client),
{:ok, duration} <- increase_read_duration(timer) do
{:ok, data, client, duration}
end
end
defp maybe_prefetch_for_content_type(headers, client, opts) do
if Keyword.get(opts, :sniff_content_type, false) and generic_content_type?(headers) do
case read_chunk(client, 0, opts) do
{:ok, data, client, duration} ->
{maybe_put_image_content_type(headers, data), client, {:ok, data, duration}}
:done ->
{headers, client, :done}
{:error, error} ->
{headers, client, {:error, error}}
end
else
{headers, client, :none}
end
end
defp generic_content_type?(headers) do
headers
|> get_content_type()
|> String.trim()
|> String.downcase()
|> then(&(&1 in ["", "application/octet-stream"]))
end
defp maybe_put_image_content_type(headers, data) do
with false <- data == "",
{:ok, %{mime_type: "image/" <> _ = content_type}} <-
Majic.perform({:bytes, binary_part(data, 0, min(byte_size(data), @sniff_bytes))},
pool: Pleroma.MajicPool
) do
[
{"content-type", content_type}
| Enum.reject(headers, fn {key, _} -> key == "content-type" end)
]
else
_ -> headers
end
rescue
error ->
Logger.debug("#{__MODULE__}: content-type sniffing failed: #{Exception.message(error)}")
headers
catch
kind, reason ->
Logger.debug("#{__MODULE__}: content-type sniffing failed: #{kind}: #{inspect(reason)}")
headers
end
defp head_response(conn, url, code, headers, opts) do defp head_response(conn, url, code, headers, opts) do
Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}") Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}")
@ -283,17 +364,34 @@ defmodule Pleroma.ReverseProxy do
|> Enum.filter(fn {k, _} -> k in @keep_req_headers end) |> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
|> build_req_range_or_encoding_header(opts) |> build_req_range_or_encoding_header(opts)
|> build_req_user_agent_header(opts) |> build_req_user_agent_header(opts)
|> Keyword.merge(Keyword.get(opts, :req_headers, [])) |> merge_headers(Keyword.get(opts, :req_headers, []))
|> maybe_force_identity_encoding(opts)
end
defp merge_headers(headers, extra_headers) do
Enum.reduce(extra_headers, headers, fn {key, value}, headers ->
List.keystore(headers, String.downcase(key), 0, {String.downcase(key), value})
end)
end
defp maybe_force_identity_encoding(headers, opts) do
if Keyword.get(opts, :sniff_content_type, false) do
List.keystore(headers, "accept-encoding", 0, {"accept-encoding", "identity"})
else
headers
end
end end
# Disable content-encoding if any @range_headers are requested (see #1823). # Disable content-encoding if any @range_headers are requested (see #1823).
defp build_req_range_or_encoding_header(headers, _opts) do defp build_req_range_or_encoding_header(headers, _opts) do
range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end) range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
if range? && List.keymember?(headers, "accept-encoding", 0) do cond do
List.keydelete(headers, "accept-encoding", 0) range? && List.keymember?(headers, "accept-encoding", 0) ->
else List.keydelete(headers, "accept-encoding", 0)
headers
true ->
headers
end end
end end
@ -386,7 +484,7 @@ defmodule Pleroma.ReverseProxy do
disposition = "attachment; filename=\"#{name}\"" disposition = "attachment; filename=\"#{name}\""
List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) replace_header(headers, "content-disposition", disposition)
else else
if opt == true do if opt == true do
headers headers
@ -400,11 +498,15 @@ defmodule Pleroma.ReverseProxy do
"inline" "inline"
end end
List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) replace_header(headers, "content-disposition", disposition)
end end
end end
end end
defp replace_header(headers, key, value) do
[{key, value} | Enum.reject(headers, fn {header, _} -> header == key end)]
end
defp inline_filename(content_type) do defp inline_filename(content_type) do
case MIME.extensions(content_type) do case MIME.extensions(content_type) do
[ext | _] when ext != "" -> "inline.#{ext}" [ext | _] when ext != "" -> "inline.#{ext}"
@ -443,7 +545,9 @@ defmodule Pleroma.ReverseProxy do
end end
end end
defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit} defp check_read_duration(_, _), do: {:ok, :no_duration_limit}
defp increase_read_duration(:no_duration_limit), do: {:ok, :no_duration_limit}
defp increase_read_duration({previous_duration, started}) defp increase_read_duration({previous_duration, started})
when is_integer(previous_duration) and is_integer(started) do when is_integer(previous_duration) and is_integer(started) do

View file

@ -1,59 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer do
@moduledoc """
Best-effort MIME sniffing for remote attachments whose declared type is
unusable (missing or `application/octet-stream`).
Fetches the first chunk of the URL and runs it through libmagic. Only
`image/*` results are returned; anything else, or any failure, yields `nil` —
so this never reclassifies non-image media and never blocks ingestion. This
exists because some remotes (e.g. extensionless Cloudflare Images URLs) ship
attachments with no usable `mediaType`, which would otherwise render in the
timeline as a clickable link instead of an inline image.
"""
alias Pleroma.HTTP
require Logger
# libmagic only needs the file header; a few KB is plenty and keeps the
# request cheap even when the origin ignores Range.
@sniff_bytes 8 * 1024
@timeout 5_000
@spec sniff_image_type(binary() | nil) :: {:ok, binary() | nil}
def sniff_image_type(url) when is_binary(url) and url != "" do
do_sniff(url)
rescue
e ->
Logger.debug(
"Attachment type sniff failed for #{url}: #{Exception.format(:error, e, __STACKTRACE__)}"
)
{:ok, nil}
end
def sniff_image_type(_), do: {:ok, nil}
defp do_sniff(url) do
headers = [{"range", "bytes=0-#{@sniff_bytes - 1}"}]
opts = [pool: :media, timeout: @timeout, recv_timeout: @timeout]
with {:ok, %Tesla.Env{status: status, body: body}} when status in 200..299 <-
HTTP.get(url, headers, opts),
false <- blank?(body),
{:ok, %{mime_type: mime}} <- Majic.perform({:bytes, body}, pool: Pleroma.MajicPool),
true <- String.starts_with?(mime, "image/") do
{:ok, mime}
else
_ -> {:ok, nil}
end
end
defp blank?(nil), do: true
defp blank?(""), do: true
defp blank?(_), do: false
end

View file

@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
use Ecto.Schema use Ecto.Schema
alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.EctoType.ActivityPub.ObjectValidators
alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer
import Ecto.Changeset import Ecto.Changeset
@ -62,27 +61,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
end end
def fix_media_type(data) do def fix_media_type(data) do
current = data["mediaType"] || data["mimeType"] || "application/octet-stream" Map.put_new(data, "mediaType", data["mimeType"] || "application/octet-stream")
resolved =
if sniffable?(current) do
case AttachmentTypeSniffer.sniff_image_type(data["href"]) do
{:ok, mime} when is_binary(mime) -> mime
_ -> current
end
else
current
end
Map.put(data, "mediaType", resolved)
end end
# Only sniff when the declared type carries no real information. Everything
# else (including unrecognized-but-present types) is left as the remote sent it.
defp sniffable?("application/octet-stream"), do: true
defp sniffable?(""), do: true
defp sniffable?(_), do: false
defp handle_href(href, mediaType, data) do defp handle_href(href, mediaType, data) do
[ [
%{ %{

View file

@ -620,6 +620,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
String.contains?(media_type, "image") -> "image" String.contains?(media_type, "image") -> "image"
String.contains?(media_type, "video") -> "video" String.contains?(media_type, "video") -> "video"
String.contains?(media_type, "audio") -> "audio" String.contains?(media_type, "audio") -> "audio"
attachment["type"] == "Image" -> "image"
true -> "unknown" true -> "unknown"
end end

View file

@ -208,6 +208,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do
defp media_proxy_opts do defp media_proxy_opts do
Config.get([:media_proxy, :proxy_opts], []) Config.get([:media_proxy, :proxy_opts], [])
|> Keyword.put_new(:sniff_content_type, true)
end end
defp sandbox(conn, _params) do defp sandbox(conn, _params) do

View file

@ -222,6 +222,56 @@ defmodule Pleroma.ReverseProxyTest do
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"] assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end end
test "sniffs and preserves an image body with a generic content type", %{conn: conn} do
body = File.read!("test/fixtures/image.jpg")
ClientMock
|> expect(:request, fn :get, "/extensionless", headers, _, _ ->
assert {"accept-encoding", "identity"} in headers
{:ok, 200, [{"content-type", "application/octet-stream"}], %{body: body}}
end)
|> expect(:stream_body, fn %{body: ^body} = client ->
{:ok, body, Map.delete(client, :body)}
end)
|> expect(:stream_body, fn %{} -> :done end)
conn =
ReverseProxy.call(conn, "/extensionless",
sniff_content_type: true,
max_read_duration: :infinity,
req_headers: [{"accept-encoding", "gzip"}]
)
assert conn.resp_body == body
assert Conn.get_resp_header(conn, "content-type") == ["image/jpeg"]
assert Conn.get_resp_header(conn, "content-disposition") == [
"inline; filename=\"inline.jpg\""
]
end
test "preserves a generic content type for a non-image body", %{conn: conn} do
body = "not an image"
ClientMock
|> expect(:request, fn :get, "/extensionless", _, _, _ ->
{:ok, 200, [], %{body: body}}
end)
|> expect(:stream_body, fn %{body: ^body} = client ->
{:ok, body, Map.delete(client, :body)}
end)
|> expect(:stream_body, fn %{} -> :done end)
conn = ReverseProxy.call(conn, "/extensionless", sniff_content_type: true)
assert conn.resp_body == body
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
assert Conn.get_resp_header(conn, "content-disposition") == [
"attachment; filename=\"extensionless\""
]
end
defp headers_mock(_) do defp headers_mock(_) do
ClientMock ClientMock
|> expect(:request, fn :get, "/headers", headers, _, _ -> |> expect(:request, fn :get, "/headers", headers, _, _ ->
@ -298,21 +348,21 @@ defmodule Pleroma.ReverseProxyTest do
end end
end end
defp disposition_headers_mock(headers, url \\ "/disposition") do defp disposition_headers_mock(headers) do
ClientMock ClientMock
|> expect(:request, fn :get, ^url, _, _, _ -> |> expect(:request, fn :get, "/disposition", _, _, _ ->
Registry.register(ClientMock, url, 0) Registry.register(ClientMock, "/disposition", 0)
{:ok, 200, headers, %{url: url}} {:ok, 200, headers, %{url: "/disposition"}}
end) end)
|> expect(:stream_body, 2, fn %{url: ^url} = client -> |> expect(:stream_body, 2, fn %{url: "/disposition"} = client ->
case Registry.lookup(ClientMock, url) do case Registry.lookup(ClientMock, "/disposition") do
[{_, 0}] -> [{_, 0}] ->
Registry.update_value(ClientMock, url, &(&1 + 1)) Registry.update_value(ClientMock, "/disposition", &(&1 + 1))
{:ok, "", client} {:ok, "", client}
[{_, 1}] -> [{_, 1}] ->
Registry.unregister(ClientMock, url) Registry.unregister(ClientMock, "/disposition")
:done :done
end end
end) end)
@ -337,6 +387,7 @@ defmodule Pleroma.ReverseProxyTest do
disposition_headers_mock([ disposition_headers_mock([
{"content-type", "image/png"}, {"content-type", "image/png"},
{"content-disposition", "attachment; filename=\"filename.png\""}, {"content-disposition", "attachment; filename=\"filename.png\""},
{"content-disposition", "attachment; filename=\"duplicate.png\""},
{"content-length", "0"} {"content-length", "0"}
]) ])
@ -347,23 +398,6 @@ defmodule Pleroma.ReverseProxyTest do
refute String.starts_with?(disposition, "attachment") refute String.starts_with?(disposition, "attachment")
end end
test "forces inline based on content type even without a file extension in the url", %{
conn: conn
} do
disposition_headers_mock(
[
{"content-type", "image/jpeg"},
{"content-length", "0"}
],
"/original"
)
conn = ReverseProxy.call(conn, "/original")
[disposition] = Conn.get_resp_header(conn, "content-disposition")
assert disposition == "inline; filename=\"inline.jpg\""
end
test "with content-disposition header", %{conn: conn} do test "with content-disposition header", %{conn: conn} do
disposition_headers_mock([ disposition_headers_mock([
{"content-disposition", "attachment; filename=\"filename.jpg\""}, {"content-disposition", "attachment; filename=\"filename.jpg\""},
@ -390,69 +424,6 @@ defmodule Pleroma.ReverseProxyTest do
assert {"content-disposition", "attachment; filename=\"upstream.png\""} in conn.resp_headers assert {"content-disposition", "attachment; filename=\"upstream.png\""} in conn.resp_headers
end end
test "with inline_content_types: true does not synthesise inline when upstream is absent", %{
conn: conn
} do
disposition_headers_mock([
{"content-type", "image/png"},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition", inline_content_types: true)
assert Conn.get_resp_header(conn, "content-disposition") == []
end
test "with inline_content_types: false forces attachment for everything", %{
conn: conn
} do
disposition_headers_mock([
{"content-type", "image/png"},
{"content-disposition", "inline; filename=\"pic.png\""},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition", inline_content_types: false)
[disposition] = Conn.get_resp_header(conn, "content-disposition")
assert String.starts_with?(disposition, "attachment")
end
test "with inline_content_types: false derives attachment filename from the URL basename when no upstream filename", %{
conn: conn
} do
# No content-disposition header: the attachment branch falls back to
# attachment_name, which by default comes from MediaProxy.filename/1
# (the URL basename).
disposition_headers_mock([
{"content-type", "image/png"},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition", inline_content_types: false)
[disposition] = Conn.get_resp_header(conn, "content-disposition")
assert disposition == "attachment; filename=\"disposition\""
end
test "with inline_content_types: false honours an explicit attachment_name opt", %{
conn: conn
} do
disposition_headers_mock([
{"content-type", "image/png"},
{"content-length", "0"}
])
conn =
ReverseProxy.call(conn, "/disposition",
inline_content_types: false,
attachment_name: "custom.bin"
)
[disposition] = Conn.get_resp_header(conn, "content-disposition")
assert disposition == "attachment; filename=\"custom.bin\""
end
test "forces bare inline for a whitelisted type with no MIME extension", %{ test "forces bare inline for a whitelisted type with no MIME extension", %{
conn: conn conn: conn
} do } do
@ -469,36 +440,17 @@ defmodule Pleroma.ReverseProxyTest do
assert disposition == "inline" assert disposition == "inline"
end end
test "honours a custom inline_content_types whitelist", %{conn: conn} do test "serves modern browser image types inline", %{conn: conn} do
# image/bmp is NOT in the default whitelist; with a custom whitelist
# that includes it, the proxy should force inline.
disposition_headers_mock([ disposition_headers_mock([
{"content-type", "image/bmp"}, {"content-type", "image/webp"},
{"content-disposition", "attachment; filename=\"upstream.bmp\""},
{"content-length", "0"} {"content-length", "0"}
]) ])
conn = conn = ReverseProxy.call(conn, "/disposition")
ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"])
[disposition] = Conn.get_resp_header(conn, "content-disposition") assert Conn.get_resp_header(conn, "content-disposition") == [
assert String.starts_with?(disposition, "inline") "inline; filename=\"inline.webp\""
assert String.ends_with?(disposition, "inline.bmp\"") ]
end
test "treats a type outside the whitelist as attachment even on custom whitelist", %{
conn: conn
} do
disposition_headers_mock([
{"content-type", "image/png"},
{"content-disposition", "attachment; filename=\"filename.png\""},
{"content-length", "0"}
])
conn =
ReverseProxy.call(conn, "/disposition", inline_content_types: ["image/bmp"])
assert {"content-disposition", "attachment; filename=\"filename.png\""} in conn.resp_headers
end end
end end

View file

@ -1,178 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSnifferTest do
use Pleroma.DataCase, async: true
alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer
# @sniff_bytes must mirror the value in the module under test; we assert the
# Range header that the sniffer sends, so the test should fail loudly if the
# constant drifts.
@sniff_bytes 8 * 1024
@range_header "bytes=0-#{@sniff_bytes - 1}"
describe "sniff_image_type/1 with a real image body" do
test "returns image/jpeg for a JPEG body" do
url = "https://example.com/media/image.jpg"
jpeg = File.read!("test/fixtures/image.jpg")
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: jpeg}
end)
assert {:ok, "image/jpeg"} = AttachmentTypeSniffer.sniff_image_type(url)
end
test "returns image/png for a PNG body" do
url = "https://example.com/media/image.png"
png = File.read!("test/fixtures/image.png")
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: png}
end)
assert {:ok, "image/png"} = AttachmentTypeSniffer.sniff_image_type(url)
end
test "returns image/gif for a GIF body" do
url = "https://example.com/media/image.gif"
gif = File.read!("test/fixtures/image.gif")
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: gif}
end)
assert {:ok, "image/gif"} = AttachmentTypeSniffer.sniff_image_type(url)
end
end
describe "sniff_image_type/1 sends a ranged request" do
test "sends a Range header asking for the first chunk" do
url = "https://example.com/media/ranged"
jpeg = File.read!("test/fixtures/image.jpg")
Tesla.Mock.mock(fn
%{method: :get, url: ^url, headers: headers} ->
headers_map = Enum.into(headers, %{})
assert headers_map["range"] == @range_header
%Tesla.Env{status: 200, body: jpeg}
%{method: :get, url: ^url} ->
# If the sniffer did not send the Range header, fail explicitly.
flunk("expected Range header to be sent with the request")
end)
assert {:ok, "image/jpeg"} = AttachmentTypeSniffer.sniff_image_type(url)
end
end
describe "sniff_image_type/1 with a non-image body" do
test "returns nil for plain text" do
url = "https://example.com/media/note.txt"
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: "just some plain text, not an image at all"}
end)
assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
end
test "returns nil for an HTML body" do
url = "https://example.com/page"
html = File.read!("test/fixtures/rel_me_anchor.html")
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: html}
end)
assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
end
test "returns nil for an empty body" do
url = "https://example.com/media/empty"
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: ""}
end)
assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
end
test "returns nil for a nil body" do
url = "https://example.com/media/nil-body"
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: nil}
end)
assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
end
end
describe "sniff_image_type/1 on HTTP failure" do
test "returns nil on a 404" do
url = "https://example.com/media/missing"
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 404, body: ""}
end)
assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
end
test "returns nil on a 500" do
url = "https://example.com/media/oops"
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 500, body: "server error"}
end)
assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
end
test "returns nil on a 3xx (not in 200..299)" do
url = "https://example.com/media/redirect"
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 302, body: ""}
end)
assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
end
test "returns nil on a transport-level error" do
url = "https://example.com/media/econnrefused"
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
{:error, :econnrefused}
end)
assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
end
test "returns nil when the HTTP adapter raises" do
url = "https://example.com/media/raises"
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
raise "boom"
end)
assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(url)
end
end
describe "sniff_image_type/1 guard clauses" do
test "returns nil for nil input without making a request" do
# No Tesla mock is configured; if a request were made, Tesla.Mock would
# raise and fail the test.
assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type(nil)
end
test "returns nil for an empty-string URL without making a request" do
assert {:ok, nil} = AttachmentTypeSniffer.sniff_image_type("")
end
end
end

View file

@ -215,226 +215,5 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidatorTest do
AttachmentValidator.cast_and_validate(attachment) AttachmentValidator.cast_and_validate(attachment)
|> Ecto.Changeset.apply_action(:insert) |> Ecto.Changeset.apply_action(:insert)
end end
test "sniffs image/jpeg for octet-stream attachments whose body is an image" do
url = "https://example.com/media/no-extension/original"
jpeg = File.read!("test/fixtures/image.jpg")
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: jpeg}
end)
attachment = %{
"type" => "Document",
"mediaType" => "application/octet-stream",
"url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
}
{:ok, attachment} =
attachment
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert [%{href: ^url, mediaType: "image/jpeg"}] = attachment.url
end
test "sniffs image/jpeg for attachments with a missing mediaType" do
url = "https://example.com/media/missing-type/original"
jpeg = File.read!("test/fixtures/image.jpg")
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: jpeg}
end)
attachment = %{
"type" => "Document",
"url" => [%{"type" => "Link", "href" => url}]
}
{:ok, attachment} =
attachment
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert [%{href: ^url, mediaType: "image/jpeg"}] = attachment.url
end
test "leaves non-image octet-stream attachments as octet-stream" do
url = "https://example.com/media/some-document"
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: "just some plain text, not an image at all"}
end)
attachment = %{
"type" => "Document",
"mediaType" => "application/octet-stream",
"url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
}
{:ok, attachment} =
attachment
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert [%{mediaType: "application/octet-stream"}] = attachment.url
end
test "does not sniff when the remote already provided a real mediaType" do
# No Tesla mock is set up: if the validator tried to fetch, Tesla.Mock
# would raise and fail the test. A real image type must be kept as-is.
attachment = %{
"type" => "Document",
"mediaType" => "image/png",
"url" => [
%{"type" => "Link", "href" => "https://example.com/x", "mediaType" => "image/png"}
]
}
{:ok, attachment} =
attachment
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert [%{mediaType: "image/png"}] = attachment.url
end
end
describe "fix_media_type fallbacks and sniffing" do
test "uses mimeType as a fallback when mediaType is absent (real type, not sniffed)" do
# No Tesla mock: if the validator tried to fetch, the test would fail.
# A real image type coming from mimeType must be preserved as-is on the
# outer attachment and on the synthesised url entry (string url form).
attachment = %{
"type" => "Document",
"mimeType" => "image/png",
"url" => "https://example.com/x.png"
}
{:ok, attachment} =
attachment
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert attachment.mediaType == "image/png"
assert [%{mediaType: "image/png"}] = attachment.url
end
test "prefers mediaType over mimeType when both are present" do
attachment = %{
"type" => "Document",
"mediaType" => "image/gif",
"mimeType" => "image/png",
"url" => "https://example.com/x.gif"
}
{:ok, attachment} =
attachment
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert attachment.mediaType == "image/gif"
assert [%{mediaType: "image/gif"}] = attachment.url
end
test "sniffs when the url entry's mediaType is application/octet-stream and the body is an image" do
# The outer attachment mediaType is not sniffed (no top-level href); only
# the url entry is. We assert only on the url entry.
url = "https://example.com/media/mime-type-octet"
jpeg = File.read!("test/fixtures/image.jpg")
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: jpeg}
end)
attachment = %{
"type" => "Document",
"mediaType" => "application/octet-stream",
"url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
}
{:ok, attachment} =
attachment
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert [%{mediaType: "image/jpeg"}] = attachment.url
end
test "sniffs when mediaType is the empty string and the body is an image" do
url = "https://example.com/media/empty-mediatype"
png = File.read!("test/fixtures/image.png")
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 200, body: png}
end)
attachment = %{
"type" => "Document",
"mediaType" => "",
"url" => [%{"type" => "Link", "href" => url, "mediaType" => ""}]
}
{:ok, attachment} =
attachment
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert [%{mediaType: "image/png"}] = attachment.url
end
test "falls back to the declared type when the sniffer HTTP request fails" do
url = "https://example.com/media/sniff-404"
Tesla.Mock.mock(fn %{method: :get, url: ^url} ->
%Tesla.Env{status: 404, body: ""}
end)
attachment = %{
"type" => "Document",
"mediaType" => "application/octet-stream",
"url" => [%{"type" => "Link", "href" => url, "mediaType" => "application/octet-stream"}]
}
{:ok, attachment} =
attachment
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert [%{mediaType: "application/octet-stream"}] = attachment.url
end
test "sniffs each url entry independently" do
jpeg_url = "https://example.com/media/a"
octet_url = "https://example.com/media/b"
jpeg = File.read!("test/fixtures/image.jpg")
Tesla.Mock.mock(fn
%{method: :get, url: ^jpeg_url} ->
%Tesla.Env{status: 200, body: jpeg}
%{method: :get, url: ^octet_url} ->
%Tesla.Env{status: 200, body: "definitely not an image"}
end)
attachment = %{
"type" => "Document",
"mediaType" => "application/octet-stream",
"url" => [
%{"type" => "Link", "href" => jpeg_url, "mediaType" => "application/octet-stream"},
%{"type" => "Link", "href" => octet_url, "mediaType" => "application/octet-stream"}
]
}
{:ok, attachment} =
attachment
|> AttachmentValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert)
assert [
%{href: ^jpeg_url, mediaType: "image/jpeg"},
%{href: ^octet_url, mediaType: "application/octet-stream"}
] = attachment.url
end
end end
end end

View file

@ -668,6 +668,23 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
assert expected == StatusView.render("attachment.json", %{attachment: object}) assert expected == StatusView.render("attachment.json", %{attachment: object})
assert_schema(expected, "Attachment", api_spec) assert_schema(expected, "Attachment", api_spec)
end end
test "uses the ActivityStreams image type when the MIME type is generic" do
object = %{
"type" => "Image",
"url" => [
%{
"mediaType" => "application/octet-stream",
"href" => "https://example.com/extensionless"
}
]
}
attachment = StatusView.render("attachment.json", %{attachment: object})
assert attachment.type == "image"
assert attachment.pleroma.mime_type == "application/octet-stream"
end
end end
test "put the url advertised in the Activity in to the url attribute" do test "put the url advertised in the Activity in to the url attribute" do

View file

@ -71,7 +71,10 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do
test "it performs ReverseProxy.call with valid signature", %{conn: conn, url: url} do test "it performs ReverseProxy.call with valid signature", %{conn: conn, url: url} do
with_mock Pleroma.ReverseProxy, with_mock Pleroma.ReverseProxy,
call: fn _conn, _url, _opts -> %Conn{status: :success} end do call: fn _conn, _url, opts ->
assert opts[:sniff_content_type]
%Conn{status: :success}
end do
assert %Conn{status: :success} = get(conn, url) assert %Conn{status: :success} = get(conn, url)
end end
end end