forked from AkkomaGang/akkoma
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:
parent
bcc528b2e2
commit
11ae8344eb
2 changed files with 57 additions and 2 deletions
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue