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/hackney_follow_redirect_regression_test.exs b/test/pleroma/http/hackney_follow_redirect_regression_test.exs
new file mode 100644
index 000000000..ea631024c
--- /dev/null
+++ b/test/pleroma/http/hackney_follow_redirect_regression_test.exs
@@ -0,0 +1,325 @@
+# 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
+
+ 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