fix(media-proxy): safely detect extensionless images
This commit is contained in:
parent
465a81ecf2
commit
d5f48101b8
11 changed files with 211 additions and 609 deletions
|
|
@ -17,6 +17,7 @@ defmodule Pleroma.ReverseProxy do
|
|||
@max_read_duration :timer.seconds(30)
|
||||
@max_body_length :infinity
|
||||
@failed_request_ttl :timer.seconds(60)
|
||||
@sniff_bytes 8 * 1024
|
||||
@methods ~w(GET HEAD)
|
||||
|
||||
@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
|
||||
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.
|
||||
|
||||
* `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]
|
||||
|
||||
@inline_content_types [
|
||||
"image/apng",
|
||||
"image/avif",
|
||||
"image/bmp",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/webp",
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"video/webm",
|
||||
|
|
@ -94,6 +102,7 @@ defmodule Pleroma.ReverseProxy do
|
|||
| {:req_headers, [{String.t(), String.t()}]}
|
||||
| {:resp_headers, [{String.t(), String.t()}]}
|
||||
| {:inline_content_types, boolean() | list(String.t())}
|
||||
| {:sniff_content_type, boolean()}
|
||||
| {:redirect_on_failure, boolean()}
|
||||
|
||||
@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
|
||||
Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}")
|
||||
|
||||
{headers, client, prefetched} = maybe_prefetch_for_content_type(headers, client, opts)
|
||||
|
||||
result =
|
||||
conn
|
||||
|> put_resp_headers(build_resp_headers(headers, opts))
|
||||
|> streaming_compat
|
||||
|> send_chunked(status)
|
||||
|> chunk_reply(client, opts)
|
||||
|> chunk_reply(client, opts, prefetched)
|
||||
|
||||
case result do
|
||||
{:ok, conn} ->
|
||||
|
|
@ -211,18 +222,28 @@ defmodule Pleroma.ReverseProxy do
|
|||
end
|
||||
end
|
||||
|
||||
defp chunk_reply(conn, client, opts) do
|
||||
defp chunk_reply(conn, client, opts, :none) do
|
||||
chunk_reply(conn, client, opts, 0, 0)
|
||||
end
|
||||
|
||||
defp chunk_reply(conn, client, opts, sent_so_far, duration) do
|
||||
with {:ok, duration} <-
|
||||
check_read_duration(
|
||||
duration,
|
||||
Keyword.get(opts, :max_read_duration, @max_read_duration)
|
||||
defp chunk_reply(conn, _client, _opts, :done), do: {:ok, conn}
|
||||
defp chunk_reply(conn, _client, _opts, {:error, error}), do: {:error, error, conn}
|
||||
|
||||
defp chunk_reply(conn, client, opts, {:ok, data, duration}) do
|
||||
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, duration} <- increase_read_duration(duration),
|
||||
{:ok, conn} <- chunk(conn, data) do
|
||||
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),
|
||||
:ok <-
|
||||
body_size_constraint(
|
||||
|
|
@ -237,6 +258,66 @@ defmodule Pleroma.ReverseProxy do
|
|||
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
|
||||
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)
|
||||
|> build_req_range_or_encoding_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
|
||||
|
||||
# Disable content-encoding if any @range_headers are requested (see #1823).
|
||||
defp build_req_range_or_encoding_header(headers, _opts) do
|
||||
range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
|
||||
|
||||
if range? && List.keymember?(headers, "accept-encoding", 0) do
|
||||
List.keydelete(headers, "accept-encoding", 0)
|
||||
else
|
||||
headers
|
||||
cond do
|
||||
range? && List.keymember?(headers, "accept-encoding", 0) ->
|
||||
List.keydelete(headers, "accept-encoding", 0)
|
||||
|
||||
true ->
|
||||
headers
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -386,7 +484,7 @@ defmodule Pleroma.ReverseProxy do
|
|||
|
||||
disposition = "attachment; filename=\"#{name}\""
|
||||
|
||||
List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
|
||||
replace_header(headers, "content-disposition", disposition)
|
||||
else
|
||||
if opt == true do
|
||||
headers
|
||||
|
|
@ -400,11 +498,15 @@ defmodule Pleroma.ReverseProxy do
|
|||
"inline"
|
||||
end
|
||||
|
||||
List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
|
||||
replace_header(headers, "content-disposition", disposition)
|
||||
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
|
||||
case MIME.extensions(content_type) do
|
||||
[ext | _] when ext != "" -> "inline.#{ext}"
|
||||
|
|
@ -443,7 +545,9 @@ defmodule Pleroma.ReverseProxy do
|
|||
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})
|
||||
when is_integer(previous_duration) and is_integer(started) do
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
|
|||
use Ecto.Schema
|
||||
|
||||
alias Pleroma.EctoType.ActivityPub.ObjectValidators
|
||||
alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentTypeSniffer
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
|
|
@ -62,27 +61,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
|
|||
end
|
||||
|
||||
def fix_media_type(data) do
|
||||
current = 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)
|
||||
Map.put_new(data, "mediaType", data["mimeType"] || "application/octet-stream")
|
||||
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
|
||||
[
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -620,6 +620,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
|
|||
String.contains?(media_type, "image") -> "image"
|
||||
String.contains?(media_type, "video") -> "video"
|
||||
String.contains?(media_type, "audio") -> "audio"
|
||||
attachment["type"] == "Image" -> "image"
|
||||
true -> "unknown"
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do
|
|||
|
||||
defp media_proxy_opts do
|
||||
Config.get([:media_proxy, :proxy_opts], [])
|
||||
|> Keyword.put_new(:sniff_content_type, true)
|
||||
end
|
||||
|
||||
defp sandbox(conn, _params) do
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue