diff --git a/changelog.d/hackney-mediaproxy.change b/changelog.d/hackney-mediaproxy.change
new file mode 100644
index 000000000..10dfb0775
--- /dev/null
+++ b/changelog.d/hackney-mediaproxy.change
@@ -0,0 +1 @@
+Use a custom redirect handler to ensure MediaProxy redirects are followed with Hackney
diff --git a/changelog.d/hackney.change b/changelog.d/hackney.change
new file mode 100644
index 000000000..3158cfc77
--- /dev/null
+++ b/changelog.d/hackney.change
@@ -0,0 +1 @@
+Update Hackney, the default HTTP client, to the latest release which supports Happy Eyeballs for improved IPv6 federation
diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex
index f3451cf9c..2b1502001 100644
--- a/lib/pleroma/http/adapter_helper/hackney.ex
+++ b/lib/pleroma/http/adapter_helper/hackney.ex
@@ -6,8 +6,9 @@ defmodule Pleroma.HTTP.AdapterHelper.Hackney do
@behaviour Pleroma.HTTP.AdapterHelper
@defaults [
- follow_redirect: true,
- force_redirect: true
+ follow_redirect: false,
+ force_redirect: false,
+ with_body: true
]
@spec options(keyword(), URI.t()) :: keyword()
diff --git a/lib/pleroma/reverse_proxy/client/hackney.ex b/lib/pleroma/reverse_proxy/client/hackney.ex
index 0aa5f5715..7e1fca80d 100644
--- a/lib/pleroma/reverse_proxy/client/hackney.ex
+++ b/lib/pleroma/reverse_proxy/client/hackney.ex
@@ -5,6 +5,23 @@
defmodule Pleroma.ReverseProxy.Client.Hackney do
@behaviour Pleroma.ReverseProxy.Client
+ # In-app redirect handler to avoid Hackney redirect bugs:
+ # - https://github.com/benoitc/hackney/issues/527 (relative/protocol-less redirects can crash Hackney)
+ # - https://github.com/benoitc/hackney/issues/273 (redirects not followed when using HTTP proxy)
+ #
+ # Based on a redirect handler from Pleb, slightly modified to work with Hackney:
+ # https://declin.eu/objects/d4f38e62-5429-4614-86d1-e8fc16e6bf33
+ @redirect_statuses [301, 302, 303, 307, 308]
+ defp absolute_redirect_url(original_url, resp_headers) do
+ location =
+ Enum.find(resp_headers, fn {header, _location} ->
+ String.downcase(header) == "location"
+ end)
+
+ URI.merge(original_url, elem(location, 1))
+ |> URI.to_string()
+ end
+
@impl true
def request(method, url, headers, body, opts \\ []) do
opts =
@@ -12,7 +29,35 @@ defmodule Pleroma.ReverseProxy.Client.Hackney do
path
end)
- :hackney.request(method, url, headers, body, opts)
+ if opts[:follow_redirect] != false do
+ {_state, req_opts} = Access.get_and_update(opts, :follow_redirect, fn a -> {a, false} end)
+ res = :hackney.request(method, url, headers, body, req_opts)
+
+ case res do
+ {:ok, code, resp_headers, _client} when code in @redirect_statuses ->
+ :hackney.request(
+ method,
+ absolute_redirect_url(url, resp_headers),
+ headers,
+ body,
+ req_opts
+ )
+
+ {:ok, code, resp_headers} when code in @redirect_statuses ->
+ :hackney.request(
+ method,
+ absolute_redirect_url(url, resp_headers),
+ headers,
+ body,
+ req_opts
+ )
+
+ _ ->
+ res
+ end
+ else
+ :hackney.request(method, url, headers, body, opts)
+ end
end
@impl true
diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex
index b0d07a6f8..43c2c0449 100644
--- a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex
+++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex
@@ -27,7 +27,14 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do
end
defp fetch(url) do
- http_client_opts = Pleroma.Config.get([:media_proxy, :proxy_opts, :http], pool: :media)
+ # This module uses Tesla (Pleroma.HTTP) to fetch the MediaProxy URL.
+ # Redirect following is handled by Tesla middleware, so we must not enable
+ # adapter-level redirect logic (Hackney can crash on relative redirects when proxied).
+ http_client_opts =
+ [:media_proxy, :proxy_opts, :http]
+ |> Pleroma.Config.get(pool: :media)
+ |> Keyword.drop([:follow_redirect, :force_redirect])
+
HTTP.get(url, [], http_client_opts)
end
diff --git a/mix.exs b/mix.exs
index 5b0c62a49..a4415fddc 100644
--- a/mix.exs
+++ b/mix.exs
@@ -211,7 +211,7 @@ defmodule Pleroma.Mixfile do
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:mock, "~> 0.3.5", only: :test},
{:covertool, "~> 2.0", only: :test},
- {:hackney, "~> 1.18.0", override: true},
+ {:hackney, "~> 1.25.0", override: true},
{:mox, "~> 1.0", only: :test},
{:websockex, "~> 0.4.3", only: :test},
{:benchee, "~> 1.0", only: :benchmark},
diff --git a/mix.lock b/mix.lock
index a0ef38fc0..8e1f684dc 100644
--- a/mix.lock
+++ b/mix.lock
@@ -13,7 +13,7 @@
"captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "e7b7cc34cc16b383461b966484c297e4ec9aeef6", [ref: "e7b7cc34cc16b383461b966484c297e4ec9aeef6"]},
"castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
- "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
+ "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"concurrent_limiter": {:hex, :concurrent_limiter, "0.1.1", "43ae1dc23edda1ab03dd66febc739c4ff710d047bb4d735754909f9a474ae01c", [:mix], [{:telemetry, "~> 0.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "53968ff238c0fbb4d7ed76ddb1af0be6f3b2f77909f6796e249e737c505a16eb"},
@@ -59,7 +59,7 @@
"gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"},
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
"gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"},
- "hackney": {:hex, :hackney, "1.18.2", "d7ff544ddae5e1cb49e9cf7fa4e356d7f41b283989a1c304bfc47a8cc1cf966f", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "af94d5c9f97857db257090a4a10e5426ecb6f4918aa5cc666798566ae14b65fd"},
+ "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"http_signatures": {:hex, :http_signatures, "0.1.2", "ed1cc7043abcf5bb4f30d68fb7bad9d618ec1a45c4ff6c023664e78b67d9c406", [:mix], [], "hexpm", "f08aa9ac121829dae109d608d83c84b940ef2f183ae50f2dd1e9a8bc619d8be7"},
diff --git a/test/fixtures/server.pem b/test/fixtures/server.pem
new file mode 100644
index 000000000..8bd3d6d05
--- /dev/null
+++ b/test/fixtures/server.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIICpDCCAYwCCQC0vCQAnSoGdzANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls
+b2NhbGhvc3QwHhcNMjYwMTE2MTY1ODE5WhcNMzYwMTE0MTY1ODE5WjAUMRIwEAYD
+VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq
+dZ4O2upZqwIo1eK5KrW1IIsjkfsFK8hE7Llh+4axcesiUKot0ib1CUhRSYiL1DLO
+CIYQOw8IKQDVSC4JWAX9SsnX4W8dwexMQuSQG7/IKX2auC1bNNySFvoqM6Gq3GL9
+MqBFonZGXDPZu8fmxsI/2p9+2GK13F+HXgoLlXSCoO3XELJaBmjv29tgxxWRxCiH
+m4u0briSxgUEx+CctpKPvGDmLaoIOIhjtuoG6OjkeWUOp6jDcteazO23VxPyF5cS
+NbRJgm8AckrTQ6wbWSnhyqF8rPEsIc0ZAlUdDEs5fL3sjugc566FvE+GOkZIEyDD
+tgWbc4Ne+Kp/nnt6oVxpAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADv+J1DTok8V
+MKVKo0hsRnHTeJQ2+EIgOspuYlEzez3PysOZH6diAQxO2lzuo9LKxP3hnmw17XO/
+P2oCzYyb9/P58VY/gr4UDIfuhgcE0cVfdsRhVId/I2FW6VP2f5q1TGbDUxSsVIlG
+6hufn1aLBu90LtEbDkHqbnD05yYPwdqzWg4TrOXbX+jBhQrXJJdB3W7KTgozjRQw
+F7+/2IyXoxXuxcwQBQlYhUbvGlsFqFpP/6cz2al5i5pNUkiNaSYwlRmuwa7zoTft
+tHf57dhfXIpXET2BaJM6DSjDOOG/QleRXkvkTI5J21q+Bo+XnOzo19p4cZKJpTFC
+SNgrftyNh3k=
+-----END CERTIFICATE-----
+
diff --git a/test/pleroma/http/adapter_helper/hackney_test.exs b/test/pleroma/http/adapter_helper/hackney_test.exs
index 57ce4728c..343bdb800 100644
--- a/test/pleroma/http/adapter_helper/hackney_test.exs
+++ b/test/pleroma/http/adapter_helper/hackney_test.exs
@@ -16,6 +16,14 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do
describe "options/2" do
setup do: clear_config([:http, :adapter], a: 1, b: 2)
+ test "uses redirect-safe defaults", %{uri: uri} do
+ opts = Hackney.options([], uri)
+
+ assert opts[:follow_redirect] == false
+ assert opts[:force_redirect] == false
+ assert opts[:with_body] == true
+ end
+
test "add proxy and opts from config", %{uri: uri} do
opts = Hackney.options([proxy: "localhost:8123"], uri)
diff --git a/test/pleroma/http/hackney_follow_redirect_regression_test.exs b/test/pleroma/http/hackney_follow_redirect_regression_test.exs
new file mode 100644
index 000000000..71fda4479
--- /dev/null
+++ b/test/pleroma/http/hackney_follow_redirect_regression_test.exs
@@ -0,0 +1,355 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.HTTP.HackneyFollowRedirectRegressionTest do
+ use ExUnit.Case, async: false
+
+ setup do
+ {:ok, _} = Application.ensure_all_started(:hackney)
+
+ {:ok, tls_server} = start_tls_redirect_server()
+ {:ok, proxy} = start_connect_proxy()
+
+ on_exit(fn ->
+ stop_connect_proxy(proxy)
+ stop_tls_redirect_server(tls_server)
+ end)
+
+ {:ok, tls_server: tls_server, proxy: proxy}
+ end
+
+ test "hackney follow_redirect crashes behind CONNECT proxy on relative redirects", %{
+ tls_server: tls_server,
+ proxy: proxy
+ } do
+ url = "#{tls_server.base_url}/redirect"
+
+ opts = [
+ pool: :media,
+ proxy: proxy.proxy_url,
+ insecure: true,
+ connect_timeout: 1_000,
+ recv_timeout: 1_000,
+ follow_redirect: true,
+ force_redirect: true
+ ]
+
+ {pid, ref} = spawn_monitor(fn -> :hackney.request(:get, url, [], <<>>, opts) end)
+
+ assert_receive {:DOWN, ^ref, :process, ^pid, reason}, 5_000
+
+ assert match?({%FunctionClauseError{}, _}, reason) or match?(%FunctionClauseError{}, reason) or
+ match?({:function_clause, _}, reason)
+ end
+
+ test "redirects work via proxy when hackney follow_redirect is disabled", %{
+ tls_server: tls_server,
+ proxy: proxy
+ } do
+ url = "#{tls_server.base_url}/redirect"
+
+ adapter_opts = [
+ pool: :media,
+ proxy: proxy.proxy_url,
+ insecure: true,
+ connect_timeout: 1_000,
+ recv_timeout: 1_000,
+ follow_redirect: false,
+ force_redirect: false,
+ with_body: true
+ ]
+
+ client = Tesla.client([Tesla.Middleware.FollowRedirects], Tesla.Adapter.Hackney)
+
+ assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
+ Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts])
+ end
+
+ test "reverse proxy hackney client follows redirects via proxy without crashing", %{
+ tls_server: tls_server,
+ proxy: proxy
+ } do
+ url = "#{tls_server.base_url}/redirect"
+
+ opts = [
+ pool: :media,
+ proxy: proxy.proxy_url,
+ insecure: true,
+ connect_timeout: 1_000,
+ recv_timeout: 1_000,
+ follow_redirect: true
+ ]
+
+ assert {:ok, 200, _headers, ref} =
+ Pleroma.ReverseProxy.Client.Hackney.request(:get, url, [], "", opts)
+
+ assert collect_body(ref) == "ok"
+ Pleroma.ReverseProxy.Client.Hackney.close(ref)
+ end
+
+ defp collect_body(ref, acc \\ "") do
+ case Pleroma.ReverseProxy.Client.Hackney.stream_body(ref) do
+ :done -> acc
+ {:ok, data, _ref} -> collect_body(ref, acc <> data)
+ {:error, error} -> flunk("stream_body failed: #{inspect(error)}")
+ end
+ end
+
+ defp start_tls_redirect_server do
+ certfile = Path.expand("../../fixtures/server.pem", __DIR__)
+ keyfile = Path.expand("../../fixtures/private_key.pem", __DIR__)
+
+ {:ok, listener} =
+ :ssl.listen(0, [
+ :binary,
+ certfile: certfile,
+ keyfile: keyfile,
+ reuseaddr: true,
+ active: false,
+ packet: :raw,
+ ip: {127, 0, 0, 1}
+ ])
+
+ {:ok, {{127, 0, 0, 1}, port}} = :ssl.sockname(listener)
+
+ {:ok, acceptor} =
+ Task.start_link(fn ->
+ accept_tls_loop(listener)
+ end)
+
+ {:ok, %{listener: listener, acceptor: acceptor, base_url: "https://127.0.0.1:#{port}"}}
+ end
+
+ defp stop_tls_redirect_server(%{listener: listener, acceptor: acceptor}) do
+ :ok = :ssl.close(listener)
+
+ if Process.alive?(acceptor) do
+ Process.exit(acceptor, :normal)
+ end
+ end
+
+ defp accept_tls_loop(listener) do
+ case :ssl.transport_accept(listener) do
+ {:ok, socket} ->
+ _ = Task.start(fn -> serve_tls(socket) end)
+ accept_tls_loop(listener)
+
+ {:error, :closed} ->
+ :ok
+
+ {:error, _reason} ->
+ :ok
+ end
+ end
+
+ defp serve_tls(tcp_socket) do
+ with {:ok, ssl_socket} <- :ssl.handshake(tcp_socket, 2_000),
+ {:ok, data} <- recv_ssl_headers(ssl_socket),
+ {:ok, path} <- parse_path(data) do
+ case path do
+ "/redirect" ->
+ send_ssl_response(ssl_socket, 302, "Found", [{"Location", "/final"}], "")
+
+ "/final" ->
+ send_ssl_response(ssl_socket, 200, "OK", [], "ok")
+
+ _ ->
+ send_ssl_response(ssl_socket, 404, "Not Found", [], "not found")
+ end
+
+ :ssl.close(ssl_socket)
+ else
+ _ ->
+ _ = :gen_tcp.close(tcp_socket)
+ :ok
+ end
+ end
+
+ defp recv_ssl_headers(socket, acc \\ <<>>) do
+ case :ssl.recv(socket, 0, 1_000) do
+ {:ok, data} ->
+ acc = acc <> data
+
+ if :binary.match(acc, "\r\n\r\n") != :nomatch do
+ {:ok, acc}
+ else
+ if byte_size(acc) > 8_192 do
+ {:error, :too_large}
+ else
+ recv_ssl_headers(socket, acc)
+ end
+ end
+
+ {:error, _} = error ->
+ error
+ end
+ end
+
+ defp send_ssl_response(socket, status, reason, headers, body) do
+ base_headers =
+ [
+ {"Content-Length", Integer.to_string(byte_size(body))},
+ {"Connection", "close"}
+ ] ++ headers
+
+ iodata =
+ [
+ "HTTP/1.1 ",
+ Integer.to_string(status),
+ " ",
+ reason,
+ "\r\n",
+ Enum.map(base_headers, fn {k, v} -> [k, ": ", v, "\r\n"] end),
+ "\r\n",
+ body
+ ]
+
+ :ssl.send(socket, iodata)
+ end
+
+ defp start_connect_proxy do
+ {:ok, listener} =
+ :gen_tcp.listen(0, [
+ :binary,
+ active: false,
+ packet: :raw,
+ reuseaddr: true,
+ ip: {127, 0, 0, 1}
+ ])
+
+ {:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(listener)
+
+ {:ok, acceptor} =
+ Task.start_link(fn ->
+ accept_proxy_loop(listener)
+ end)
+
+ {:ok, %{listener: listener, acceptor: acceptor, proxy_url: "127.0.0.1:#{port}"}}
+ end
+
+ defp stop_connect_proxy(%{listener: listener, acceptor: acceptor}) do
+ :ok = :gen_tcp.close(listener)
+
+ if Process.alive?(acceptor) do
+ Process.exit(acceptor, :normal)
+ end
+ end
+
+ defp accept_proxy_loop(listener) do
+ case :gen_tcp.accept(listener) do
+ {:ok, socket} ->
+ _ = Task.start(fn -> serve_proxy(socket) end)
+ accept_proxy_loop(listener)
+
+ {:error, :closed} ->
+ :ok
+
+ {:error, _reason} ->
+ :ok
+ end
+ end
+
+ defp serve_proxy(client_socket) do
+ with {:ok, {headers, rest}} <- recv_tcp_headers(client_socket),
+ {:ok, {host, port}} <- parse_connect(headers),
+ {:ok, upstream_socket} <- connect_upstream(host, port) do
+ :gen_tcp.send(client_socket, "HTTP/1.1 200 Connection established\r\n\r\n")
+
+ if rest != <<>> do
+ :gen_tcp.send(upstream_socket, rest)
+ end
+
+ tunnel(client_socket, upstream_socket)
+ else
+ _ ->
+ :gen_tcp.close(client_socket)
+ :ok
+ end
+ end
+
+ defp tunnel(client_socket, upstream_socket) do
+ parent = self()
+ _ = spawn_link(fn -> forward(client_socket, upstream_socket, parent) end)
+ _ = spawn_link(fn -> forward(upstream_socket, client_socket, parent) end)
+
+ receive do
+ :tunnel_closed -> :ok
+ after
+ 10_000 -> :ok
+ end
+
+ :gen_tcp.close(client_socket)
+ :gen_tcp.close(upstream_socket)
+ end
+
+ defp forward(from_socket, to_socket, parent) do
+ case :gen_tcp.recv(from_socket, 0, 10_000) do
+ {:ok, data} ->
+ _ = :gen_tcp.send(to_socket, data)
+ forward(from_socket, to_socket, parent)
+
+ {:error, _reason} ->
+ send(parent, :tunnel_closed)
+ :ok
+ end
+ end
+
+ defp recv_tcp_headers(socket, acc \\ <<>>) do
+ case :gen_tcp.recv(socket, 0, 1_000) do
+ {:ok, data} ->
+ acc = acc <> data
+
+ case :binary.match(acc, "\r\n\r\n") do
+ :nomatch ->
+ if byte_size(acc) > 8_192 do
+ {:error, :too_large}
+ else
+ recv_tcp_headers(socket, acc)
+ end
+
+ {idx, _len} ->
+ split_at = idx + 4
+ <> = acc
+ {:ok, {headers, rest}}
+ end
+
+ {:error, _} = error ->
+ error
+ end
+ end
+
+ defp parse_connect(data) do
+ with [request_line | _] <- String.split(data, "\r\n", trim: true),
+ ["CONNECT", hostport | _] <- String.split(request_line, " ", parts: 3),
+ [host, port_str] <- String.split(hostport, ":", parts: 2),
+ {port, ""} <- Integer.parse(port_str) do
+ {:ok, {host, port}}
+ else
+ _ -> {:error, :invalid_connect}
+ end
+ end
+
+ defp connect_upstream(host, port) do
+ address =
+ case :inet.parse_address(String.to_charlist(host)) do
+ {:ok, ip} -> ip
+ {:error, _} -> String.to_charlist(host)
+ end
+
+ :gen_tcp.connect(address, port, [:binary, active: false, packet: :raw], 1_000)
+ end
+
+ defp parse_path(data) do
+ case String.split(data, "\r\n", parts: 2) do
+ [request_line | _] ->
+ case String.split(request_line, " ") do
+ [_method, path, _protocol] -> {:ok, path}
+ _ -> {:error, :invalid_request}
+ end
+
+ _ ->
+ {:error, :invalid_request}
+ end
+ end
+end
diff --git a/test/pleroma/http/hackney_redirect_regression_test.exs b/test/pleroma/http/hackney_redirect_regression_test.exs
new file mode 100644
index 000000000..61389aa7e
--- /dev/null
+++ b/test/pleroma/http/hackney_redirect_regression_test.exs
@@ -0,0 +1,151 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.HTTP.HackneyRedirectRegressionTest do
+ use ExUnit.Case, async: false
+
+ alias Pleroma.HTTP.AdapterHelper.Hackney, as: HackneyAdapterHelper
+
+ setup do
+ {:ok, _} = Application.ensure_all_started(:hackney)
+
+ {:ok, server} = start_server()
+ on_exit(fn -> stop_server(server) end)
+
+ {:ok, server: server}
+ end
+
+ test "pooled redirects work with follow_redirect disabled", %{server: server} do
+ url = "#{server.base_url}/redirect"
+ uri = URI.parse(url)
+
+ adapter_opts =
+ HackneyAdapterHelper.options(
+ [pool: :media, follow_redirect: false, no_proxy_env: true],
+ uri
+ )
+
+ client = Tesla.client([Tesla.Middleware.FollowRedirects], Tesla.Adapter.Hackney)
+
+ assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
+ Tesla.request(client, method: :get, url: url, opts: [adapter: adapter_opts])
+ end
+
+ defp start_server do
+ {:ok, listener} =
+ :gen_tcp.listen(0, [
+ :binary,
+ active: false,
+ packet: :raw,
+ reuseaddr: true,
+ ip: {127, 0, 0, 1}
+ ])
+
+ {:ok, {{127, 0, 0, 1}, port}} = :inet.sockname(listener)
+
+ {:ok, acceptor} =
+ Task.start_link(fn ->
+ accept_loop(listener)
+ end)
+
+ {:ok, %{listener: listener, acceptor: acceptor, base_url: "http://127.0.0.1:#{port}"}}
+ end
+
+ defp stop_server(%{listener: listener, acceptor: acceptor}) do
+ :ok = :gen_tcp.close(listener)
+
+ if Process.alive?(acceptor) do
+ Process.exit(acceptor, :normal)
+ end
+ end
+
+ defp accept_loop(listener) do
+ case :gen_tcp.accept(listener) do
+ {:ok, socket} ->
+ serve(socket)
+ accept_loop(listener)
+
+ {:error, :closed} ->
+ :ok
+
+ {:error, _reason} ->
+ :ok
+ end
+ end
+
+ defp serve(socket) do
+ with {:ok, data} <- recv_headers(socket),
+ {:ok, path} <- parse_path(data) do
+ case path do
+ "/redirect" ->
+ send_response(socket, 302, "Found", [{"Location", "/final"}], "")
+
+ "/final" ->
+ send_response(socket, 200, "OK", [], "ok")
+
+ _ ->
+ send_response(socket, 404, "Not Found", [], "not found")
+ end
+ else
+ _ -> :ok
+ end
+
+ :gen_tcp.close(socket)
+ end
+
+ defp recv_headers(socket, acc \\ <<>>) do
+ case :gen_tcp.recv(socket, 0, 1_000) do
+ {:ok, data} ->
+ acc = acc <> data
+
+ if :binary.match(acc, "\r\n\r\n") != :nomatch do
+ {:ok, acc}
+ else
+ if byte_size(acc) > 8_192 do
+ {:error, :too_large}
+ else
+ recv_headers(socket, acc)
+ end
+ end
+
+ {:error, _} = error ->
+ error
+ end
+ end
+
+ defp parse_path(data) do
+ case String.split(data, "\r\n", parts: 2) do
+ [request_line | _] ->
+ case String.split(request_line, " ") do
+ [_method, path, _protocol] -> {:ok, path}
+ _ -> {:error, :invalid_request}
+ end
+
+ _ ->
+ {:error, :invalid_request}
+ end
+ end
+
+ defp send_response(socket, status, reason, headers, body) do
+ base_headers =
+ [
+ {"Content-Length", Integer.to_string(byte_size(body))},
+ {"Connection", "close"}
+ ] ++ headers
+
+ iodata =
+ [
+ "HTTP/1.1 ",
+ Integer.to_string(status),
+ " ",
+ reason,
+ "\r\n",
+ Enum.map(base_headers, fn {k, v} -> [k, ": ", v, "\r\n"] end),
+ "\r\n",
+ body
+ ]
+
+ :gen_tcp.send(socket, iodata)
+ end
+end
diff --git a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs
index 0da3afa3b..4b94b9ac7 100644
--- a/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs
+++ b/test/pleroma/web/activity_pub/mrf/media_proxy_warming_policy_test.exs
@@ -54,14 +54,17 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
setup do: clear_config([:media_proxy, :enabled], true)
test "it prefetches media proxy URIs" do
- Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} ->
- {:ok, %Tesla.Env{status: 200, body: ""}}
- end)
-
- with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
+ with_mock HTTP,
+ get: fn _, _, opts ->
+ send(self(), {:prefetch_opts, opts})
+ {:ok, []}
+ end do
MediaProxyWarmingPolicy.filter(@message)
assert called(HTTP.get(:_, :_, :_))
+ assert_receive {:prefetch_opts, opts}
+ refute Keyword.has_key?(opts, :follow_redirect)
+ refute Keyword.has_key?(opts, :force_redirect)
end
end
@@ -81,10 +84,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
end
test "history-aware" do
- Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} ->
- {:ok, %Tesla.Env{status: 200, body: ""}}
- end)
-
with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history)
@@ -93,10 +92,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
end
test "works with Updates" do
- Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} ->
- {:ok, %Tesla.Env{status: 200, body: ""}}
- end)
-
with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history |> Map.put("type", "Update"))