From d090aeb2bb3398871f730981a49e52627ed0a635 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Wed, 22 Jul 2026 20:35:51 +0400 Subject: [PATCH] fix(http): forward plain HTTP proxy requests --- DEVLOG.md | 1 + changelog.d/gun-pool-rework.fix | 2 +- docs/configuration/howto_proxy.md | 1 + lib/pleroma/gun/conn.ex | 21 +++- lib/pleroma/gun/connection_pool.ex | 2 +- lib/pleroma/http/adapter/gun.ex | 39 +++++++- test/pleroma/gun/conn_test.exs | 35 +++++++ test/pleroma/http/gun_integration_test.exs | 111 ++++++++++++++++++++- 8 files changed, 201 insertions(+), 11 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 25f07171f..1e5d2b63d 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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. diff --git a/changelog.d/gun-pool-rework.fix b/changelog.d/gun-pool-rework.fix index b7d9adccf..d555b5bce 100644 --- a/changelog.d/gun-pool-rework.fix +++ b/changelog.d/gun-pool-rework.fix @@ -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. diff --git a/docs/configuration/howto_proxy.md b/docs/configuration/howto_proxy.md index 9e571d6fc..d805ff965 100644 --- a/docs/configuration/howto_proxy.md +++ b/docs/configuration/howto_proxy.md @@ -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. diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index f56a1af2f..85a2fb0e8 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -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 diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex index f2620836e..5e4fc5c3f 100644 --- a/lib/pleroma/gun/connection_pool.ex +++ b/lib/pleroma/gun/connection_pool.ex @@ -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"]} diff --git a/lib/pleroma/http/adapter/gun.ex b/lib/pleroma/http/adapter/gun.ex index 61d297101..dea1798a1 100644 --- a/lib/pleroma/http/adapter/gun.ex +++ b/lib/pleroma/http/adapter/gun.ex @@ -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 diff --git a/test/pleroma/gun/conn_test.exs b/test/pleroma/gun/conn_test.exs index 79ea7b33f..a38740a42 100644 --- a/test/pleroma/gun/conn_test.exs +++ b/test/pleroma/gun/conn_test.exs @@ -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"), diff --git a/test/pleroma/http/gun_integration_test.exs b/test/pleroma/http/gun_integration_test.exs index f50f8105e..9282e2e6c 100644 --- a/test/pleroma/http/gun_integration_test.exs +++ b/test/pleroma/http/gun_integration_test.exs @@ -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),