logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma
commit: b6fd16d0a424f916b7338237a2972e595e404955
parent: 1179a135333aa59e7fa4f5b908e1db8257b994f5
Author: kaniini <nenolod@gmail.com>
Date:   Tue,  9 Jul 2019 16:54:14 +0000

Merge branch 'test-coverage' into 'develop'

reverse proxy tests

See merge request pleroma/pleroma!1385

Diffstat:

Mconfig/test.exs2++
Mlib/mix/tasks/pleroma/ecto/ecto.ex1+
Dlib/pleroma/reverse_proxy.ex382-------------------------------------------------------------------------------
Alib/pleroma/reverse_proxy/client.ex24++++++++++++++++++++++++
Alib/pleroma/reverse_proxy/reverse_proxy.ex382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmix.exs3++-
Mmix.lock3+--
Atest/http/request_builder_test.exs91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/reverse_proxy_test.exs297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/tasks/ecto/ecto_test.exs11+++++++++++
Atest/tasks/pleroma_test.exs46++++++++++++++++++++++++++++++++++++++++++++++
Atest/tasks/robots_txt_test.exs43+++++++++++++++++++++++++++++++++++++++++++
Mtest/test_helper.exs2+-
13 files changed, 901 insertions(+), 386 deletions(-)

diff --git a/config/test.exs b/config/test.exs @@ -75,6 +75,8 @@ rum_enabled = System.get_env("RUM_ENABLED") == "true" config :pleroma, :database, rum_enabled: rum_enabled IO.puts("RUM enabled: #{rum_enabled}") +config :pleroma, Pleroma.ReverseProxy.Client, Pleroma.ReverseProxy.ClientMock + try do import_config "test.secret.exs" rescue diff --git a/lib/mix/tasks/pleroma/ecto/ecto.ex b/lib/mix/tasks/pleroma/ecto/ecto.ex @@ -1,6 +1,7 @@ # Pleroma: A lightweight social networking server # Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-onl + defmodule Mix.Tasks.Pleroma.Ecto do @doc """ Ensures the given repository's migrations path exists on the file system. diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex @@ -1,382 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.ReverseProxy do - alias Pleroma.HTTP - - @keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since) ++ - ~w(if-unmodified-since if-none-match if-range range) - @resp_cache_headers ~w(etag date last-modified cache-control) - @keep_resp_headers @resp_cache_headers ++ - ~w(content-type content-disposition content-encoding content-range) ++ - ~w(accept-ranges vary) - @default_cache_control_header "public, max-age=1209600" - @valid_resp_codes [200, 206, 304] - @max_read_duration :timer.seconds(30) - @max_body_length :infinity - @methods ~w(GET HEAD) - - @moduledoc """ - A reverse proxy. - - Pleroma.ReverseProxy.call(conn, url, options) - - It is not meant to be added into a plug pipeline, but to be called from another plug or controller. - - Supports `#{inspect(@methods)}` HTTP methods, and only allows `#{inspect(@valid_resp_codes)}` status codes. - - Responses are chunked to the client while downloading from the upstream. - - Some request / responses headers are preserved: - - * request: `#{inspect(@keep_req_headers)}` - * response: `#{inspect(@keep_resp_headers)}` - - If no caching headers (`#{inspect(@resp_cache_headers)}`) are returned by upstream, `cache-control` will be - set to `#{inspect(@default_cache_control_header)}`. - - Options: - - * `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP - errors. Any error during body processing will not be redirected as the response is chunked. This may expose - remote URL, clients IPs, …. - - * `max_body_length` (default `#{inspect(@max_body_length)}`): limits the content length to be approximately the - specified length. It is validated with the `content-length` header and also verified when proxying. - - * `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to - read from the remote upstream. - - * `inline_content_types`: - * `true` will not alter `content-disposition` (up to the upstream), - * `false` will add `content-disposition: attachment` to any request, - * a list of whitelisted content types - - * `keep_user_agent` will forward the client's user-agent to the upstream. This may be useful if the upstream is - doing content transformation (encoding, …) depending on the request. - - * `req_headers`, `resp_headers` additional headers. - - * `http`: options for [hackney](https://github.com/benoitc/hackney). - - """ - @default_hackney_options [] - - @inline_content_types [ - "image/gif", - "image/jpeg", - "image/jpg", - "image/png", - "image/svg+xml", - "audio/mpeg", - "audio/mp3", - "video/webm", - "video/mp4", - "video/quicktime" - ] - - require Logger - import Plug.Conn - - @type option() :: - {:keep_user_agent, boolean} - | {:max_read_duration, :timer.time() | :infinity} - | {:max_body_length, non_neg_integer() | :infinity} - | {:http, []} - | {:req_headers, [{String.t(), String.t()}]} - | {:resp_headers, [{String.t(), String.t()}]} - | {:inline_content_types, boolean() | [String.t()]} - | {:redirect_on_failure, boolean()} - - @spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t() - def call(_conn, _url, _opts \\ []) - - def call(conn = %{method: method}, url, opts) when method in @methods do - hackney_opts = - @default_hackney_options - |> Keyword.merge(Keyword.get(opts, :http, [])) - |> HTTP.process_request_options() - - req_headers = build_req_headers(conn.req_headers, opts) - - opts = - if filename = Pleroma.Web.MediaProxy.filename(url) do - Keyword.put_new(opts, :attachment_name, filename) - else - opts - end - - with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts), - :ok <- header_length_constraint(headers, Keyword.get(opts, :max_body_length)) do - response(conn, client, url, code, headers, opts) - else - {:ok, code, headers} -> - head_response(conn, url, code, headers, opts) - |> halt() - - {:error, {:invalid_http_response, code}} -> - Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}") - - conn - |> error_or_redirect( - url, - code, - "Request failed: " <> Plug.Conn.Status.reason_phrase(code), - opts - ) - |> halt() - - {:error, error} -> - Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}") - - conn - |> error_or_redirect(url, 500, "Request failed", opts) - |> halt() - end - end - - def call(conn, _, _) do - conn - |> send_resp(400, Plug.Conn.Status.reason_phrase(400)) - |> halt() - end - - defp request(method, url, headers, hackney_opts) do - Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}") - method = method |> String.downcase() |> String.to_existing_atom() - - case hackney().request(method, url, headers, "", hackney_opts) do - {:ok, code, headers, client} when code in @valid_resp_codes -> - {:ok, code, downcase_headers(headers), client} - - {:ok, code, headers} when code in @valid_resp_codes -> - {:ok, code, downcase_headers(headers)} - - {:ok, code, _, _} -> - {:error, {:invalid_http_response, code}} - - {:error, error} -> - {:error, error} - end - end - - defp response(conn, client, url, status, headers, opts) do - result = - conn - |> put_resp_headers(build_resp_headers(headers, opts)) - |> send_chunked(status) - |> chunk_reply(client, opts) - - case result do - {:ok, conn} -> - halt(conn) - - {:error, :closed, conn} -> - :hackney.close(client) - halt(conn) - - {:error, error, conn} -> - Logger.warn( - "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}" - ) - - :hackney.close(client) - halt(conn) - end - end - - defp chunk_reply(conn, client, opts) 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) - ), - {:ok, data} <- hackney().stream_body(client), - {:ok, duration} <- increase_read_duration(duration), - sent_so_far = sent_so_far + byte_size(data), - :ok <- body_size_constraint(sent_so_far, Keyword.get(opts, :max_body_size)), - {:ok, conn} <- chunk(conn, data) do - chunk_reply(conn, client, opts, sent_so_far, duration) - else - :done -> {:ok, conn} - {:error, error} -> {:error, error, conn} - end - end - - defp head_response(conn, _url, code, headers, opts) do - conn - |> put_resp_headers(build_resp_headers(headers, opts)) - |> send_resp(code, "") - end - - defp error_or_redirect(conn, url, code, body, opts) do - if Keyword.get(opts, :redirect_on_failure, false) do - conn - |> Phoenix.Controller.redirect(external: url) - |> halt() - else - conn - |> send_resp(code, body) - |> halt - end - end - - defp downcase_headers(headers) do - Enum.map(headers, fn {k, v} -> - {String.downcase(k), v} - end) - end - - defp get_content_type(headers) do - {_, content_type} = - List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"}) - - [content_type | _] = String.split(content_type, ";") - content_type - end - - defp put_resp_headers(conn, headers) do - Enum.reduce(headers, conn, fn {k, v}, conn -> - put_resp_header(conn, k, v) - end) - end - - defp build_req_headers(headers, opts) do - headers - |> downcase_headers() - |> Enum.filter(fn {k, _} -> k in @keep_req_headers end) - |> (fn headers -> - headers = headers ++ Keyword.get(opts, :req_headers, []) - - if Keyword.get(opts, :keep_user_agent, false) do - List.keystore( - headers, - "user-agent", - 0, - {"user-agent", Pleroma.Application.user_agent()} - ) - else - headers - end - end).() - end - - defp build_resp_headers(headers, opts) do - headers - |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end) - |> build_resp_cache_headers(opts) - |> build_resp_content_disposition_header(opts) - |> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).() - end - - defp build_resp_cache_headers(headers, _opts) do - has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end) - has_cache_control? = List.keymember?(headers, "cache-control", 0) - - cond do - has_cache? && has_cache_control? -> - headers - - has_cache? -> - # There's caching header present but no cache-control -- we need to explicitely override it - # to public as Plug defaults to "max-age=0, private, must-revalidate" - List.keystore(headers, "cache-control", 0, {"cache-control", "public"}) - - true -> - List.keystore( - headers, - "cache-control", - 0, - {"cache-control", @default_cache_control_header} - ) - end - end - - defp build_resp_content_disposition_header(headers, opts) do - opt = Keyword.get(opts, :inline_content_types, @inline_content_types) - - content_type = get_content_type(headers) - - attachment? = - cond do - is_list(opt) && !Enum.member?(opt, content_type) -> true - opt == false -> true - true -> false - end - - if attachment? do - name = - try do - {{"content-disposition", content_disposition_string}, _} = - List.keytake(headers, "content-disposition", 0) - - [name | _] = - Regex.run( - ~r/filename="((?:[^"\\]|\\.)*)"/u, - content_disposition_string || "", - capture: :all_but_first - ) - - name - rescue - MatchError -> Keyword.get(opts, :attachment_name, "attachment") - end - - disposition = "attachment; filename=\"#{name}\"" - - List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) - else - headers - end - end - - defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do - with {_, size} <- List.keyfind(headers, "content-length", 0), - {size, _} <- Integer.parse(size), - true <- size <= limit do - :ok - else - false -> - {:error, :body_too_large} - - _ -> - :ok - end - end - - defp header_length_constraint(_, _), do: :ok - - defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do - {:error, :body_too_large} - end - - defp body_size_constraint(_, _), do: :ok - - defp check_read_duration(duration, max) - when is_integer(duration) and is_integer(max) and max > 0 do - if duration > max do - {:error, :read_duration_exceeded} - else - {:ok, {duration, :erlang.system_time(:millisecond)}} - end - end - - defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit} - - defp increase_read_duration({previous_duration, started}) - when is_integer(previous_duration) and is_integer(started) do - duration = :erlang.system_time(:millisecond) - started - {:ok, previous_duration + duration} - end - - defp increase_read_duration(_) do - {:ok, :no_duration_limit, :no_duration_limit} - end - - defp hackney, do: Pleroma.Config.get(:hackney, :hackney) -end diff --git a/lib/pleroma/reverse_proxy/client.ex b/lib/pleroma/reverse_proxy/client.ex @@ -0,0 +1,24 @@ +defmodule Pleroma.ReverseProxy.Client do + @callback request(atom(), String.t(), [tuple()], String.t(), list()) :: + {:ok, pos_integer(), [tuple()], reference() | map()} + | {:ok, pos_integer(), [tuple()]} + | {:ok, reference()} + | {:error, term()} + + @callback stream_body(reference() | pid() | map()) :: + {:ok, binary()} | :done | {:error, String.t()} + + @callback close(reference() | pid() | map()) :: :ok + + def request(method, url, headers, "", opts \\ []) do + client().request(method, url, headers, "", opts) + end + + def stream_body(ref), do: client().stream_body(ref) + + def close(ref), do: client().close(ref) + + defp client do + Pleroma.Config.get([Pleroma.ReverseProxy.Client], :hackney) + end +end diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -0,0 +1,382 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxy do + alias Pleroma.HTTP + + @keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since) ++ + ~w(if-unmodified-since if-none-match if-range range) + @resp_cache_headers ~w(etag date last-modified cache-control) + @keep_resp_headers @resp_cache_headers ++ + ~w(content-type content-disposition content-encoding content-range) ++ + ~w(accept-ranges vary) + @default_cache_control_header "public, max-age=1209600" + @valid_resp_codes [200, 206, 304] + @max_read_duration :timer.seconds(30) + @max_body_length :infinity + @methods ~w(GET HEAD) + + @moduledoc """ + A reverse proxy. + + Pleroma.ReverseProxy.call(conn, url, options) + + It is not meant to be added into a plug pipeline, but to be called from another plug or controller. + + Supports `#{inspect(@methods)}` HTTP methods, and only allows `#{inspect(@valid_resp_codes)}` status codes. + + Responses are chunked to the client while downloading from the upstream. + + Some request / responses headers are preserved: + + * request: `#{inspect(@keep_req_headers)}` + * response: `#{inspect(@keep_resp_headers)}` + + If no caching headers (`#{inspect(@resp_cache_headers)}`) are returned by upstream, `cache-control` will be + set to `#{inspect(@default_cache_control_header)}`. + + Options: + + * `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP + errors. Any error during body processing will not be redirected as the response is chunked. This may expose + remote URL, clients IPs, …. + + * `max_body_length` (default `#{inspect(@max_body_length)}`): limits the content length to be approximately the + specified length. It is validated with the `content-length` header and also verified when proxying. + + * `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to + read from the remote upstream. + + * `inline_content_types`: + * `true` will not alter `content-disposition` (up to the upstream), + * `false` will add `content-disposition: attachment` to any request, + * a list of whitelisted content types + + * `keep_user_agent` will forward the client's user-agent to the upstream. This may be useful if the upstream is + doing content transformation (encoding, …) depending on the request. + + * `req_headers`, `resp_headers` additional headers. + + * `http`: options for [hackney](https://github.com/benoitc/hackney). + + """ + @default_hackney_options [] + + @inline_content_types [ + "image/gif", + "image/jpeg", + "image/jpg", + "image/png", + "image/svg+xml", + "audio/mpeg", + "audio/mp3", + "video/webm", + "video/mp4", + "video/quicktime" + ] + + require Logger + import Plug.Conn + + @type option() :: + {:keep_user_agent, boolean} + | {:max_read_duration, :timer.time() | :infinity} + | {:max_body_length, non_neg_integer() | :infinity} + | {:http, []} + | {:req_headers, [{String.t(), String.t()}]} + | {:resp_headers, [{String.t(), String.t()}]} + | {:inline_content_types, boolean() | [String.t()]} + | {:redirect_on_failure, boolean()} + + @spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t() + def call(_conn, _url, _opts \\ []) + + def call(conn = %{method: method}, url, opts) when method in @methods do + hackney_opts = + @default_hackney_options + |> Keyword.merge(Keyword.get(opts, :http, [])) + |> HTTP.process_request_options() + + req_headers = build_req_headers(conn.req_headers, opts) + + opts = + if filename = Pleroma.Web.MediaProxy.filename(url) do + Keyword.put_new(opts, :attachment_name, filename) + else + opts + end + + with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts), + :ok <- header_length_constraint(headers, Keyword.get(opts, :max_body_length)) do + response(conn, client, url, code, headers, opts) + else + {:ok, code, headers} -> + head_response(conn, url, code, headers, opts) + |> halt() + + {:error, {:invalid_http_response, code}} -> + Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}") + + conn + |> error_or_redirect( + url, + code, + "Request failed: " <> Plug.Conn.Status.reason_phrase(code), + opts + ) + |> halt() + + {:error, error} -> + Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}") + + conn + |> error_or_redirect(url, 500, "Request failed", opts) + |> halt() + end + end + + def call(conn, _, _) do + conn + |> send_resp(400, Plug.Conn.Status.reason_phrase(400)) + |> halt() + end + + defp request(method, url, headers, hackney_opts) do + Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}") + method = method |> String.downcase() |> String.to_existing_atom() + + case client().request(method, url, headers, "", hackney_opts) do + {:ok, code, headers, client} when code in @valid_resp_codes -> + {:ok, code, downcase_headers(headers), client} + + {:ok, code, headers} when code in @valid_resp_codes -> + {:ok, code, downcase_headers(headers)} + + {:ok, code, _, _} -> + {:error, {:invalid_http_response, code}} + + {:error, error} -> + {:error, error} + end + end + + defp response(conn, client, url, status, headers, opts) do + result = + conn + |> put_resp_headers(build_resp_headers(headers, opts)) + |> send_chunked(status) + |> chunk_reply(client, opts) + + case result do + {:ok, conn} -> + halt(conn) + + {:error, :closed, conn} -> + client().close(client) + halt(conn) + + {:error, error, conn} -> + Logger.warn( + "#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}" + ) + + client().close(client) + halt(conn) + end + end + + defp chunk_reply(conn, client, opts) 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) + ), + {:ok, data} <- client().stream_body(client), + {:ok, duration} <- increase_read_duration(duration), + sent_so_far = sent_so_far + byte_size(data), + :ok <- body_size_constraint(sent_so_far, Keyword.get(opts, :max_body_size)), + {:ok, conn} <- chunk(conn, data) do + chunk_reply(conn, client, opts, sent_so_far, duration) + else + :done -> {:ok, conn} + {:error, error} -> {:error, error, conn} + end + end + + defp head_response(conn, _url, code, headers, opts) do + conn + |> put_resp_headers(build_resp_headers(headers, opts)) + |> send_resp(code, "") + end + + defp error_or_redirect(conn, url, code, body, opts) do + if Keyword.get(opts, :redirect_on_failure, false) do + conn + |> Phoenix.Controller.redirect(external: url) + |> halt() + else + conn + |> send_resp(code, body) + |> halt + end + end + + defp downcase_headers(headers) do + Enum.map(headers, fn {k, v} -> + {String.downcase(k), v} + end) + end + + defp get_content_type(headers) do + {_, content_type} = + List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"}) + + [content_type | _] = String.split(content_type, ";") + content_type + end + + defp put_resp_headers(conn, headers) do + Enum.reduce(headers, conn, fn {k, v}, conn -> + put_resp_header(conn, k, v) + end) + end + + defp build_req_headers(headers, opts) do + headers + |> downcase_headers() + |> Enum.filter(fn {k, _} -> k in @keep_req_headers end) + |> (fn headers -> + headers = headers ++ Keyword.get(opts, :req_headers, []) + + if Keyword.get(opts, :keep_user_agent, false) do + List.keystore( + headers, + "user-agent", + 0, + {"user-agent", Pleroma.Application.user_agent()} + ) + else + headers + end + end).() + end + + defp build_resp_headers(headers, opts) do + headers + |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end) + |> build_resp_cache_headers(opts) + |> build_resp_content_disposition_header(opts) + |> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).() + end + + defp build_resp_cache_headers(headers, _opts) do + has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end) + has_cache_control? = List.keymember?(headers, "cache-control", 0) + + cond do + has_cache? && has_cache_control? -> + headers + + has_cache? -> + # There's caching header present but no cache-control -- we need to explicitely override it + # to public as Plug defaults to "max-age=0, private, must-revalidate" + List.keystore(headers, "cache-control", 0, {"cache-control", "public"}) + + true -> + List.keystore( + headers, + "cache-control", + 0, + {"cache-control", @default_cache_control_header} + ) + end + end + + defp build_resp_content_disposition_header(headers, opts) do + opt = Keyword.get(opts, :inline_content_types, @inline_content_types) + + content_type = get_content_type(headers) + + attachment? = + cond do + is_list(opt) && !Enum.member?(opt, content_type) -> true + opt == false -> true + true -> false + end + + if attachment? do + name = + try do + {{"content-disposition", content_disposition_string}, _} = + List.keytake(headers, "content-disposition", 0) + + [name | _] = + Regex.run( + ~r/filename="((?:[^"\\]|\\.)*)"/u, + content_disposition_string || "", + capture: :all_but_first + ) + + name + rescue + MatchError -> Keyword.get(opts, :attachment_name, "attachment") + end + + disposition = "attachment; filename=\"#{name}\"" + + List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition}) + else + headers + end + end + + defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do + with {_, size} <- List.keyfind(headers, "content-length", 0), + {size, _} <- Integer.parse(size), + true <- size <= limit do + :ok + else + false -> + {:error, :body_too_large} + + _ -> + :ok + end + end + + defp header_length_constraint(_, _), do: :ok + + defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do + {:error, :body_too_large} + end + + defp body_size_constraint(_, _), do: :ok + + defp check_read_duration(duration, max) + when is_integer(duration) and is_integer(max) and max > 0 do + if duration > max do + {:error, :read_duration_exceeded} + else + {:ok, {duration, :erlang.system_time(:millisecond)}} + end + end + + defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit} + + defp increase_read_duration({previous_duration, started}) + when is_integer(previous_duration) and is_integer(started) do + duration = :erlang.system_time(:millisecond) - started + {:ok, previous_duration + duration} + end + + defp increase_read_duration(_) do + {:ok, :no_duration_limit, :no_duration_limit} + end + + defp client, do: Pleroma.ReverseProxy.Client +end diff --git a/mix.exs b/mix.exs @@ -151,7 +151,8 @@ defmodule Pleroma.Mixfile do {:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)}, {:ex_rated, "~> 1.3"}, {:plug_static_index_html, "~> 1.0.0"}, - {:excoveralls, "~> 0.11.1", only: :test} + {:excoveralls, "~> 0.11.1", only: :test}, + {:mox, "~> 0.5", only: :test} ] ++ oauth_deps() end diff --git a/mix.lock b/mix.lock @@ -52,6 +52,7 @@ "mochiweb": {:hex, :mochiweb, "2.15.0", "e1daac474df07651e5d17cc1e642c4069c7850dc4508d3db7263a0651330aacc", [:rebar3], [], "hexpm"}, "mock": {:hex, :mock, "0.3.3", "42a433794b1291a9cf1525c6d26b38e039e0d3a360732b5e467bfc77ef26c914", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm"}, + "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm"}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.3", "6706a148809a29c306062862c803406e88f048277f6e85b68faf73291e820b84", [:mix], [], "hexpm"}, @@ -65,14 +66,12 @@ "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, - "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, "postgrex": {:hex, :postgrex, "0.14.3", "5754dee2fdf6e9e508cbf49ab138df964278700b764177e8f3871e658b345a1e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, "prometheus": {:hex, :prometheus, "4.2.2", "a830e77b79dc6d28183f4db050a7cac926a6c58f1872f9ef94a35cd989aceef8", [:mix, :rebar3], [], "hexpm"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.1", "6c768ea9654de871e5b32fab2eac348467b3021604ebebbcbd8bcbe806a65ed5", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"}, "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.2.1", "964a74dfbc055f781d3a75631e06ce3816a2913976d1df7830283aa3118a797a", [:mix], [{:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"}, "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm"}, - "prometheus_process_collector": {:hex, :prometheus_process_collector, "1.4.0", "6dbd39e3165b9ef1c94a7a820e9ffe08479f949dcdd431ed4aaea7b250eebfde", [:rebar3], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"}, "quack": {:hex, :quack, "0.1.1", "cca7b4da1a233757fdb44b3334fce80c94785b3ad5a602053b7a002b5a8967bf", [:mix], [{:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, "recon": {:git, "https://github.com/ferd/recon.git", "75d70c7c08926d2f24f1ee6de14ee50fe8a52763", [tag: "2.4.0"]}, diff --git a/test/http/request_builder_test.exs b/test/http/request_builder_test.exs @@ -0,0 +1,91 @@ +defmodule Pleroma.HTTP.RequestBuilderTest do + use ExUnit.Case, async: true + alias Pleroma.HTTP.RequestBuilder + + describe "headers/2" do + test "don't send pleroma user agent" do + assert RequestBuilder.headers(%{}, []) == %{headers: []} + end + + test "send pleroma user agent" do + send = Pleroma.Config.get([:http, :send_user_agent]) + Pleroma.Config.put([:http, :send_user_agent], true) + + on_exit(fn -> + Pleroma.Config.put([:http, :send_user_agent], send) + end) + + assert RequestBuilder.headers(%{}, []) == %{ + headers: [{"User-Agent", Pleroma.Application.user_agent()}] + } + end + end + + describe "add_optional_params/3" do + test "don't add if keyword is empty" do + assert RequestBuilder.add_optional_params(%{}, %{}, []) == %{} + end + + test "add query parameter" do + assert RequestBuilder.add_optional_params( + %{}, + %{query: :query, body: :body, another: :val}, + [ + {:query, "param1=val1&param2=val2"}, + {:body, "some body"} + ] + ) == %{query: "param1=val1&param2=val2", body: "some body"} + end + end + + describe "add_param/4" do + test "add file parameter" do + %{ + body: %Tesla.Multipart{ + boundary: _, + content_type_params: [], + parts: [ + %Tesla.Multipart.Part{ + body: %File.Stream{ + line_or_bytes: 2048, + modes: [:raw, :read_ahead, :read, :binary], + path: "some-path/filename.png", + raw: true + }, + dispositions: [name: "filename.png", filename: "filename.png"], + headers: [] + } + ] + } + } = RequestBuilder.add_param(%{}, :file, "filename.png", "some-path/filename.png") + end + + test "add key to body" do + %{ + body: %Tesla.Multipart{ + boundary: _, + content_type_params: [], + parts: [ + %Tesla.Multipart.Part{ + body: "\"someval\"", + dispositions: [name: "somekey"], + headers: ["Content-Type": "application/json"] + } + ] + } + } = RequestBuilder.add_param(%{}, :body, "somekey", "someval") + end + + test "add form parameter" do + assert RequestBuilder.add_param(%{}, :form, "somename", "someval") == %{ + body: %{"somename" => "someval"} + } + end + + test "add for location" do + assert RequestBuilder.add_param(%{}, :some_location, "somekey", "someval") == %{ + some_location: [{"somekey", "someval"}] + } + end + end +end diff --git a/test/reverse_proxy_test.exs b/test/reverse_proxy_test.exs @@ -0,0 +1,297 @@ +defmodule Pleroma.ReverseProxyTest do + use Pleroma.Web.ConnCase, async: true + import ExUnit.CaptureLog + import ExUnit.CaptureLog + import Mox + alias Pleroma.ReverseProxy + alias Pleroma.ReverseProxy.ClientMock + + setup_all do + {:ok, _} = Registry.start_link(keys: :unique, name: Pleroma.ReverseProxy.ClientMock) + :ok + end + + setup :verify_on_exit! + + defp user_agent_mock(user_agent, invokes) do + json = Jason.encode!(%{"user-agent": user_agent}) + + ClientMock + |> expect(:request, fn :get, url, _, _, _ -> + Registry.register(Pleroma.ReverseProxy.ClientMock, url, 0) + + {:ok, 200, + [ + {"content-type", "application/json"}, + {"content-length", byte_size(json) |> to_string()} + ], %{url: url}} + end) + |> expect(:stream_body, invokes, fn %{url: url} -> + case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do + [{_, 0}] -> + Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1)) + {:ok, json} + + [{_, 1}] -> + Registry.unregister(Pleroma.ReverseProxy.ClientMock, url) + :done + end + end) + end + + describe "user-agent" do + test "don't keep", %{conn: conn} do + user_agent_mock("hackney/1.15.1", 2) + conn = ReverseProxy.call(conn, "/user-agent") + assert json_response(conn, 200) == %{"user-agent" => "hackney/1.15.1"} + end + + test "keep", %{conn: conn} do + user_agent_mock(Pleroma.Application.user_agent(), 2) + conn = ReverseProxy.call(conn, "/user-agent-keep", keep_user_agent: true) + assert json_response(conn, 200) == %{"user-agent" => Pleroma.Application.user_agent()} + end + 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 + + describe "max_body " do + test "length returns error if content-length more than option", %{conn: conn} do + user_agent_mock("hackney/1.15.1", 0) + + assert capture_log(fn -> + ReverseProxy.call(conn, "/user-agent", max_body_length: 4) + end) =~ + "[error] Elixir.Pleroma.ReverseProxy: request to \"/user-agent\" failed: :body_too_large" + end + + defp stream_mock(invokes, with_close? \\ false) do + ClientMock + |> expect(:request, fn :get, "/stream-bytes/" <> length, _, _, _ -> + Registry.register(Pleroma.ReverseProxy.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} -> + max = String.to_integer(length) + + case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length) do + [{_, current}] when current < max -> + Registry.update_value( + Pleroma.ReverseProxy.ClientMock, + "/stream-bytes/" <> length, + &(&1 + 10) + ) + + {:ok, "0123456789"} + + [{_, ^max}] -> + Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/stream-bytes/" <> length) + :done + end + end) + + if with_close? do + expect(ClientMock, :close, fn _ -> :ok end) + end + end + + test "max_body_size 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_size: 30) + end) =~ + "[warn] 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", "text/html; charset=utf-8"}]} + end) + + conn = ReverseProxy.call(Map.put(conn, :method, "HEAD"), "/head") + assert html_response(conn, 200) == "" + 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) + + capture_log(fn -> ReverseProxy.call(conn, "/status/500") end) =~ + "[error] Elixir.Pleroma.ReverseProxy: request to /status/500 failed with HTTP status 500" + end + + test "400", %{conn: conn} do + error_mock(400) + + capture_log(fn -> ReverseProxy.call(conn, "/status/400") end) =~ + "[error] Elixir.Pleroma.ReverseProxy: request to /status/400 failed with HTTP status 400" + end + + test "204", %{conn: conn} do + ClientMock + |> expect(:request, fn :get, "/status/204", _, _, _ -> {:ok, 204, [], %{}} end) + + capture_log(fn -> + conn = ReverseProxy.call(conn, "/status/204") + 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" + 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 Plug.Conn.get_resp_header(conn, "content-type") == ["application/octet-stream"] + end + + defp headers_mock(_) do + ClientMock + |> expect(:request, fn :get, "/headers", headers, _, _ -> + Registry.register(Pleroma.ReverseProxy.ClientMock, "/headers", 0) + {:ok, 200, [{"content-type", "application/json"}], %{url: "/headers", headers: headers}} + end) + |> expect(:stream_body, 2, fn %{url: url, headers: headers} -> + case Registry.lookup(Pleroma.ReverseProxy.ClientMock, url) do + [{_, 0}] -> + Registry.update_value(Pleroma.ReverseProxy.ClientMock, url, &(&1 + 1)) + headers = for {k, v} <- headers, into: %{}, do: {String.capitalize(k), v} + {:ok, Jason.encode!(%{headers: headers})} + + [{_, 1}] -> + Registry.unregister(Pleroma.ReverseProxy.ClientMock, url) + :done + end + end) + + :ok + end + + describe "keep request headers" do + setup [:headers_mock] + + test "header passes", %{conn: conn} do + conn = + Plug.Conn.put_req_header( + conn, + "accept", + "text/html" + ) + |> ReverseProxy.call("/headers") + + %{"headers" => headers} = json_response(conn, 200) + assert headers["Accept"] == "text/html" + end + + test "header is filtered", %{conn: conn} do + conn = + Plug.Conn.put_req_header( + conn, + "accept-language", + "en-US" + ) + |> ReverseProxy.call("/headers") + + %{"headers" => headers} = json_response(conn, 200) + 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 "returns headers", %{conn: conn} do + ClientMock + |> expect(:request, fn :get, "/cache/" <> ttl, _, _, _ -> + {:ok, 200, [{"cache-control", "public, max-age=" <> ttl}], %{}} + end) + |> expect(:stream_body, fn _ -> :done end) + + conn = ReverseProxy.call(conn, "/cache/10") + assert {"cache-control", "public, max-age=10"} in conn.resp_headers + end + + 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"} in conn.resp_headers + end + end + + defp disposition_headers_mock(headers) do + ClientMock + |> expect(:request, fn :get, "/disposition", _, _, _ -> + Registry.register(Pleroma.ReverseProxy.ClientMock, "/disposition", 0) + + {:ok, 200, headers, %{url: "/disposition"}} + end) + |> expect(:stream_body, 2, fn %{url: "/disposition"} -> + case Registry.lookup(Pleroma.ReverseProxy.ClientMock, "/disposition") do + [{_, 0}] -> + Registry.update_value(Pleroma.ReverseProxy.ClientMock, "/disposition", &(&1 + 1)) + {:ok, ""} + + [{_, 1}] -> + Registry.unregister(Pleroma.ReverseProxy.ClientMock, "/disposition") + :done + end + end) + end + + describe "response content disposition header" do + test "not atachment", %{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 +end diff --git a/test/tasks/ecto/ecto_test.exs b/test/tasks/ecto/ecto_test.exs @@ -0,0 +1,11 @@ +defmodule Mix.Tasks.Pleroma.EctoTest do + use ExUnit.Case, async: true + + test "raise on bad path" do + assert_raise RuntimeError, ~r/Could not find migrations directory/, fn -> + Mix.Tasks.Pleroma.Ecto.ensure_migrations_path(Pleroma.Repo, + migrations_path: "some-path" + ) + end + end +end diff --git a/test/tasks/pleroma_test.exs b/test/tasks/pleroma_test.exs @@ -0,0 +1,46 @@ +defmodule Mix.PleromaTest do + use ExUnit.Case, async: true + import Mix.Pleroma + + setup_all do + Mix.shell(Mix.Shell.Process) + + on_exit(fn -> + Mix.shell(Mix.Shell.IO) + end) + + :ok + end + + describe "shell_prompt/1" do + test "input" do + send(self(), {:mix_shell_input, :prompt, "Yes"}) + + answer = shell_prompt("Do you want this?") + assert_received {:mix_shell, :prompt, [message]} + assert message =~ "Do you want this?" + assert answer == "Yes" + end + + test "with defval" do + send(self(), {:mix_shell_input, :prompt, "\n"}) + + answer = shell_prompt("Do you want this?", "defval") + + assert_received {:mix_shell, :prompt, [message]} + assert message =~ "Do you want this? [defval]" + assert answer == "defval" + end + end + + describe "get_option/3" do + test "get from options" do + assert get_option([domain: "some-domain.com"], :domain, "Promt") == "some-domain.com" + end + + test "get from prompt" do + send(self(), {:mix_shell_input, :prompt, "another-domain.com"}) + assert get_option([], :domain, "Prompt") == "another-domain.com" + end + end +end diff --git a/test/tasks/robots_txt_test.exs b/test/tasks/robots_txt_test.exs @@ -0,0 +1,43 @@ +defmodule Mix.Tasks.Pleroma.RobotsTxtTest do + use ExUnit.Case, async: true + alias Mix.Tasks.Pleroma.RobotsTxt + + test "creates new dir" do + path = "test/fixtures/new_dir/" + file_path = path <> "robots.txt" + + static_dir = Pleroma.Config.get([:instance, :static_dir]) + Pleroma.Config.put([:instance, :static_dir], path) + + on_exit(fn -> + Pleroma.Config.put([:instance, :static_dir], static_dir) + {:ok, ["test/fixtures/new_dir/", "test/fixtures/new_dir/robots.txt"]} = File.rm_rf(path) + end) + + RobotsTxt.run(["disallow_all"]) + + assert File.exists?(file_path) + {:ok, file} = File.read(file_path) + + assert file == "User-Agent: *\nDisallow: /\n" + end + + test "to existance folder" do + path = "test/fixtures/" + file_path = path <> "robots.txt" + static_dir = Pleroma.Config.get([:instance, :static_dir]) + Pleroma.Config.put([:instance, :static_dir], path) + + on_exit(fn -> + Pleroma.Config.put([:instance, :static_dir], static_dir) + :ok = File.rm(file_path) + end) + + RobotsTxt.run(["disallow_all"]) + + assert File.exists?(file_path) + {:ok, file} = File.read(file_path) + + assert file == "User-Agent: *\nDisallow: /\n" + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs @@ -3,6 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only ExUnit.start() - Ecto.Adapters.SQL.Sandbox.mode(Pleroma.Repo, :manual) +Mox.defmock(Pleroma.ReverseProxy.ClientMock, for: Pleroma.ReverseProxy.Client) {:ok, _} = Application.ensure_all_started(:ex_machina)