Sanitise Content-Type of media proxy URLs

Just as with uploads and emoji before, this can otherwise be used
to place counterfeit AP objects or other malicious payloads.
In this case, even if we never assign a priviliged type to content,
the remote server can and until now we just mimcked whatever it told us.

Preview URLs already handle only specific, safe content types
and redirect to the external host for all else; thus no additional
sanitisiation is needed for them.

Non-previews are all delegated to the modified ReverseProxy module.
It already has consolidated logic for building response headers
making it easy to slip in sanitisation.

Although proxy urls are prefixed by a MAC built from a server secret,
attackers can still achieve a perfect id match when they are able to
change the contents of the pointed to URL. After sending an posts
containing an attachment at a controlled destination, the proxy URL can
be read back and inserted into the payload. After injection of
counterfeits in the target server the content can again be changed
to something innocuous lessening chance of detection.
This commit is contained in:
Oneric 2024-03-10 18:57:19 +00:00
parent bcc528b2e2
commit 11ae8344eb
2 changed files with 57 additions and 2 deletions

View file

@ -17,6 +17,8 @@ defmodule Pleroma.ReverseProxy do
@failed_request_ttl :timer.seconds(60) @failed_request_ttl :timer.seconds(60)
@methods ~w(GET HEAD) @methods ~w(GET HEAD)
@allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], [])
@cachex Pleroma.Config.get([:cachex, :provider], Cachex) @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
def max_read_duration_default, do: @max_read_duration def max_read_duration_default, do: @max_read_duration
@ -253,6 +255,7 @@ defp build_resp_headers(headers, opts) do
headers headers
|> Enum.filter(fn {k, _} -> k in @keep_resp_headers end) |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
|> build_resp_cache_headers(opts) |> build_resp_cache_headers(opts)
|> sanitise_content_type()
|> build_resp_content_disposition_header(opts) |> build_resp_content_disposition_header(opts)
|> build_csp_headers() |> build_csp_headers()
|> Keyword.merge(Keyword.get(opts, :resp_headers, [])) |> Keyword.merge(Keyword.get(opts, :resp_headers, []))
@ -282,6 +285,21 @@ defp build_resp_cache_headers(headers, _opts) do
end end
end end
defp sanitise_content_type(headers) do
original_ct = get_content_type(headers)
safe_ct =
Pleroma.Web.Plugs.Utils.get_safe_mime_type(
%{allowed_mime_types: @allowed_mime_types},
original_ct
)
[
{"content-type", safe_ct}
| Enum.filter(headers, fn {k, _v} -> k != "content-type" end)
]
end
defp build_resp_content_disposition_header(headers, opts) do defp build_resp_content_disposition_header(headers, opts) do
opt = Keyword.get(opts, :inline_content_types, @inline_content_types) opt = Keyword.get(opts, :inline_content_types, @inline_content_types)

View file

@ -75,13 +75,16 @@ test "common", %{conn: conn} do
Tesla.Mock.mock(fn %{method: :head, url: "/head"} -> Tesla.Mock.mock(fn %{method: :head, url: "/head"} ->
%Tesla.Env{ %Tesla.Env{
status: 200, status: 200,
headers: [{"content-type", "text/html; charset=utf-8"}], headers: [{"content-type", "image/png"}],
body: "" body: ""
} }
end) end)
conn = ReverseProxy.call(Map.put(conn, :method, "HEAD"), "/head") conn = ReverseProxy.call(Map.put(conn, :method, "HEAD"), "/head")
assert html_response(conn, 200) == ""
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["image/png"]
assert conn.resp_body == ""
end end
end end
@ -252,4 +255,38 @@ test "with content-disposition header", %{conn: conn} do
assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers
end end
end end
describe "content-type sanitisation" do
test "preserves video type", %{conn: conn} do
Tesla.Mock.mock(fn %{method: :get, url: "/content"} ->
%Tesla.Env{
status: 200,
headers: [{"content-type", "video/mp4"}],
body: "test"
}
end)
conn = ReverseProxy.call(Map.put(conn, :method, "GET"), "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["video/mp4"]
assert conn.resp_body == "test"
end
test "replaces application type", %{conn: conn} do
Tesla.Mock.mock(fn %{method: :get, url: "/content"} ->
%Tesla.Env{
status: 200,
headers: [{"content-type", "application/activity+json"}],
body: "test"
}
end)
conn = ReverseProxy.call(Map.put(conn, :method, "GET"), "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
assert conn.resp_body == "test"
end
end
end end