11ae8344eb
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.
292 lines
7.9 KiB
Elixir
292 lines
7.9 KiB
Elixir
# Pleroma: A lightweight social networking server
|
|
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
defmodule Pleroma.ReverseProxyTest do
|
|
use Pleroma.Web.ConnCase, async: false
|
|
import ExUnit.CaptureLog
|
|
|
|
alias Pleroma.ReverseProxy
|
|
alias Plug.Conn
|
|
|
|
describe "reverse proxy" do
|
|
test "do not track successful request", %{conn: conn} do
|
|
url = "/success"
|
|
|
|
Tesla.Mock.mock(fn %{url: ^url} ->
|
|
%Tesla.Env{
|
|
status: 200,
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
conn = ReverseProxy.call(conn, url)
|
|
|
|
assert response(conn, 200)
|
|
assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, nil}
|
|
end
|
|
|
|
test "use Pleroma's user agent in the request; don't pass the client's", %{conn: conn} do
|
|
clear_config([:http, :send_user_agent], true)
|
|
# Mock will fail if the client's user agent isn't filtered
|
|
wanted_headers = [{"user-agent", Pleroma.Application.user_agent()}]
|
|
|
|
Tesla.Mock.mock(fn %{url: "/user-agent", headers: ^wanted_headers} ->
|
|
%Tesla.Env{
|
|
status: 200,
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
conn =
|
|
conn
|
|
|> Plug.Conn.put_req_header("user-agent", "fake/1.0")
|
|
|> ReverseProxy.call("/user-agent")
|
|
|
|
assert response(conn, 200)
|
|
end
|
|
end
|
|
|
|
describe "max_body" do
|
|
test "length returns error if content-length more than option", %{conn: conn} do
|
|
Tesla.Mock.mock(fn %{url: "/huge-file"} ->
|
|
%Tesla.Env{
|
|
status: 200,
|
|
headers: [{"content-length", "100"}],
|
|
body: "This body is too large."
|
|
}
|
|
end)
|
|
|
|
assert capture_log(fn ->
|
|
ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
|
|
end) =~
|
|
"[error] Elixir.Pleroma.ReverseProxy: request to \"/huge-file\" failed: :body_too_large"
|
|
|
|
assert {:ok, true} == Cachex.get(:failed_proxy_url_cache, "/huge-file")
|
|
|
|
assert capture_log(fn ->
|
|
ReverseProxy.call(conn, "/huge-file", max_body_length: 4)
|
|
end) == ""
|
|
end
|
|
end
|
|
|
|
describe "HEAD requests" do
|
|
test "common", %{conn: conn} do
|
|
Tesla.Mock.mock(fn %{method: :head, url: "/head"} ->
|
|
%Tesla.Env{
|
|
status: 200,
|
|
headers: [{"content-type", "image/png"}],
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
conn = ReverseProxy.call(Map.put(conn, :method, "HEAD"), "/head")
|
|
|
|
assert conn.status == 200
|
|
assert Conn.get_resp_header(conn, "content-type") == ["image/png"]
|
|
assert conn.resp_body == ""
|
|
end
|
|
end
|
|
|
|
describe "returns error on" do
|
|
test "500", %{conn: conn} do
|
|
url = "/status/500"
|
|
|
|
Tesla.Mock.mock(fn %{url: ^url} ->
|
|
%Tesla.Env{
|
|
status: 500,
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
capture_log(fn -> ReverseProxy.call(conn, url) end) =~
|
|
"[error] Elixir.Pleroma.ReverseProxy: request to /status/500 failed with HTTP status 500"
|
|
|
|
assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true}
|
|
|
|
{:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url)
|
|
assert ttl <= 60_000
|
|
end
|
|
|
|
test "400", %{conn: conn} do
|
|
url = "/status/400"
|
|
|
|
Tesla.Mock.mock(fn %{url: ^url} ->
|
|
%Tesla.Env{
|
|
status: 400,
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
capture_log(fn -> ReverseProxy.call(conn, url) end) =~
|
|
"[error] Elixir.Pleroma.ReverseProxy: request to /status/400 failed with HTTP status 400"
|
|
|
|
assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true}
|
|
assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil}
|
|
end
|
|
|
|
test "403", %{conn: conn} do
|
|
url = "/status/403"
|
|
|
|
Tesla.Mock.mock(fn %{url: ^url} ->
|
|
%Tesla.Env{
|
|
status: 403,
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
capture_log(fn ->
|
|
ReverseProxy.call(conn, url, failed_request_ttl: :timer.seconds(120))
|
|
end) =~
|
|
"[error] Elixir.Pleroma.ReverseProxy: request to /status/403 failed with HTTP status 403"
|
|
|
|
{:ok, ttl} = Cachex.ttl(:failed_proxy_url_cache, url)
|
|
assert ttl > 100_000
|
|
end
|
|
end
|
|
|
|
describe "keep request headers" do
|
|
test "header passes", %{conn: conn} do
|
|
Tesla.Mock.mock(fn %{url: "/headers"} ->
|
|
%Tesla.Env{
|
|
status: 200,
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
conn =
|
|
Conn.put_req_header(
|
|
conn,
|
|
"accept",
|
|
"text/html"
|
|
)
|
|
|> ReverseProxy.call("/headers")
|
|
|
|
assert response(conn, 200)
|
|
assert {"accept", "text/html"} in conn.req_headers
|
|
end
|
|
|
|
test "header is filtered", %{conn: conn} do
|
|
# Mock will fail if the accept-language header isn't filtered
|
|
wanted_headers = [{"accept-encoding", "*"}]
|
|
|
|
Tesla.Mock.mock(fn %{url: "/headers", headers: ^wanted_headers} ->
|
|
%Tesla.Env{
|
|
status: 200,
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
conn =
|
|
conn
|
|
|> Conn.put_req_header("accept-language", "en-US")
|
|
|> Conn.put_req_header("accept-encoding", "*")
|
|
|> ReverseProxy.call("/headers")
|
|
|
|
assert response(conn, 200)
|
|
end
|
|
end
|
|
|
|
test "returns 400 on non GET, HEAD requests", %{conn: conn} do
|
|
Tesla.Mock.mock(fn %{url: "/ip"} ->
|
|
%Tesla.Env{
|
|
status: 200,
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
conn = ReverseProxy.call(Map.put(conn, :method, "POST"), "/ip")
|
|
assert response(conn, 400)
|
|
end
|
|
|
|
describe "cache resp headers not filtered" do
|
|
test "add cache-control", %{conn: conn} do
|
|
Tesla.Mock.mock(fn %{url: "/cache"} ->
|
|
%Tesla.Env{
|
|
status: 200,
|
|
headers: [
|
|
{"cache-control", "public, max-age=1209600"},
|
|
{"etag", "some ETag"},
|
|
{"expires", "Wed, 21 Oct 2015 07:28:00 GMT"}
|
|
],
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
conn = ReverseProxy.call(conn, "/cache")
|
|
assert {"cache-control", "public, max-age=1209600"} in conn.resp_headers
|
|
assert {"etag", "some ETag"} in conn.resp_headers
|
|
assert {"expires", "Wed, 21 Oct 2015 07:28:00 GMT"} in conn.resp_headers
|
|
end
|
|
end
|
|
|
|
describe "response content disposition header" do
|
|
test "not attachment", %{conn: conn} do
|
|
Tesla.Mock.mock(fn %{url: "/disposition"} ->
|
|
%Tesla.Env{
|
|
status: 200,
|
|
headers: [
|
|
{"content-type", "image/gif"},
|
|
{"content-length", "0"}
|
|
],
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
conn = ReverseProxy.call(conn, "/disposition")
|
|
|
|
assert {"content-type", "image/gif"} in conn.resp_headers
|
|
end
|
|
|
|
test "with content-disposition header", %{conn: conn} do
|
|
Tesla.Mock.mock(fn %{url: "/disposition"} ->
|
|
%Tesla.Env{
|
|
status: 200,
|
|
headers: [
|
|
{"content-disposition", "attachment; filename=\"filename.jpg\""},
|
|
{"content-length", "0"}
|
|
],
|
|
body: ""
|
|
}
|
|
end)
|
|
|
|
conn = ReverseProxy.call(conn, "/disposition")
|
|
|
|
assert {"content-disposition", "attachment; filename=\"filename.jpg\""} in conn.resp_headers
|
|
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
|