fix(http): forward plain HTTP proxy requests
This commit is contained in:
parent
b862254889
commit
d090aeb2bb
8 changed files with 201 additions and 11 deletions
|
|
@ -11,3 +11,4 @@
|
|||
- Gun reports a stale stream error to the process that calls `cancel/2`; pool workers must consume that reply so one HTTP/2 cancellation cannot terminate other multiplexed requests.
|
||||
- Streamed uploads must be registered before body enumeration and cancelled if enumeration exits, throws, or raises, otherwise a partial HTTP/1 request can remain reusable.
|
||||
- Gun 2.4 bounds HTTP/1 response header and trailer buffers, rejects invalid request headers by default, and fixes cross-origin HTTP/2 push handling; Gun 2.4.1 includes Cowlib 2.17.1 to repair HTTP/2 HPACK decoding.
|
||||
- Plain HTTP proxy requests must use absolute-form request targets; CONNECT is reserved for HTTPS tunnels because forwarding proxies commonly reject CONNECT to port 80.
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Fixed Gun HTTP/1 concurrency, authenticated proxy handling, tunnel requests, and streamed connection cleanup. Updated Gun and Cowlib for HTTP client security fixes.
|
||||
Fixed Gun HTTP/1 concurrency, forwarding and authenticated proxy handling, tunnel requests, and streamed connection cleanup. Updated Gun and Cowlib for HTTP client security fixes.
|
||||
|
|
|
|||
|
|
@ -12,3 +12,4 @@ config :pleroma, :http, proxy_url: {:socks5, :localhost, 9050}
|
|||
```
|
||||
|
||||
The Gun HTTP adapter supports SOCKS5. SOCKS4 proxies require another HTTP adapter.
|
||||
For regular HTTP proxies, Gun forwards plain HTTP requests directly and uses CONNECT tunnels for HTTPS requests.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,21 @@ defmodule Pleroma.Gun.Conn do
|
|||
Map.put(opts, :tls_opts, tls_opts)
|
||||
end
|
||||
|
||||
defp do_open(%URI{scheme: "http"} = uri, %{proxy: {proxy_host, proxy_port}} = opts) do
|
||||
with open_opts <- opts |> proxy_open_opts() |> Map.put(:protocols, [:http]),
|
||||
{:ok, conn} <- Gun.open(proxy_host, proxy_port, open_opts),
|
||||
{:ok, protocol} <- await_up(conn, opts[:connect_timeout]) do
|
||||
{:ok, conn, protocol, :forward_proxy}
|
||||
else
|
||||
error ->
|
||||
Logger.warning(
|
||||
"Opening forward proxy connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}"
|
||||
)
|
||||
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do
|
||||
connect_opts =
|
||||
uri
|
||||
|
|
@ -174,12 +189,12 @@ defmodule Pleroma.Gun.Conn do
|
|||
error -> error
|
||||
end
|
||||
|
||||
{:response, _fin, 403, _headers} ->
|
||||
close_with_error(conn, :unauthorized)
|
||||
|
||||
{:response, _fin, 407, _headers} ->
|
||||
close_with_error(conn, :proxy_auth_failed)
|
||||
|
||||
{:response, _fin, status, _headers} when is_integer(status) ->
|
||||
close_with_error(conn, {:proxy_connect_failed, status})
|
||||
|
||||
error ->
|
||||
Gun.close(conn)
|
||||
error
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ defmodule Pleroma.Gun.ConnectionPool do
|
|||
call_worker(conn_pid, {:release_stream, stream})
|
||||
end
|
||||
|
||||
@spec tunnel_ref(pid()) :: reference() | nil
|
||||
@spec tunnel_ref(pid()) :: reference() | :forward_proxy | nil
|
||||
def tunnel_ref(conn_pid) do
|
||||
case Registry.select(@registry, [
|
||||
{{:_, :_, {:"$1", :"$2"}}, [{:==, :"$1", conn_pid}], [:"$2"]}
|
||||
|
|
|
|||
|
|
@ -34,9 +34,10 @@ defmodule Pleroma.HTTP.Adapter.Gun do
|
|||
|
||||
defp request(env, %{conn: conn, tunnel: tunnel} = opts) do
|
||||
uri = URI.parse(Tesla.build_url(env))
|
||||
path = Tesla.Adapter.Shared.prepare_path(uri.path, uri.query)
|
||||
path = request_path(uri, tunnel)
|
||||
method = Tesla.Adapter.Shared.format_method(env.method)
|
||||
{headers, body, send_body} = prepare_body(env.headers, env.body, opts[:send_body])
|
||||
headers = maybe_add_forward_proxy_headers(headers, uri, opts, tunnel)
|
||||
request_opts = request_opts(opts[:reply_to], tunnel)
|
||||
|
||||
stream =
|
||||
|
|
@ -76,10 +77,46 @@ defmodule Pleroma.HTTP.Adapter.Gun do
|
|||
|
||||
defp request_opts(reply_to, nil), do: %{reply_to: reply_to || self()}
|
||||
|
||||
defp request_opts(reply_to, :forward_proxy), do: %{reply_to: reply_to || self()}
|
||||
|
||||
defp request_opts(reply_to, tunnel) do
|
||||
%{reply_to: reply_to || self(), tunnel: tunnel}
|
||||
end
|
||||
|
||||
defp request_path(uri, :forward_proxy) do
|
||||
path = Tesla.Adapter.Shared.prepare_path(uri.path, uri.query)
|
||||
"#{uri.scheme}://#{authority(uri)}#{path}"
|
||||
end
|
||||
|
||||
defp request_path(uri, _tunnel) do
|
||||
Tesla.Adapter.Shared.prepare_path(uri.path, uri.query)
|
||||
end
|
||||
|
||||
defp maybe_add_forward_proxy_headers(headers, uri, opts, :forward_proxy) do
|
||||
headers
|
||||
|> put_header_new("host", authority(uri))
|
||||
|> maybe_put_proxy_authorization(opts)
|
||||
end
|
||||
|
||||
defp maybe_add_forward_proxy_headers(headers, _uri, _opts, _tunnel), do: headers
|
||||
|
||||
defp maybe_put_proxy_authorization(headers, %{proxy_auth: {username, password}})
|
||||
when is_binary(username) and is_binary(password) do
|
||||
value = "Basic " <> Base.encode64(username <> ":" <> password)
|
||||
List.keystore(headers, "proxy-authorization", 0, {"proxy-authorization", value})
|
||||
end
|
||||
|
||||
defp maybe_put_proxy_authorization(headers, _opts), do: headers
|
||||
|
||||
defp put_header_new(headers, name, value) do
|
||||
if List.keymember?(headers, name, 0), do: headers, else: [{name, value} | headers]
|
||||
end
|
||||
|
||||
defp authority(%URI{scheme: scheme, host: host, port: port}) do
|
||||
host = if String.contains?(host, ":"), do: "[#{host}]", else: host
|
||||
if port == URI.default_port(scheme), do: host, else: "#{host}:#{port}"
|
||||
end
|
||||
|
||||
defp prepare_body(headers, %Multipart{} = multipart, _send_body) do
|
||||
{format_headers(headers ++ Multipart.headers(multipart)), Multipart.body(multipart), :stream}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -88,6 +88,26 @@ defmodule Pleroma.Gun.ConnTest do
|
|||
stop_conn(conn)
|
||||
end
|
||||
|
||||
test "uses HTTP/1 for a TLS forwarding proxy" do
|
||||
conn = spawn_link(fn -> Process.sleep(:infinity) end)
|
||||
|
||||
expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 8443, opts ->
|
||||
assert opts.transport == :tls
|
||||
assert opts.tls_opts == [verify: :verify_none]
|
||||
assert opts.protocols == [:http]
|
||||
{:ok, conn}
|
||||
end)
|
||||
|
||||
assert {:ok, ^conn, :http, :forward_proxy} =
|
||||
Conn.open(URI.parse("http://origin.example/inbox"),
|
||||
proxy: {~c"proxy.example", 8443},
|
||||
transport: :tls,
|
||||
proxy_tls_opts: [verify: :verify_none]
|
||||
)
|
||||
|
||||
stop_conn(conn)
|
||||
end
|
||||
|
||||
test "passes authentication and remote DNS destination to a SOCKS5 proxy" do
|
||||
conn = spawn_link(fn -> Process.sleep(:infinity) end)
|
||||
|
||||
|
|
@ -130,6 +150,21 @@ defmodule Pleroma.Gun.ConnTest do
|
|||
)
|
||||
end
|
||||
|
||||
test "returns the proxy status when CONNECT is forbidden" do
|
||||
conn = spawn_link(fn -> Process.sleep(:infinity) end)
|
||||
stream = make_ref()
|
||||
|
||||
expect(Pleroma.GunMock, :open, fn _, _, _ -> {:ok, conn} end)
|
||||
expect(Pleroma.GunMock, :connect, fn ^conn, _ -> stream end)
|
||||
expect(Pleroma.GunMock, :await, fn ^conn, ^stream -> {:response, :fin, 403, []} end)
|
||||
expect(Pleroma.GunMock, :close, fn ^conn -> Process.exit(conn, :normal) end)
|
||||
|
||||
assert {:error, {:proxy_connect_failed, 403}} =
|
||||
Conn.open(URI.parse("https://origin.example/inbox"),
|
||||
proxy: {~c"proxy.example", 8080}
|
||||
)
|
||||
end
|
||||
|
||||
test "rejects SOCKS4 before opening a socket" do
|
||||
assert {:error, :socks4_unsupported} =
|
||||
Conn.open(URI.parse("https://origin.example/inbox"),
|
||||
|
|
|
|||
|
|
@ -14,16 +14,22 @@ defmodule Pleroma.HTTP.GunIntegrationTest do
|
|||
|
||||
origin = start_tls_origin(self())
|
||||
connect_proxy = start_connect_proxy(self())
|
||||
forward_proxy = start_forward_proxy(self())
|
||||
socks_proxy = start_socks5_proxy(self())
|
||||
|
||||
on_exit(fn ->
|
||||
Process.sleep(20)
|
||||
stop_server(socks_proxy)
|
||||
stop_server(forward_proxy)
|
||||
stop_server(connect_proxy)
|
||||
stop_server(origin)
|
||||
end)
|
||||
|
||||
{:ok, origin: origin, connect_proxy: connect_proxy, socks_proxy: socks_proxy}
|
||||
{:ok,
|
||||
origin: origin,
|
||||
connect_proxy: connect_proxy,
|
||||
forward_proxy: forward_proxy,
|
||||
socks_proxy: socks_proxy}
|
||||
end
|
||||
|
||||
test "requests HTTPS directly on a non-standard port", %{origin: origin} do
|
||||
|
|
@ -66,6 +72,28 @@ defmodule Pleroma.HTTP.GunIntegrationTest do
|
|||
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = result
|
||||
end
|
||||
|
||||
test "requests HTTP through a forwarding proxy", %{forward_proxy: proxy} do
|
||||
proxy_opts = [proxy: {~c"127.0.0.1", proxy.port}]
|
||||
url = "http://origin.example:8080/final?key=value"
|
||||
|
||||
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(url, proxy_opts)
|
||||
|
||||
assert_receive {:forward_proxy, ^url, "origin.example:8080", nil}
|
||||
end
|
||||
|
||||
test "authenticates HTTP requests to a forwarding proxy", %{forward_proxy: proxy} do
|
||||
proxy_opts = [
|
||||
proxy: {~c"127.0.0.1", proxy.port},
|
||||
proxy_auth: {"alice", "secret"}
|
||||
]
|
||||
|
||||
url = "http://origin.example/final"
|
||||
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = request(url, proxy_opts)
|
||||
|
||||
expected = "Basic " <> Base.encode64("alice:secret")
|
||||
assert_receive {:forward_proxy, ^url, "origin.example", ^expected}
|
||||
end
|
||||
|
||||
test "opens a request-ready authenticated CONNECT tunnel", %{
|
||||
origin: origin,
|
||||
connect_proxy: proxy
|
||||
|
|
@ -215,6 +243,32 @@ defmodule Pleroma.HTTP.GunIntegrationTest do
|
|||
assert origin_port == origin.port
|
||||
end
|
||||
|
||||
test "requests through unauthenticated SOCKS5 with an IP proxy host", %{
|
||||
origin: origin,
|
||||
socks_proxy: proxy
|
||||
} do
|
||||
proxy_opts = [proxy: {:socks5, {127, 0, 0, 1}, proxy.port}]
|
||||
|
||||
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
|
||||
request(origin.base_url <> "/final", proxy_opts)
|
||||
|
||||
assert_receive {:socks5_proxy, nil, nil, "127.0.0.1", origin_port}
|
||||
assert origin_port == origin.port
|
||||
end
|
||||
|
||||
test "requests through unauthenticated SOCKS5 with a localhost proxy host", %{
|
||||
origin: origin,
|
||||
socks_proxy: proxy
|
||||
} do
|
||||
proxy_opts = [proxy: {:socks5, ~c"localhost", proxy.port}]
|
||||
|
||||
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} =
|
||||
request(origin.base_url <> "/final", proxy_opts)
|
||||
|
||||
assert_receive {:socks5_proxy, nil, nil, "127.0.0.1", origin_port}
|
||||
assert origin_port == origin.port
|
||||
end
|
||||
|
||||
defp request(url, proxy_opts \\ [], follow_redirects \\ false) do
|
||||
middleware =
|
||||
if follow_redirects do
|
||||
|
|
@ -433,6 +487,37 @@ defmodule Pleroma.HTTP.GunIntegrationTest do
|
|||
end
|
||||
end
|
||||
|
||||
defp start_forward_proxy(parent) do
|
||||
start_tcp_server(fn socket -> serve_forward_proxy(socket, parent) end)
|
||||
end
|
||||
|
||||
defp serve_forward_proxy(socket, parent) do
|
||||
with {:ok, request} <- recv_headers(:gen_tcp, socket) do
|
||||
{headers, _buffered_body} = split_headers(request)
|
||||
target = request_path(headers)
|
||||
host = header(headers, "host")
|
||||
auth = header(headers, "proxy-authorization")
|
||||
|
||||
if String.starts_with?(target, "http://") do
|
||||
send(parent, {:forward_proxy, target, host, auth})
|
||||
|
||||
:gen_tcp.send(
|
||||
socket,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok"
|
||||
)
|
||||
|
||||
serve_forward_proxy(socket, parent)
|
||||
else
|
||||
:gen_tcp.send(
|
||||
socket,
|
||||
"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
|
||||
:gen_tcp.close(socket)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp start_socks5_proxy(parent) do
|
||||
start_tcp_server(fn socket -> serve_socks5_proxy(socket, parent) end)
|
||||
end
|
||||
|
|
@ -440,10 +525,7 @@ defmodule Pleroma.HTTP.GunIntegrationTest do
|
|||
defp serve_socks5_proxy(socket, parent) do
|
||||
with {:ok, <<5, method_count>>} <- :gen_tcp.recv(socket, 2, 2_000),
|
||||
{:ok, methods} <- :gen_tcp.recv(socket, method_count, 2_000),
|
||||
true <- :binary.match(methods, <<2>>) != :nomatch,
|
||||
:ok <- :gen_tcp.send(socket, <<5, 2>>),
|
||||
{:ok, username, password} <- recv_socks5_auth(socket),
|
||||
:ok <- :gen_tcp.send(socket, <<1, 0>>),
|
||||
{:ok, username, password} <- negotiate_socks5_auth(socket, methods),
|
||||
{:ok, host, port} <- recv_socks5_destination(socket),
|
||||
{:ok, upstream} <- tcp_connect(host, port),
|
||||
:ok <- :gen_tcp.send(socket, <<5, 0, 0, 1, 0, 0, 0, 0, 0, 0>>) do
|
||||
|
|
@ -455,6 +537,25 @@ defmodule Pleroma.HTTP.GunIntegrationTest do
|
|||
end
|
||||
end
|
||||
|
||||
defp negotiate_socks5_auth(socket, methods) do
|
||||
cond do
|
||||
:binary.match(methods, <<2>>) != :nomatch ->
|
||||
with :ok <- :gen_tcp.send(socket, <<5, 2>>),
|
||||
{:ok, username, password} <- recv_socks5_auth(socket),
|
||||
:ok <- :gen_tcp.send(socket, <<1, 0>>) do
|
||||
{:ok, username, password}
|
||||
end
|
||||
|
||||
:binary.match(methods, <<0>>) != :nomatch ->
|
||||
with :ok <- :gen_tcp.send(socket, <<5, 0>>) do
|
||||
{:ok, nil, nil}
|
||||
end
|
||||
|
||||
true ->
|
||||
{:error, :unsupported_auth}
|
||||
end
|
||||
end
|
||||
|
||||
defp recv_socks5_auth(socket) do
|
||||
with {:ok, <<1, username_length>>} <- :gen_tcp.recv(socket, 2, 2_000),
|
||||
{:ok, username} <- :gen_tcp.recv(socket, username_length, 2_000),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue