pleroma/test/pleroma/reverse_proxy_test.exs
Phantasm 7d8a188967
Disable Hackney URL encoding function
Hackney interferes with out URI encoding and implements older RFC 2396
instead of RFC 3986 which we and Elixir implement. As an example "'"
and "!" will get encoded by it and cause problems with our MediaProxy
making unexpected 302 redirects.

If an admin supplies a different function via *.secret.exs, we
don't override it.

https://github.com/benoitc/hackney/issues/399
2025-12-10 14:56:07 +01:00

461 lines
14 KiB
Elixir

# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.ReverseProxyTest do
use Pleroma.Web.ConnCase
import ExUnit.CaptureLog
import Mox
alias Pleroma.ReverseProxy
alias Pleroma.ReverseProxy.ClientMock
alias Plug.Conn
setup_all do
{:ok, _} = Registry.start_link(keys: :unique, name: ClientMock)
:ok
end
setup :verify_on_exit!
defp request_mock(invokes) do
ClientMock
|> expect(:request, fn :get, url, headers, _body, _opts ->
Registry.register(ClientMock, url, 0)
body = headers |> Enum.into(%{}) |> Jason.encode!()
{:ok, 200,
[
{"content-type", "application/json"},
{"content-length", byte_size(body) |> to_string()}
], %{url: url, body: body}}
end)
|> expect(:stream_body, invokes, fn %{url: url, body: body} = client ->
case Registry.lookup(ClientMock, url) do
[{_, 0}] ->
Registry.update_value(ClientMock, url, &(&1 + 1))
{:ok, body, client}
[{_, 1}] ->
Registry.unregister(ClientMock, url)
:done
end
end)
end
describe "reverse proxy" do
test "do not track successful request", %{conn: conn} do
request_mock(2)
url = "/success"
conn = ReverseProxy.call(conn, url)
assert conn.status == 200
assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, nil}
end
end
test "use Pleroma's user agent in the request; don't pass the client's", %{conn: conn} do
request_mock(2)
conn =
conn
|> Plug.Conn.put_req_header("user-agent", "fake/1.0")
|> ReverseProxy.call("/user-agent")
# Convert the response to a map without relying on json_response
body = conn.resp_body
assert conn.status == 200
response = Jason.decode!(body)
assert response == %{"user-agent" => Pleroma.Application.user_agent()}
end
test "closed connection", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/closed", _, _, _ -> {:ok, 200, [], %{}} end)
|> expect(:stream_body, fn _ -> {:error, :closed} end)
|> expect(:close, fn _ -> :ok end)
conn = ReverseProxy.call(conn, "/closed")
assert conn.halted
end
defp stream_mock(invokes, with_close? \\ false) do
ClientMock
|> expect(:request, fn :get, "/stream-bytes/" <> length, _, _, _ ->
Registry.register(ClientMock, "/stream-bytes/" <> length, 0)
{:ok, 200, [{"content-type", "application/octet-stream"}],
%{url: "/stream-bytes/" <> length}}
end)
|> expect(:stream_body, invokes, fn %{url: "/stream-bytes/" <> length} = client ->
max = String.to_integer(length)
case Registry.lookup(ClientMock, "/stream-bytes/" <> length) do
[{_, current}] when current < max ->
Registry.update_value(
ClientMock,
"/stream-bytes/" <> length,
&(&1 + 10)
)
{:ok, "0123456789", client}
[{_, ^max}] ->
Registry.unregister(ClientMock, "/stream-bytes/" <> length)
:done
end
end)
if with_close? do
expect(ClientMock, :close, fn _ -> :ok end)
end
end
describe "max_body" do
test "length returns error if content-length more than option", %{conn: conn} do
request_mock(0)
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
test "max_body_length returns error if streaming body more than that option", %{conn: conn} do
stream_mock(3, true)
assert capture_log(fn ->
ReverseProxy.call(conn, "/stream-bytes/50", max_body_length: 30)
end) =~
"Elixir.Pleroma.ReverseProxy request to /stream-bytes/50 failed while reading/chunking: :body_too_large"
end
end
describe "HEAD requests" do
test "common", %{conn: conn} do
ClientMock
|> expect(:request, fn :head, "/head", _, _, _ ->
{:ok, 200, [{"content-type", "image/png"}]}
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
defp error_mock(status) when is_integer(status) do
ClientMock
|> expect(:request, fn :get, "/status/" <> _, _, _, _ ->
{:error, status}
end)
end
describe "returns error on" do
test "500", %{conn: conn} do
error_mock(500)
url = "/status/500"
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
error_mock(400)
url = "/status/400"
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
error_mock(403)
url = "/status/403"
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
test "204", %{conn: conn} do
url = "/status/204"
expect(ClientMock, :request, fn :get, _url, _, _, _ -> {:ok, 204, [], %{}} end)
capture_log(fn ->
conn = ReverseProxy.call(conn, url)
assert conn.resp_body == "Request failed: No Content"
assert conn.halted
end) =~
"[error] Elixir.Pleroma.ReverseProxy: request to \"/status/204\" failed with HTTP status 204"
assert Cachex.get(:failed_proxy_url_cache, url) == {:ok, true}
assert Cachex.ttl(:failed_proxy_url_cache, url) == {:ok, nil}
end
end
test "streaming", %{conn: conn} do
stream_mock(21)
conn = ReverseProxy.call(conn, "/stream-bytes/200")
assert conn.state == :chunked
assert byte_size(conn.resp_body) == 200
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end
defp headers_mock(_) do
ClientMock
|> expect(:request, fn :get, "/headers", headers, _, _ ->
Registry.register(ClientMock, "/headers", 0)
{:ok, 200, [{"content-type", "application/json"}], %{url: "/headers", headers: headers}}
end)
|> expect(:stream_body, 2, fn %{url: url, headers: headers} = client ->
case Registry.lookup(ClientMock, url) do
[{_, 0}] ->
Registry.update_value(ClientMock, url, &(&1 + 1))
headers = for {k, v} <- headers, into: %{}, do: {String.capitalize(k), v}
{:ok, Jason.encode!(%{headers: headers}), client}
[{_, 1}] ->
Registry.unregister(ClientMock, url)
:done
end
end)
:ok
end
describe "keep request headers" do
setup [:headers_mock]
test "header passes", %{conn: conn} do
conn =
Conn.put_req_header(
conn,
"accept",
"text/html"
)
|> ReverseProxy.call("/headers")
body = conn.resp_body
assert conn.status == 200
response = Jason.decode!(body)
headers = response["headers"]
assert headers["Accept"] == "text/html"
end
test "header is filtered", %{conn: conn} do
conn =
Conn.put_req_header(
conn,
"accept-language",
"en-US"
)
|> ReverseProxy.call("/headers")
body = conn.resp_body
assert conn.status == 200
response = Jason.decode!(body)
headers = response["headers"]
refute headers["Accept-Language"]
end
end
test "returns 400 on non GET, HEAD requests", %{conn: conn} do
conn = ReverseProxy.call(Map.put(conn, :method, "POST"), "/ip")
assert conn.status == 400
end
describe "cache resp headers" do
test "add cache-control", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/cache", _, _, _ ->
{:ok, 200, [{"ETag", "some ETag"}], %{}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/cache")
assert {"cache-control", "public, max-age=1209600"} in conn.resp_headers
end
end
defp disposition_headers_mock(headers) do
ClientMock
|> expect(:request, fn :get, "/disposition", _, _, _ ->
Registry.register(ClientMock, "/disposition", 0)
{:ok, 200, headers, %{url: "/disposition"}}
end)
|> expect(:stream_body, 2, fn %{url: "/disposition"} = client ->
case Registry.lookup(ClientMock, "/disposition") do
[{_, 0}] ->
Registry.update_value(ClientMock, "/disposition", &(&1 + 1))
{:ok, "", client}
[{_, 1}] ->
Registry.unregister(ClientMock, "/disposition")
:done
end
end)
end
describe "response content disposition header" do
test "not attachment", %{conn: conn} do
disposition_headers_mock([
{"content-type", "image/gif"},
{"content-length", "0"}
])
conn = ReverseProxy.call(conn, "/disposition")
assert {"content-type", "image/gif"} in conn.resp_headers
end
test "with content-disposition header", %{conn: conn} do
disposition_headers_mock([
{"content-disposition", "attachment; filename=\"filename.jpg\""},
{"content-length", "0"}
])
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 allowed image type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "image/png"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["image/png"]
end
test "preserves allowed video type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "video/mp4"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["video/mp4"]
end
test "sanitizes ActivityPub content type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "application/activity+json"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end
test "sanitizes LD-JSON content type", %{conn: conn} do
ClientMock
|> expect(:request, fn :get, "/content", _, _, _ ->
{:ok, 200, [{"content-type", "application/ld+json"}], %{url: "/content"}}
end)
|> expect(:stream_body, fn _ -> :done end)
conn = ReverseProxy.call(conn, "/content")
assert conn.status == 200
assert Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"]
end
end
# Hackney is used for Reverse Proxy when Hackney or Finch is the Tesla Adapter
# Gun is able to proxy through Tesla, so it does not need testing as the
# test cases in the Pleroma.HTTPTest module are sufficient
describe "Hackney URL encoding:" do
setup do
ClientMock
|> expect(:request, fn
:get,
"https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz",
_headers,
_body,
_opts ->
{:ok, 200, [{"content-type", "image/png"}], "It works!"}
:get,
"https://example.com/media/foo/bar%20!$&'()*+,;=/:%20@a%20%5Bbaz%5D.mp4",
_headers,
_body,
_opts ->
{:ok, 200, [{"content-type", "video/mp4"}], "Allowed reserved chars."}
:get, "https://example.com/media/unicode%20%F0%9F%99%82%20.gif", _headers, _body, _opts ->
{:ok, 200, [{"content-type", "image/gif"}], "Unicode emoji in path"}
end)
|> stub(:stream_body, fn _ -> :done end)
|> stub(:close, fn _ -> :ok end)
:ok
end
test "properly encodes URLs with spaces", %{conn: conn} do
url_with_space = "https://example.com/emoji/Pack 1/koronebless.png?foo=bar baz"
result = ReverseProxy.call(conn, url_with_space)
assert result.status == 200
end
test "properly encoded URL should not be altered", %{conn: conn} do
properly_encoded_url = "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz"
result = ReverseProxy.call(conn, properly_encoded_url)
assert result.status == 200
end
test "properly encodes URLs with allowed reserved characters", %{conn: conn} do
url_with_reserved_chars = "https://example.com/media/foo/bar !$&'()*+,;=/: @a [baz].mp4"
result = ReverseProxy.call(conn, url_with_reserved_chars)
assert result.status == 200
end
test "properly encodes URLs with unicode in path", %{conn: conn} do
url_with_unicode = "https://example.com/media/unicode 🙂 .gif"
result = ReverseProxy.call(conn, url_with_unicode)
assert result.status == 200
end
end
end