From 6af343363af721a5fe0474c79f8e7285b740fb2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 19 Jul 2026 18:29:42 +0200 Subject: [PATCH 1/2] Ignore content-length and transfer-encoding in 2xx responses to CONNECT RFC 9110 (section 9.3.6) forbids these header fields in a successful response to CONNECT and requires clients to ignore them when present. The HTTP/1 parser treated them as regular body framing, so a proxy replying to CONNECT with "200" plus "content-length: 0" - which some real proxies send - emitted {:done, ref} as a body response, and Mint.TunnelProxy rejected the tunnel with :unexpected_trailing_responses. Any 2xx response to CONNECT is now body-less: the response is done at the end of the header section and everything after it belongs to the tunnel, so it stays in the buffer instead of being parsed. --- lib/mint/http1.ex | 8 +- lib/mint/tunnel_proxy.ex | 2 +- test/mint/http1/conn_test.exs | 36 +++++++ test/mint/tunnel_proxy_connect_test.exs | 135 ++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 test/mint/tunnel_proxy_connect_test.exs diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 567bf1af..87b20a69 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -1093,7 +1093,13 @@ defmodule Mint.HTTP1 do method == "HEAD" or status in [204, 304] -> {:ok, :none} - # method == "CONNECT" and status in 200..299 -> nil + # RFC9110 9.3.6: + # > A server MUST NOT send any Transfer-Encoding or Content-Length header + # > fields in a 2xx (Successful) response to CONNECT. A client MUST ignore + # > any Content-Length or Transfer-Encoding header fields received in a + # > successful response to CONNECT. + method == "CONNECT" and status in 200..299 -> + {:ok, :none} request.transfer_encoding != [] && request.content_length -> {:error, :transfer_encoding_and_content_length} diff --git a/lib/mint/tunnel_proxy.ex b/lib/mint/tunnel_proxy.ex index 7116c265..e72199b3 100644 --- a/lib/mint/tunnel_proxy.ex +++ b/lib/mint/tunnel_proxy.ex @@ -92,7 +92,7 @@ defmodule Mint.TunnelProxy do {:status, ^ref, status} -> {:error, wrap_error({:proxy, {:unexpected_status, status}})} - {:headers, ^ref, headers} when responses == [] -> + {:headers, ^ref, headers} when responses == [] or responses == [{:done, ref}] -> {:done, headers} {:headers, ^ref, _headers} -> diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index d46202d6..4a5dd281 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -144,6 +144,42 @@ defmodule Mint.HTTP1Test do assert conn.buffer == "XXX" end + test "no body in 2xx response to CONNECT request", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "CONNECT", "example.com:443", [], nil) + + assert {:ok, conn, [_status, _headers, {:done, ^ref}]} = + HTTP1.stream(conn, {:tcp, conn.socket, "HTTP/1.1 200 OK\r\n\r\nXXX"}) + + assert conn.buffer == "XXX" + end + + test "content-length is ignored in 2xx response to CONNECT request", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "CONNECT", "example.com:443", [], nil) + response = "HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n" + + assert {:ok, conn, [_status, _headers, {:done, ^ref}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert conn.buffer == "" + end + + test "transfer-encoding and content-length are ignored in 2xx response to CONNECT request", + %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "CONNECT", "example.com:443", [], nil) + response = "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\ncontent-length: 10\r\n\r\n" + + assert {:ok, _conn, [_status, _headers, {:done, ^ref}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + end + + test "non-2xx response to CONNECT request has a body", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "CONNECT", "example.com:443", [], nil) + response = "HTTP/1.1 407 Proxy Authentication Required\r\ncontent-length: 6\r\n\r\ndenied" + + assert {:ok, _conn, [_status, _headers, {:data, ^ref, "denied"}, {:done, ^ref}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + end + test "status, headers, and body", %{conn: conn} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) response = "HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\nX" diff --git a/test/mint/tunnel_proxy_connect_test.exs b/test/mint/tunnel_proxy_connect_test.exs new file mode 100644 index 00000000..69961f40 --- /dev/null +++ b/test/mint/tunnel_proxy_connect_test.exs @@ -0,0 +1,135 @@ +defmodule Mint.TunnelProxyConnectTest do + use ExUnit.Case, async: true + + import Mint.HTTP1.TestHelpers + + alias Mint.HTTP + + # Offline tests for the CONNECT request TunnelProxy uses to establish a + # tunnel. They use a local TCP server as the proxy and inspect the raw bytes + # it receives, so they need no live proxy or internet connection. They live + # in their own module (rather than in tunnel_proxy_test.exs) so they run in + # the default suite instead of being excluded by that module's `:proxy` tag. + + @cert_opts [ + certfile: Path.absname("../support/mint/certificate.pem", __DIR__), + keyfile: Path.absname("../support/mint/key.pem", __DIR__) + ] + + test "tunnels through a proxy that sends content-length: 0 in the CONNECT response" do + origin_port = start_tls_origin() + + {proxy_port, proxy_ref} = + start_connect_proxy("HTTP/1.1 200 Connection established\r\ncontent-length: 0\r\n\r\n") + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: {:http, "localhost", proxy_port, []}, + transport_opts: [verify: :verify_none] + ) + + assert_receive {^proxy_ref, :connect, head}, 2000 + assert head =~ "CONNECT localhost:#{origin_port} HTTP/1.1\r\n" + + assert {:ok, conn, request} = HTTP.request(conn, "GET", "/", [], nil) + assert {:ok, _conn, responses} = receive_stream(conn) + assert [{:status, ^request, 200}, {:headers, ^request, _headers} | rest] = responses + assert merge_body(rest, request) == "hello" + end + + # Starts a one-shot CONNECT proxy that accepts a single connection, reports + # the raw CONNECT request head to the test process, replies with the given + # response, and then blindly relays bytes to the requested host. + defp start_connect_proxy(connect_response) do + test_pid = self() + ref = make_ref() + + {:ok, listen_socket} = + :gen_tcp.listen(0, mode: :binary, packet: :raw, active: false, reuseaddr: true) + + {:ok, port} = :inet.port(listen_socket) + + spawn_link(fn -> + {:ok, socket} = :gen_tcp.accept(listen_socket) + head = recv_request_head(socket) + send(test_pid, {ref, :connect, head}) + + [request_line, _rest] = String.split(head, "\r\n", parts: 2) + ["CONNECT", authority, _version] = String.split(request_line, " ") + [host, origin_port] = String.split(authority, ":") + + {:ok, origin_socket} = + :gen_tcp.connect(String.to_charlist(host), String.to_integer(origin_port), + mode: :binary, + active: true + ) + + :ok = :gen_tcp.send(socket, connect_response) + :ok = :inet.setopts(socket, active: true) + relay(socket, origin_socket) + end) + + {port, ref} + end + + defp start_tls_origin do + socket_opts = [mode: :binary, packet: :raw, active: false, reuseaddr: true] + {:ok, listen_socket} = :ssl.listen(0, socket_opts ++ @cert_opts) + {:ok, {_address, port}} = :ssl.sockname(listen_socket) + + spawn_link(fn -> + {:ok, socket} = :ssl.transport_accept(listen_socket) + {:ok, socket} = :ssl.handshake(socket, 10_000) + _request = recv_ssl_request_head(socket) + :ok = :ssl.send(socket, "HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\nhello") + + receive do + :stop -> :ok + end + end) + + port + end + + defp recv_request_head(socket, buffer \\ "") do + if String.contains?(buffer, "\r\n\r\n") do + buffer + else + {:ok, data} = :gen_tcp.recv(socket, 0, 2000) + recv_request_head(socket, buffer <> data) + end + end + + defp recv_ssl_request_head(socket, buffer \\ "") do + if String.contains?(buffer, "\r\n\r\n") do + buffer + else + {:ok, data} = :ssl.recv(socket, 0, 2000) + recv_ssl_request_head(socket, buffer <> data) + end + end + + defp relay(client_socket, origin_socket) do + receive do + {:tcp, ^client_socket, data} -> + _ = :gen_tcp.send(origin_socket, data) + relay(client_socket, origin_socket) + + {:tcp, ^origin_socket, data} -> + _ = :gen_tcp.send(client_socket, data) + relay(client_socket, origin_socket) + + {:tcp_closed, ^client_socket} -> + :gen_tcp.close(origin_socket) + + {:tcp_closed, ^origin_socket} -> + :gen_tcp.close(client_socket) + + {:tcp_error, ^client_socket, _reason} -> + :gen_tcp.close(origin_socket) + + {:tcp_error, ^origin_socket, _reason} -> + :gen_tcp.close(client_socket) + end + end +end From f0cd5c6265bebc114be044321ad767d767c391b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 19 Jul 2026 18:30:10 +0200 Subject: [PATCH 2/2] Bracket IPv6 addresses in the CONNECT authority The authority-form request target requires IPv6 addresses to be enclosed in square brackets (RFC 3986, section 3.2.2), but the tunnel proxy sent "CONNECT ::1:443" for IPv6 targets, which proxies cannot parse unambiguously. --- lib/mint/tunnel_proxy.ex | 12 +++++++++- test/mint/tunnel_proxy_connect_test.exs | 31 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/lib/mint/tunnel_proxy.ex b/lib/mint/tunnel_proxy.ex index e72199b3..79f7238c 100644 --- a/lib/mint/tunnel_proxy.ex +++ b/lib/mint/tunnel_proxy.ex @@ -18,7 +18,7 @@ defmodule Mint.TunnelProxy do {_scheme, address, port, opts} = host hostname = Mint.Core.Util.hostname(opts, address) - path = "#{hostname}:#{port}" + path = connect_authority(hostname, port) with {:ok, conn} <- HTTP1.connect(proxy_scheme, proxy_address, proxy_port, proxy_opts), timeout_deadline = timeout_deadline(proxy_opts), @@ -107,6 +107,16 @@ defmodule Mint.TunnelProxy do :more end + # IPv6 addresses must be enclosed in square brackets in the authority-form + # request target (RFC 3986, section 3.2.2). + defp connect_authority(hostname, port) do + if String.contains?(hostname, ":") do + "[#{hostname}]:#{port}" + else + "#{hostname}:#{port}" + end + end + defp timeout_deadline(opts) do timeout = Keyword.get(opts, :tunnel_timeout, @tunnel_timeout) System.monotonic_time(:millisecond) + timeout diff --git a/test/mint/tunnel_proxy_connect_test.exs b/test/mint/tunnel_proxy_connect_test.exs index 69961f40..1cb68f04 100644 --- a/test/mint/tunnel_proxy_connect_test.exs +++ b/test/mint/tunnel_proxy_connect_test.exs @@ -37,6 +37,16 @@ defmodule Mint.TunnelProxyConnectTest do assert merge_body(rest, request) == "hello" end + test "IPv6 address targets produce a bracketed CONNECT authority" do + {proxy_port, proxy_ref} = start_capturing_proxy() + + assert {:error, _reason} = + HTTP.connect(:https, "::1", 443, proxy: {:http, "localhost", proxy_port, []}) + + assert_receive {^proxy_ref, :connect, head}, 2000 + assert head =~ "CONNECT [::1]:443 HTTP/1.1\r\n" + end + # Starts a one-shot CONNECT proxy that accepts a single connection, reports # the raw CONNECT request head to the test process, replies with the given # response, and then blindly relays bytes to the requested host. @@ -72,6 +82,27 @@ defmodule Mint.TunnelProxyConnectTest do {port, ref} end + # Starts a one-shot server that accepts a single connection, reports the raw + # request head to the test process, and closes the connection. + defp start_capturing_proxy do + test_pid = self() + ref = make_ref() + + {:ok, listen_socket} = + :gen_tcp.listen(0, mode: :binary, packet: :raw, active: false, reuseaddr: true) + + {:ok, port} = :inet.port(listen_socket) + + spawn_link(fn -> + {:ok, socket} = :gen_tcp.accept(listen_socket) + send(test_pid, {ref, :connect, recv_request_head(socket)}) + :gen_tcp.close(socket) + :gen_tcp.close(listen_socket) + end) + + {port, ref} + end + defp start_tls_origin do socket_opts = [mode: :binary, packet: :raw, active: false, reuseaddr: true] {:ok, listen_socket} = :ssl.listen(0, socket_opts ++ @cert_opts)