From edf04735ee9ce68023ca583648fdeea450c320ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 19 Jul 2026 00:06:03 +0200 Subject: [PATCH] Add support for HTTPS proxies for HTTPS connections Establish CONNECT tunnels through TLS proxies by nesting the TLS session to the host inside the TLS session to the proxy. The established :ssl socket acts as the transport for the nested connection through the :cb_info option, with Mint.Core.Transport.SSL.Tunnel as the callback module. The proxy connection is now also closed when the tunnel upgrade fails, and transport errors returned from upgrades are no longer wrapped twice. --- .dialyzer_ignore | 2 +- docker-compose.yml | 7 + lib/mint/core/transport/ssl.ex | 23 +- lib/mint/core/transport/ssl/tunnel.ex | 28 +++ lib/mint/http.ex | 24 +- lib/mint/negotiate.ex | 3 + lib/mint/tunnel_proxy.ex | 8 +- test/docker/squid-https/Dockerfile | 16 ++ test/docker/squid-https/squid.conf | 3 + test/mint/core/transport/ssl_test.exs | 8 - test/mint/https_proxy_test.exs | 291 +++++++++++++++++++++++ test/mint/tunnel_proxy_test.exs | 47 ++-- test/support/mint/http_bin.ex | 4 + test/support/mint/tunnel_proxy_server.ex | 107 +++++++++ 14 files changed, 533 insertions(+), 38 deletions(-) create mode 100644 lib/mint/core/transport/ssl/tunnel.ex create mode 100644 test/docker/squid-https/Dockerfile create mode 100644 test/docker/squid-https/squid.conf create mode 100644 test/mint/https_proxy_test.exs create mode 100644 test/support/mint/tunnel_proxy_server.ex diff --git a/.dialyzer_ignore b/.dialyzer_ignore index 646c68fe..f0d1175e 100644 --- a/.dialyzer_ignore +++ b/.dialyzer_ignore @@ -1,4 +1,4 @@ -lib/mint/tunnel_proxy.ex:49 +lib/mint/tunnel_proxy.ex:50 lib/mint/http1.ex:999 lib/mint/unsafe_proxy.ex:173 lib/mint/unsafe_proxy.ex:198 diff --git a/docker-compose.yml b/docker-compose.yml index 5f55e0d8..65235d3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,13 @@ services: ports: - "${TINYPROXY_AUTH_PORT:-8889}:8888" + proxy-https: + build: ./test/docker/squid-https + ports: + # Bound to loopback: this is an unauthenticated open proxy, it must not + # be reachable from other hosts while the test suite runs. + - "127.0.0.1:${SQUID_HTTPS_PORT:-8890}:3129" + httpbin: image: docker.io/kennethreitz/httpbin:latest platform: linux/amd64 diff --git a/lib/mint/core/transport/ssl.ex b/lib/mint/core/transport/ssl.ex index d4cbf1b1..f004d7b5 100644 --- a/lib/mint/core/transport/ssl.ex +++ b/lib/mint/core/transport/ssl.ex @@ -293,6 +293,8 @@ defmodule Mint.Core.Transport.SSL do @default_versions [:"tlsv1.3", :"tlsv1.2"] @default_timeout 30_000 + @tunnel_cb_info {Mint.Core.Transport.SSL.Tunnel, :ssl, :ssl_closed, :ssl_error, :ssl_passive} + Record.defrecordp( :certificate, :Certificate, @@ -347,6 +349,10 @@ defmodule Mint.Core.Transport.SSL do end @impl true + # :ssl.connect/3's spec doesn't cover upgrading an :ssl socket, but the + # function supports any socket type that the :cb_info callback module can + # handle. + @dialyzer {:nowarn_function, upgrade: 5} def upgrade(socket, :http, hostname, _port, opts) do hostname = String.to_charlist(hostname) timeout = Keyword.get(opts, :timeout, @default_timeout) @@ -357,8 +363,21 @@ defmodule Mint.Core.Transport.SSL do wrap_err(:ssl.connect(socket, ssl_opts(hostname, opts), timeout)) end - def upgrade(_socket, :https, _hostname, _port, _opts) do - raise "nested SSL sessions are not supported" + def upgrade(socket, :https, hostname, _port, opts) do + hostname = String.to_charlist(hostname) + timeout = Keyword.get(opts, :timeout, @default_timeout) + + with :ok <- setopts(socket, active: false) do + # The TLS session to the host is nested inside the TLS session to the + # proxy: the established :ssl socket acts as the transport for the new + # connection, so :ssl needs a callback module that speaks :ssl instead + # of :gen_tcp. Forced with Keyword.put/3 so that user-provided transport + # options cannot override it. + opts = Keyword.delete(opts, :hostname) + opts = Keyword.put(ssl_opts(hostname, opts), :cb_info, @tunnel_cb_info) + + wrap_err(:ssl.connect(socket, opts, timeout)) + end end @impl true diff --git a/lib/mint/core/transport/ssl/tunnel.ex b/lib/mint/core/transport/ssl/tunnel.ex new file mode 100644 index 00000000..32196197 --- /dev/null +++ b/lib/mint/core/transport/ssl/tunnel.ex @@ -0,0 +1,28 @@ +defmodule Mint.Core.Transport.SSL.Tunnel do + @moduledoc false + + # The transport callback module given to `:ssl.connect/3` through the + # `:cb_info` option when nesting a TLS session inside an established + # `:ssl` socket. `:ssl` requires the callback module to behave like + # `:gen_tcp`, with functions corresponding to `:inet.setopts/2`, + # `:inet.getopts/2`, `:inet.peername/1`, `:inet.sockname/1`, and + # `:inet.port/1`. + + defdelegate setopts(socket, opts), to: :ssl + defdelegate getopts(socket, opts), to: :ssl + defdelegate send(socket, data), to: :ssl + defdelegate recv(socket, length), to: :ssl + defdelegate recv(socket, length, timeout), to: :ssl + defdelegate controlling_process(socket, pid), to: :ssl + defdelegate close(socket), to: :ssl + defdelegate shutdown(socket, how), to: :ssl + defdelegate getstat(socket, options), to: :ssl + defdelegate peername(socket), to: :ssl + defdelegate sockname(socket), to: :ssl + + def port(socket) do + with {:ok, {_address, port}} <- :ssl.sockname(socket) do + {:ok, port} + end + end +end diff --git a/lib/mint/http.ex b/lib/mint/http.ex index e0bbafeb..d86d2bed 100644 --- a/lib/mint/http.ex +++ b/lib/mint/http.ex @@ -230,9 +230,6 @@ defmodule Mint.HTTP do **bytes**, of an HTTP/1 response header section or chunked trailer section. Defaults to 256 KiB. This option is only used for HTTP/1 connections. *Available since 1.9.2*. - The following options are HTTP/1-specific and will force the connection - to be an HTTP/1 connection. - * `:proxy` - a `{scheme, address, port, opts}` tuple that identifies a proxy to connect to. See the "Proxying" section below for more information. @@ -278,15 +275,17 @@ defmodule Mint.HTTP do You can set up proxying through the `:proxy` option, which is a tuple `{scheme, address, port, opts}` that identifies the proxy to connect to. Once a proxied connection is returned, the proxy is transparent to you and you - can use the connection like a normal HTTP/1 connection. + can use the connection like a normal connection. - If the `scheme` is `:http`, we will connect to the host in the most compatible - way, supporting older proxy servers. Data will be sent in clear text. + If the connection scheme is `:http`, requests are forwarded through the proxy + in the most compatible way, supporting older proxy servers. Data will be sent + in clear text. - If the connection scheme is `:https`, we will connect to the host with a tunnel - through the proxy. Using `:https` for both the proxy and the connection scheme - is not supported, it is recommended to use `:https` for the end host connection - instead of the proxy. + If the connection scheme is `:https`, a tunnel through the proxy is established + with the `CONNECT` method. If the proxy `scheme` is `:http`, the `CONNECT` + request is sent in clear text; if it is `:https`, the connection to the proxy + uses TLS and the TLS session to the host is nested inside it. *HTTPS proxies + for HTTPS connections are available since v1.10.0*. ## Transport options @@ -415,6 +414,11 @@ defmodule Mint.HTTP do proxy = {:http, "myproxy.example.com", 80, []} {:ok, conn} = Mint.HTTP.connect(:https, "httpbin.org", 443, proxy: proxy) + Using an HTTPS proxy: + + proxy = {:https, "myproxy.example.com", 443, []} + {:ok, conn} = Mint.HTTP.connect(:https, "httpbin.org", 443, proxy: proxy) + Forcing the connection to be an HTTP/2 connection: {:ok, conn} = Mint.HTTP.connect(:https, "httpbin.org", 443, protocols: [:http2]) diff --git a/lib/mint/negotiate.ex b/lib/mint/negotiate.ex index a059b04d..092d559d 100644 --- a/lib/mint/negotiate.ex +++ b/lib/mint/negotiate.ex @@ -117,6 +117,9 @@ defmodule Mint.Negotiate do {:ok, transport_state} -> alpn_negotiate(new_scheme, transport_state, hostname, port, opts) + {:error, %TransportError{} = error} -> + {:error, error} + {:error, reason} -> {:error, %TransportError{reason: reason}} end diff --git a/lib/mint/tunnel_proxy.ex b/lib/mint/tunnel_proxy.ex index 7116c265..2ce502c4 100644 --- a/lib/mint/tunnel_proxy.ex +++ b/lib/mint/tunnel_proxy.ex @@ -46,8 +46,12 @@ defmodule Mint.TunnelProxy do # Note that we may leak messages if the server sent data after the CONNECT response case Negotiate.upgrade(proxy_scheme, socket, scheme, hostname, port, opts) do - {:ok, conn} -> {:ok, HTTP.put_proxy_headers(conn, proxy_headers)} - {:error, reason} -> {:error, wrap_in_proxy_error(reason)} + {:ok, conn} -> + {:ok, HTTP.put_proxy_headers(conn, proxy_headers)} + + {:error, reason} -> + _ = Mint.Core.Util.scheme_to_transport(proxy_scheme).close(socket) + {:error, wrap_in_proxy_error(reason)} end end diff --git a/test/docker/squid-https/Dockerfile b/test/docker/squid-https/Dockerfile new file mode 100644 index 00000000..942b9877 --- /dev/null +++ b/test/docker/squid-https/Dockerfile @@ -0,0 +1,16 @@ +FROM ubuntu:24.04 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends squid-openssl openssl && \ + rm -rf /var/lib/apt/lists/* + +# Self-signed certificate for the TLS listener. Tests connect to the proxy +# with verify: :verify_none. +RUN openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout /etc/squid/key.pem -out /etc/squid/cert.pem -subj "/CN=localhost" + +COPY squid.conf /etc/squid/squid.conf + +EXPOSE 3129 + +CMD ["squid", "-N", "-d", "1"] diff --git a/test/docker/squid-https/squid.conf b/test/docker/squid-https/squid.conf new file mode 100644 index 00000000..b635b40b --- /dev/null +++ b/test/docker/squid-https/squid.conf @@ -0,0 +1,3 @@ +visible_hostname squid-https +https_port 3129 tls-cert=/etc/squid/cert.pem tls-key=/etc/squid/key.pem +http_access allow all diff --git a/test/mint/core/transport/ssl_test.exs b/test/mint/core/transport/ssl_test.exs index 6da52f69..82ee7abc 100644 --- a/test/mint/core/transport/ssl_test.exs +++ b/test/mint/core/transport/ssl_test.exs @@ -384,14 +384,6 @@ defmodule Mint.Core.Transport.SSLTest do end end - describe "upgrade/4" do - test "raises an error if the scheme is :https" do - assert_raise RuntimeError, "nested SSL sessions are not supported", fn -> - SSL.upgrade(_fake_socket = nil, :https, ~c"localhost", _port = 0, _timeout = 5000) - end - end - end - defp cn_cert(_context) do [cert: load_cert(@cn_cert)] end diff --git a/test/mint/https_proxy_test.exs b/test/mint/https_proxy_test.exs new file mode 100644 index 00000000..8695597c --- /dev/null +++ b/test/mint/https_proxy_test.exs @@ -0,0 +1,291 @@ +defmodule Mint.HTTPSProxyTest do + use ExUnit.Case, async: true + + import Mint.HTTP1.TestHelpers + import Mint.HTTP2.Frame, only: [settings: 1, ping: 1] + + alias Mint.{HTTP, HTTP2, TransportError} + alias Mint.HTTP2.Frame + alias Mint.HTTP2.TestServer + alias Mint.TunnelProxyServer + + @cert_opts [ + certfile: Path.absname("../support/mint/certificate.pem", __DIR__), + keyfile: Path.absname("../support/mint/key.pem", __DIR__) + ] + + describe "HTTPS proxies for HTTPS connections" do + test "HTTP/1 request through the tunnel" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port, origin_ref} = start_http1_origin() + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [verify: :verify_none] + ) + + assert HTTP.protocol(conn) == :http1 + + assert_receive {^proxy_ref, :connect, head}, 1000 + assert head =~ "CONNECT localhost:#{origin_port} HTTP/1.1\r\n" + + assert {:ok, conn, request} = HTTP.request(conn, "GET", "/", [], nil) + assert_receive {^origin_ref, :origin_request, origin_request}, 1000 + assert origin_request =~ "GET / HTTP/1.1\r\n" + + assert {:ok, _conn, responses} = receive_stream(conn) + assert [{:status, ^request, 200}, {:headers, ^request, _headers} | rest] = responses + assert merge_body(rest, request) == "hello" + end + + test "HTTP/1 forced through the :protocols option" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port, origin_ref} = start_http1_origin() + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [verify: :verify_none], + protocols: [:http1] + ) + + assert HTTP.protocol(conn) == :http1 + assert_receive {^proxy_ref, :connect, _head}, 1000 + + assert {:ok, conn, request} = HTTP.request(conn, "GET", "/", [], nil) + assert_receive {^origin_ref, :origin_request, _origin_request}, 1000 + + assert {:ok, _conn, responses} = receive_stream(conn) + assert [{:status, ^request, 200}, {:headers, ^request, _headers} | rest] = responses + assert merge_body(rest, request) == "hello" + end + + test "HTTP/2 negotiated through ALPN in the tunnel" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port, server_socket_task} = TestServer.listen_and_accept() + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [verify: :verify_none] + ) + + assert HTTP.protocol(conn) == :http2 + assert_receive {^proxy_ref, :connect, _head}, 1000 + + {conn, server_socket} = exchange_server_settings(conn, server_socket_task) + + assert {:ok, conn, ping_ref} = HTTP2.ping(conn) + server = TestServer.new(server_socket) + assert [ping(opaque_data: opaque_data)] = TestServer.recv_next_frames(server, 1) + + ping_ack_flags = Frame.set_flags(:ping, [:ack]) + ping_ack = ping(flags: ping_ack_flags, opaque_data: opaque_data) + :ok = :ssl.send(server_socket, Frame.encode(ping_ack)) + + client_socket = HTTP.get_socket(conn) + assert_receive {:ssl, ^client_socket, _data} = message, 1000 + assert {:ok, _conn, [{:pong, ^ping_ref}]} = HTTP.stream(conn, message) + end + + test "HTTP/2 forced through the :protocols option" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port, server_socket_task} = TestServer.listen_and_accept() + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [verify: :verify_none], + protocols: [:http2] + ) + + assert HTTP.protocol(conn) == :http2 + assert_receive {^proxy_ref, :connect, _head}, 1000 + + {_conn, _server_socket} = exchange_server_settings(conn, server_socket_task) + end + + test "verifies the host certificate through the tunnel" do + %{server_config: server_config, client_config: client_config} = pkix_test_chain() + + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start(server_config) + {:ok, origin_port, _origin_ref} = start_http1_origin(server_config) + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, + [transport_opts: [cacerts: client_config[:cacerts]]]}, + transport_opts: [cacerts: client_config[:cacerts]] + ) + + assert_receive {^proxy_ref, :connect, _head}, 1000 + + 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 + + @tag :capture_log + test "fails when the host certificate is not trusted" do + %{server_config: server_config, client_config: client_config} = pkix_test_chain() + + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start(server_config) + {:ok, origin_port, _origin_ref} = start_http1_origin() + + assert {:error, %TransportError{reason: reason}} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, + [transport_opts: [cacerts: client_config[:cacerts]]]}, + transport_opts: [cacerts: client_config[:cacerts]] + ) + + assert {:tls_alert, _alert} = reason + + # The tunnel to the proxy must be torn down when the nested handshake fails. + assert_receive {^proxy_ref, :connect, _head}, 1000 + assert_receive {^proxy_ref, :closed}, 1000 + end + + @tag :capture_log + test "returns an error when the TLS handshake inside the tunnel fails" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port} = start_garbage_origin() + + assert {:error, %TransportError{reason: reason}} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [verify: :verify_none] + ) + + refute match?(%TransportError{}, reason) + + # The tunnel to the proxy must be torn down when the nested handshake fails. + assert_receive {^proxy_ref, :connect, _head}, 1000 + assert_receive {^proxy_ref, :closed}, 1000 + end + + test ":cb_info in transport options cannot override the tunnel transport" do + {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start() + {:ok, origin_port, _origin_ref} = start_http1_origin() + + assert {:ok, conn} = + HTTP.connect(:https, "localhost", origin_port, + proxy: + {:https, "localhost", proxy_port, [transport_opts: [verify: :verify_none]]}, + transport_opts: [ + verify: :verify_none, + cb_info: {:gen_tcp, :tcp, :tcp_closed, :tcp_error} + ] + ) + + assert HTTP.open?(conn) + assert_receive {^proxy_ref, :connect, _head}, 1000 + end + end + + # Completes the connection setup like a real server would: receives the + # client preface, sends the server SETTINGS and the ack of the client + # SETTINGS, and waits for the client to ack the server SETTINGS. + defp exchange_server_settings(conn, server_socket_task) do + settings_ack_flags = Frame.set_flags(:settings, [:ack]) + + {:ok, server_socket} = Task.await(server_socket_task) + assert :ok = TestServer.perform_http2_handshake(server_socket) + + :ok = + :ssl.send(server_socket, [ + Frame.encode(settings(params: [])), + Frame.encode(settings(flags: settings_ack_flags, params: [])) + ]) + + client_socket = HTTP.get_socket(conn) + assert_receive {:ssl, ^client_socket, _data} = message, 1000 + assert {:ok, conn, []} = HTTP.stream(conn, message) + + {:ok, data} = :ssl.recv(server_socket, 0, 1000) + assert {:ok, frame, ""} = Frame.decode_next(data) + assert settings(flags: ^settings_ack_flags, params: []) = frame + + :ok = :ssl.setopts(server_socket, active: true) + + {conn, server_socket} + end + + defp start_http1_origin(cert_opts \\ @cert_opts) 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) + parent = self() + ref = make_ref() + + spawn_link(fn -> + {:ok, socket} = :ssl.transport_accept(listen_socket) + + case :ssl.handshake(socket, 10_000) do + {:ok, socket} -> + request = recv_until_blank_line(socket, "") + send(parent, {ref, :origin_request, request}) + :ok = :ssl.send(socket, "HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\nhello") + + receive do + :stop -> :ok + end + + {:error, _reason} -> + :ok + end + end) + + {:ok, port, ref} + end + + defp start_garbage_origin do + {:ok, listen_socket} = :gen_tcp.listen(0, mode: :binary, active: false) + {:ok, port} = :inet.port(listen_socket) + + spawn_link(fn -> + {:ok, socket} = :gen_tcp.accept(listen_socket) + {:ok, _client_hello} = :gen_tcp.recv(socket, 0, 10_000) + :ok = :gen_tcp.send(socket, "HTTP/1.1 502 Bad Gateway\r\n\r\n") + :gen_tcp.close(socket) + end) + + {:ok, port} + end + + defp recv_until_blank_line(socket, buffer) do + if String.contains?(buffer, "\r\n\r\n") do + buffer + else + {:ok, data} = :ssl.recv(socket, 0, 10_000) + recv_until_blank_line(socket, buffer <> data) + end + end + + defp pkix_test_chain do + san_extension = {:Extension, {2, 5, 29, 17}, false, [dNSName: ~c"localhost"]} + cert_opts = [digest: :sha256, key: {:rsa, 2048, 17}] + + :public_key.pkix_test_data(%{ + server_chain: %{ + root: cert_opts, + intermediates: [], + peer: cert_opts ++ [extensions: [san_extension]] + }, + client_chain: %{ + root: cert_opts, + intermediates: [], + peer: cert_opts + } + }) + end +end diff --git a/test/mint/tunnel_proxy_test.exs b/test/mint/tunnel_proxy_test.exs index 2d7b9864..df24bfa0 100644 --- a/test/mint/tunnel_proxy_test.exs +++ b/test/mint/tunnel_proxy_test.exs @@ -114,18 +114,36 @@ defmodule Mint.TunnelProxyTest do assert merge_body(responses, request) =~ "mint/" end - test "200 response without explicit http2 - https://httpbin.org" do + test "200 response through an HTTPS proxy - https://httpbin.org" do assert {:ok, conn} = - Mint.TunnelProxy.connect( - {:http, "localhost", HttpBin.proxy_port(), []}, - {:https, HttpBin.proxy_host(), HttpBin.https_port(), - [protocols: [:http1, :http2], transport_opts: HttpBin.https_transport_opts()]} + Mint.HTTP.connect(:https, HttpBin.proxy_host(), HttpBin.https_port(), + proxy: + {:https, "localhost", HttpBin.https_proxy_port(), + [transport_opts: [verify: :verify_none]]}, + transport_opts: HttpBin.https_transport_opts() ) - assert conn.__struct__ == Mint.HTTP2 + assert {:ok, conn, request} = HTTP.request(conn, "GET", "/", [], nil) + assert {:ok, _conn, responses} = receive_stream(conn) - assert [{"proxy-agent", <<"tinyproxy/", _version::binary>>}] = - Mint.HTTP.get_proxy_headers(conn) + assert [status, headers | responses] = responses + assert {:status, ^request, 200} = status + assert {:headers, ^request, headers} = headers + assert is_list(headers) + assert merge_body(responses, request) =~ "httpbin" + end + + test "200 response through an HTTPS proxy with explicit http2 - https://httpbin.org" do + assert {:ok, conn} = + Mint.HTTP.connect(:https, HttpBin.proxy_host(), HttpBin.https_port(), + proxy: + {:https, "localhost", HttpBin.https_proxy_port(), + [transport_opts: [verify: :verify_none]]}, + transport_opts: HttpBin.https_transport_opts(), + protocols: [:http2] + ) + + assert conn.__struct__ == Mint.HTTP2 assert {:ok, conn, request} = HTTP.request(conn, "GET", "/user-agent", [], nil) assert {:ok, _conn, responses} = receive_stream(conn) @@ -137,27 +155,26 @@ defmodule Mint.TunnelProxyTest do assert merge_body(responses, request) =~ "mint/" end - @tag :skip - test "do not support nested HTTPS connections - https://httpbin.org" do + test "200 response without explicit http2 - https://httpbin.org" do assert {:ok, conn} = Mint.TunnelProxy.connect( - {:https, "localhost", HttpBin.proxy_port(), []}, + {:http, "localhost", HttpBin.proxy_port(), []}, {:https, HttpBin.proxy_host(), HttpBin.https_port(), - [transport_opts: HttpBin.https_transport_opts()]} + [protocols: [:http1, :http2], transport_opts: HttpBin.https_transport_opts()]} ) - assert conn.__struct__ == Mint.HTTP1 + assert conn.__struct__ == Mint.HTTP2 assert [{"proxy-agent", <<"tinyproxy/", _version::binary>>}] = Mint.HTTP.get_proxy_headers(conn) - assert {:ok, conn, request} = HTTP.request(conn, "GET", "/", [], nil) + assert {:ok, conn, request} = HTTP.request(conn, "GET", "/user-agent", [], nil) assert {:ok, _conn, responses} = receive_stream(conn) assert [status, headers | responses] = responses assert {:status, ^request, 200} = status assert {:headers, ^request, headers} = headers assert is_list(headers) - assert merge_body(responses, request) =~ "httpbin" + assert merge_body(responses, request) =~ "mint/" end end diff --git a/test/support/mint/http_bin.ex b/test/support/mint/http_bin.ex index 0a0c19d3..93962f1b 100644 --- a/test/support/mint/http_bin.ex +++ b/test/support/mint/http_bin.ex @@ -29,6 +29,10 @@ defmodule Mint.HttpBin do get_env_port("TINYPROXY_AUTH_PORT", 8889) end + def https_proxy_port() do + get_env_port("SQUID_HTTPS_PORT", 8890) + end + def https_transport_opts() do [cacertfile: "caddy_storage/pki/authorities/local/root.crt"] end diff --git a/test/support/mint/tunnel_proxy_server.ex b/test/support/mint/tunnel_proxy_server.ex new file mode 100644 index 00000000..3bd5a1c3 --- /dev/null +++ b/test/support/mint/tunnel_proxy_server.ex @@ -0,0 +1,107 @@ +defmodule Mint.TunnelProxyServer do + # A minimal HTTPS CONNECT proxy: it terminates TLS, accepts a CONNECT + # request, connects to the requested host, and then blindly relays bytes + # in both directions. The head of each CONNECT request is sent to the + # process that started the server as {server_ref, :connect, head}. + + @socket_opts [ + mode: :binary, + packet: :raw, + active: false, + reuseaddr: true + ] + + @cert_opts [ + certfile: Path.absname("certificate.pem", __DIR__), + keyfile: Path.absname("key.pem", __DIR__) + ] + + def start(cert_opts \\ @cert_opts) do + {:ok, listen_socket} = :ssl.listen(0, @socket_opts ++ cert_opts) + {:ok, {_address, port}} = :ssl.sockname(listen_socket) + server_ref = make_ref() + parent = self() + + spawn_link(fn -> loop(listen_socket, parent, server_ref) end) + + {:ok, port, server_ref} + end + + defp loop(listen_socket, parent, server_ref) do + case :ssl.transport_accept(listen_socket) do + {:ok, socket} -> + case :ssl.handshake(socket, 10_000) do + {:ok, socket} -> + handler = spawn_link(fn -> handle_connection(parent, server_ref) end) + :ok = :ssl.controlling_process(socket, handler) + send(handler, {:socket, socket}) + + {:error, _reason} -> + :ok + end + + loop(listen_socket, parent, server_ref) + + {:error, :closed} -> + :ok + end + end + + defp handle_connection(parent, server_ref) do + socket = + receive do + {:socket, socket} -> socket + end + + head = recv_until_blank_line(socket, "") + send(parent, {server_ref, :connect, head}) + + [request_line, _rest] = String.split(head, "\r\n", parts: 2) + ["CONNECT", authority, _version] = String.split(request_line, " ") + [host, port] = String.split(authority, ":") + + {:ok, tcp_socket} = + :gen_tcp.connect(String.to_charlist(host), String.to_integer(port), + mode: :binary, + active: true + ) + + :ok = :ssl.send(socket, "HTTP/1.1 200 OK\r\n\r\n") + :ok = :ssl.setopts(socket, active: true) + relay(socket, tcp_socket) + send(parent, {server_ref, :closed}) + end + + defp recv_until_blank_line(socket, buffer) do + if String.contains?(buffer, "\r\n\r\n") do + buffer + else + {:ok, data} = :ssl.recv(socket, 0, 10_000) + recv_until_blank_line(socket, buffer <> data) + end + end + + defp relay(ssl_socket, tcp_socket) do + receive do + {:ssl, ^ssl_socket, data} -> + _ = :gen_tcp.send(tcp_socket, data) + relay(ssl_socket, tcp_socket) + + {:tcp, ^tcp_socket, data} -> + _ = :ssl.send(ssl_socket, data) + relay(ssl_socket, tcp_socket) + + {:ssl_closed, ^ssl_socket} -> + :gen_tcp.close(tcp_socket) + + {:tcp_closed, ^tcp_socket} -> + :ssl.close(ssl_socket) + + {:ssl_error, ^ssl_socket, _reason} -> + :gen_tcp.close(tcp_socket) + + {:tcp_error, ^tcp_socket, _reason} -> + :ssl.close(ssl_socket) + end + end +end