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.
|
- 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.
|
- 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.
|
- 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.
|
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)
|
Map.put(opts, :tls_opts, tls_opts)
|
||||||
end
|
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
|
defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do
|
||||||
connect_opts =
|
connect_opts =
|
||||||
uri
|
uri
|
||||||
|
|
@ -174,12 +189,12 @@ defmodule Pleroma.Gun.Conn do
|
||||||
error -> error
|
error -> error
|
||||||
end
|
end
|
||||||
|
|
||||||
{:response, _fin, 403, _headers} ->
|
|
||||||
close_with_error(conn, :unauthorized)
|
|
||||||
|
|
||||||
{:response, _fin, 407, _headers} ->
|
{:response, _fin, 407, _headers} ->
|
||||||
close_with_error(conn, :proxy_auth_failed)
|
close_with_error(conn, :proxy_auth_failed)
|
||||||
|
|
||||||
|
{:response, _fin, status, _headers} when is_integer(status) ->
|
||||||
|
close_with_error(conn, {:proxy_connect_failed, status})
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
Gun.close(conn)
|
Gun.close(conn)
|
||||||
error
|
error
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ defmodule Pleroma.Gun.ConnectionPool do
|
||||||
call_worker(conn_pid, {:release_stream, stream})
|
call_worker(conn_pid, {:release_stream, stream})
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec tunnel_ref(pid()) :: reference() | nil
|
@spec tunnel_ref(pid()) :: reference() | :forward_proxy | nil
|
||||||
def tunnel_ref(conn_pid) do
|
def tunnel_ref(conn_pid) do
|
||||||
case Registry.select(@registry, [
|
case Registry.select(@registry, [
|
||||||
{{:_, :_, {:"$1", :"$2"}}, [{:==, :"$1", conn_pid}], [:"$2"]}
|
{{:_, :_, {:"$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
|
defp request(env, %{conn: conn, tunnel: tunnel} = opts) do
|
||||||
uri = URI.parse(Tesla.build_url(env))
|
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)
|
method = Tesla.Adapter.Shared.format_method(env.method)
|
||||||
{headers, body, send_body} = prepare_body(env.headers, env.body, opts[:send_body])
|
{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)
|
request_opts = request_opts(opts[:reply_to], tunnel)
|
||||||
|
|
||||||
stream =
|
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, 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
|
defp request_opts(reply_to, tunnel) do
|
||||||
%{reply_to: reply_to || self(), tunnel: tunnel}
|
%{reply_to: reply_to || self(), tunnel: tunnel}
|
||||||
end
|
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
|
defp prepare_body(headers, %Multipart{} = multipart, _send_body) do
|
||||||
{format_headers(headers ++ Multipart.headers(multipart)), Multipart.body(multipart), :stream}
|
{format_headers(headers ++ Multipart.headers(multipart)), Multipart.body(multipart), :stream}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,26 @@ defmodule Pleroma.Gun.ConnTest do
|
||||||
stop_conn(conn)
|
stop_conn(conn)
|
||||||
end
|
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
|
test "passes authentication and remote DNS destination to a SOCKS5 proxy" do
|
||||||
conn = spawn_link(fn -> Process.sleep(:infinity) end)
|
conn = spawn_link(fn -> Process.sleep(:infinity) end)
|
||||||
|
|
||||||
|
|
@ -130,6 +150,21 @@ defmodule Pleroma.Gun.ConnTest do
|
||||||
)
|
)
|
||||||
end
|
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
|
test "rejects SOCKS4 before opening a socket" do
|
||||||
assert {:error, :socks4_unsupported} =
|
assert {:error, :socks4_unsupported} =
|
||||||
Conn.open(URI.parse("https://origin.example/inbox"),
|
Conn.open(URI.parse("https://origin.example/inbox"),
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,22 @@ defmodule Pleroma.HTTP.GunIntegrationTest do
|
||||||
|
|
||||||
origin = start_tls_origin(self())
|
origin = start_tls_origin(self())
|
||||||
connect_proxy = start_connect_proxy(self())
|
connect_proxy = start_connect_proxy(self())
|
||||||
|
forward_proxy = start_forward_proxy(self())
|
||||||
socks_proxy = start_socks5_proxy(self())
|
socks_proxy = start_socks5_proxy(self())
|
||||||
|
|
||||||
on_exit(fn ->
|
on_exit(fn ->
|
||||||
Process.sleep(20)
|
Process.sleep(20)
|
||||||
stop_server(socks_proxy)
|
stop_server(socks_proxy)
|
||||||
|
stop_server(forward_proxy)
|
||||||
stop_server(connect_proxy)
|
stop_server(connect_proxy)
|
||||||
stop_server(origin)
|
stop_server(origin)
|
||||||
end)
|
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
|
end
|
||||||
|
|
||||||
test "requests HTTPS directly on a non-standard port", %{origin: origin} do
|
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
|
assert {:ok, %Tesla.Env{status: 200, body: "ok"}} = result
|
||||||
end
|
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", %{
|
test "opens a request-ready authenticated CONNECT tunnel", %{
|
||||||
origin: origin,
|
origin: origin,
|
||||||
connect_proxy: proxy
|
connect_proxy: proxy
|
||||||
|
|
@ -215,6 +243,32 @@ defmodule Pleroma.HTTP.GunIntegrationTest do
|
||||||
assert origin_port == origin.port
|
assert origin_port == origin.port
|
||||||
end
|
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
|
defp request(url, proxy_opts \\ [], follow_redirects \\ false) do
|
||||||
middleware =
|
middleware =
|
||||||
if follow_redirects do
|
if follow_redirects do
|
||||||
|
|
@ -433,6 +487,37 @@ defmodule Pleroma.HTTP.GunIntegrationTest do
|
||||||
end
|
end
|
||||||
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
|
defp start_socks5_proxy(parent) do
|
||||||
start_tcp_server(fn socket -> serve_socks5_proxy(socket, parent) end)
|
start_tcp_server(fn socket -> serve_socks5_proxy(socket, parent) end)
|
||||||
end
|
end
|
||||||
|
|
@ -440,10 +525,7 @@ defmodule Pleroma.HTTP.GunIntegrationTest do
|
||||||
defp serve_socks5_proxy(socket, parent) do
|
defp serve_socks5_proxy(socket, parent) do
|
||||||
with {:ok, <<5, method_count>>} <- :gen_tcp.recv(socket, 2, 2_000),
|
with {:ok, <<5, method_count>>} <- :gen_tcp.recv(socket, 2, 2_000),
|
||||||
{:ok, methods} <- :gen_tcp.recv(socket, method_count, 2_000),
|
{:ok, methods} <- :gen_tcp.recv(socket, method_count, 2_000),
|
||||||
true <- :binary.match(methods, <<2>>) != :nomatch,
|
{:ok, username, password} <- negotiate_socks5_auth(socket, methods),
|
||||||
:ok <- :gen_tcp.send(socket, <<5, 2>>),
|
|
||||||
{:ok, username, password} <- recv_socks5_auth(socket),
|
|
||||||
:ok <- :gen_tcp.send(socket, <<1, 0>>),
|
|
||||||
{:ok, host, port} <- recv_socks5_destination(socket),
|
{:ok, host, port} <- recv_socks5_destination(socket),
|
||||||
{:ok, upstream} <- tcp_connect(host, port),
|
{:ok, upstream} <- tcp_connect(host, port),
|
||||||
:ok <- :gen_tcp.send(socket, <<5, 0, 0, 1, 0, 0, 0, 0, 0, 0>>) do
|
: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
|
||||||
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
|
defp recv_socks5_auth(socket) do
|
||||||
with {:ok, <<1, username_length>>} <- :gen_tcp.recv(socket, 2, 2_000),
|
with {:ok, <<1, username_length>>} <- :gen_tcp.recv(socket, 2, 2_000),
|
||||||
{:ok, username} <- :gen_tcp.recv(socket, username_length, 2_000),
|
{:ok, username} <- :gen_tcp.recv(socket, username_length, 2_000),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue