diff --git a/lib/mint/core/util.ex b/lib/mint/core/util.ex index 83c1b96d..dcedff0e 100644 --- a/lib/mint/core/util.ex +++ b/lib/mint/core/util.ex @@ -3,7 +3,7 @@ defmodule Mint.Core.Util do alias Mint.Types - @spec hostname(keyword(), String.t()) :: String.t() + @spec hostname(keyword(), Types.address()) :: String.t() def hostname(opts, address) when is_list(opts) do case Keyword.fetch(opts, :hostname) do {:ok, hostname} -> diff --git a/lib/mint/tunnel_proxy.ex b/lib/mint/tunnel_proxy.ex index f2e3c012..c3f77894 100644 --- a/lib/mint/tunnel_proxy.ex +++ b/lib/mint/tunnel_proxy.ex @@ -16,14 +16,13 @@ defmodule Mint.TunnelProxy do defp establish_proxy(proxy, host) do {proxy_scheme, proxy_address, proxy_port, proxy_opts} = proxy {_scheme, address, port, opts} = host - hostname = Mint.Core.Util.hostname(opts, address) - path = connect_authority(hostname, port) + authority = connect_authority(address, port, opts) with {:ok, conn} <- HTTP1.connect(proxy_scheme, proxy_address, proxy_port, proxy_opts), timeout_deadline = timeout_deadline(proxy_opts), - headers = Keyword.get(opts, :proxy_headers, []), - {:ok, conn, ref} <- HTTP1.request(conn, "CONNECT", path, headers, nil), + headers = connect_headers(opts, authority), + {:ok, conn, ref} <- HTTP1.request(conn, "CONNECT", authority, headers, nil), {:ok, proxy_headers} <- receive_response(conn, ref, timeout_deadline) do {:ok, HTTP1.put_proxy_headers(conn, proxy_headers)} else @@ -39,8 +38,9 @@ defmodule Mint.TunnelProxy do defp upgrade_connection( conn, {proxy_scheme, _proxy_address, _proxy_port, _proxy_opts} = _proxy, - {scheme, hostname, port, opts} = _host + {scheme, address, port, opts} = _host ) do + hostname = Mint.Core.Util.hostname(opts, address) proxy_headers = HTTP1.get_proxy_headers(conn) socket = HTTP1.get_socket(conn) @@ -111,13 +111,37 @@ 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}" + # The CONNECT request targets the connection address (the host the proxy + # should dial), not the :hostname identity. IPv6 addresses must be enclosed + # in square brackets in the authority-form request target (RFC 3986, + # section 3.2.2). + defp connect_authority(address, port, opts) do + address = format_address(address, opts) + + if String.contains?(address, ":") do + "[#{address}]:#{port}" + else + "#{address}:#{port}" + end + end + + defp format_address(address, _opts) when is_binary(address), do: address + + defp format_address(address, _opts) when tuple_size(address) in [4, 8], + do: address |> :inet.ntoa() |> List.to_string() + + defp format_address(address, opts), do: Mint.Core.Util.hostname(opts, address) + + # The Host header of a CONNECT request must be identical to the + # authority-form request target (RFC 9112, section 3.2.3). Mint.HTTP1 would + # otherwise default it to the proxy's own host and port. + defp connect_headers(opts, authority) do + headers = Keyword.get(opts, :proxy_headers, []) + + if Enum.any?(headers, fn {name, _value} -> Mint.Core.Headers.lower_raw(name) == "host" end) do + headers else - "#{hostname}:#{port}" + [{"Host", authority} | headers] end end diff --git a/test/mint/https_proxy_test.exs b/test/mint/https_proxy_test.exs index 8695597c..1ebd5c49 100644 --- a/test/mint/https_proxy_test.exs +++ b/test/mint/https_proxy_test.exs @@ -111,7 +111,8 @@ defmodule Mint.HTTPSProxyTest do end test "verifies the host certificate through the tunnel" do - %{server_config: server_config, client_config: client_config} = pkix_test_chain() + %{server_config: server_config, client_config: client_config} = + Mint.TestCertificates.pkix_test_chain() {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start(server_config) {:ok, origin_port, _origin_ref} = start_http1_origin(server_config) @@ -134,7 +135,8 @@ defmodule Mint.HTTPSProxyTest do @tag :capture_log test "fails when the host certificate is not trusted" do - %{server_config: server_config, client_config: client_config} = pkix_test_chain() + %{server_config: server_config, client_config: client_config} = + Mint.TestCertificates.pkix_test_chain() {:ok, proxy_port, proxy_ref} = TunnelProxyServer.start(server_config) {:ok, origin_port, _origin_ref} = start_http1_origin() @@ -270,22 +272,4 @@ defmodule Mint.HTTPSProxyTest do 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_connect_test.exs b/test/mint/tunnel_proxy_connect_test.exs index 1cb68f04..4393f0c3 100644 --- a/test/mint/tunnel_proxy_connect_test.exs +++ b/test/mint/tunnel_proxy_connect_test.exs @@ -17,7 +17,7 @@ defmodule Mint.TunnelProxyConnectTest do ] test "tunnels through a proxy that sends content-length: 0 in the CONNECT response" do - origin_port = start_tls_origin() + {origin_port, _origin_ref} = start_tls_origin() {proxy_port, proxy_ref} = start_connect_proxy("HTTP/1.1 200 Connection established\r\ncontent-length: 0\r\n\r\n") @@ -47,6 +47,103 @@ defmodule Mint.TunnelProxyConnectTest do assert head =~ "CONNECT [::1]:443 HTTP/1.1\r\n" end + test "the CONNECT authority and Host header use the address, not the :hostname identity" do + {proxy_port, proxy_ref} = start_capturing_proxy() + + assert {:error, _reason} = + HTTP.connect(:https, "93.184.216.34", 443, + hostname: "example.com", + proxy: {:http, "localhost", proxy_port, []} + ) + + assert_receive {^proxy_ref, :connect, head}, 2000 + assert head =~ "CONNECT 93.184.216.34:443 HTTP/1.1\r\n" + assert host_header(head) == "host: 93.184.216.34:443" + refute head =~ "example.com" + end + + test "a caller-supplied host header in :proxy_headers is not overridden" do + {proxy_port, proxy_ref} = start_capturing_proxy() + + assert {:error, _reason} = + HTTP.connect(:https, "example.com", 443, + proxy: {:http, "localhost", proxy_port, []}, + proxy_headers: [{"host", "other.example"}] + ) + + assert_receive {^proxy_ref, :connect, head}, 2000 + assert host_header(head) == "host: other.example" + end + + test ":inet tuple addresses are formatted in the CONNECT authority" do + {proxy_port, proxy_ref} = start_capturing_proxy() + + assert {:error, _reason} = + HTTP.connect(:https, {127, 0, 0, 1}, 443, + hostname: "localhost", + proxy: {:http, "localhost", proxy_port, []} + ) + + assert_receive {^proxy_ref, :connect, head}, 2000 + assert head =~ "CONNECT 127.0.0.1:443 HTTP/1.1\r\n" + end + + test "IPv6 tuple addresses produce a bracketed CONNECT authority" do + {proxy_port, proxy_ref} = start_capturing_proxy() + + assert {:error, _reason} = + HTTP.connect(:https, {0, 0, 0, 0, 0, 0, 0, 1}, 443, + hostname: "localhost", + proxy: {:http, "localhost", proxy_port, []} + ) + + assert_receive {^proxy_ref, :connect, head}, 2000 + assert head =~ "CONNECT [::1]:443 HTTP/1.1\r\n" + end + + test "the tunnel TLS session verifies the certificate against the :hostname identity" do + %{server_config: server_config, client_config: client_config} = + Mint.TestCertificates.pkix_test_chain() + + {origin_port, origin_ref} = start_tls_origin(server_config) + {proxy_port, proxy_ref} = start_connect_proxy("HTTP/1.1 200 OK\r\n\r\n") + + assert {:ok, conn} = + HTTP.connect(:https, "127.0.0.1", origin_port, + hostname: "localhost", + proxy: {:http, "localhost", proxy_port, []}, + transport_opts: [cacerts: client_config[:cacerts]] + ) + + assert_receive {^proxy_ref, :connect, head}, 2000 + assert head =~ "CONNECT 127.0.0.1:#{origin_port} HTTP/1.1\r\n" + + assert {:ok, conn, request} = HTTP.request(conn, "GET", "/", [], nil) + + assert_receive {^origin_ref, :request, origin_head}, 2000 + assert host_header(origin_head) == "host: localhost:#{origin_port}" + + 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 "certificate verification fails when the :hostname identity does not match" do + %{server_config: server_config, client_config: client_config} = + Mint.TestCertificates.pkix_test_chain() + + {origin_port, _origin_ref} = start_tls_origin(server_config) + {proxy_port, _proxy_ref} = start_connect_proxy("HTTP/1.1 200 OK\r\n\r\n") + + assert {:error, %Mint.TransportError{}} = + HTTP.connect(:https, "127.0.0.1", origin_port, + hostname: "wrong.example", + proxy: {:http, "localhost", proxy_port, []}, + transport_opts: [cacerts: client_config[:cacerts]] + ) + 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. @@ -103,23 +200,31 @@ defmodule Mint.TunnelProxyConnectTest do {port, ref} end - defp start_tls_origin do + defp start_tls_origin(ssl_opts \\ @cert_opts) do + test_pid = self() + ref = make_ref() socket_opts = [mode: :binary, packet: :raw, active: false, reuseaddr: true] - {:ok, listen_socket} = :ssl.listen(0, socket_opts ++ @cert_opts) + {:ok, listen_socket} = :ssl.listen(0, socket_opts ++ ssl_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 + case :ssl.handshake(socket, 10_000) do + {:ok, socket} -> + send(test_pid, {ref, :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 + + {:error, _reason} -> + :ok end end) - port + {port, ref} end defp recv_request_head(socket, buffer \\ "") do @@ -140,6 +245,13 @@ defmodule Mint.TunnelProxyConnectTest do end end + defp host_header(head) do + head + |> String.split("\r\n") + |> Enum.find(&(&1 |> String.downcase() |> String.starts_with?("host:"))) + |> String.downcase() + end + defp relay(client_socket, origin_socket) do receive do {:tcp, ^client_socket, data} -> diff --git a/test/mint/unsafe_proxy_opts_test.exs b/test/mint/unsafe_proxy_opts_test.exs index c17c11f0..c7e8d938 100644 --- a/test/mint/unsafe_proxy_opts_test.exs +++ b/test/mint/unsafe_proxy_opts_test.exs @@ -45,7 +45,9 @@ defmodule Mint.UnsafeProxyOptsTest do end test "the target :hostname option is not used to verify the proxy certificate" do - %{server_config: server_config, client_config: client_config} = pkix_test_chain() + %{server_config: server_config, client_config: client_config} = + Mint.TestCertificates.pkix_test_chain() + {proxy_port, proxy_ref} = start_tls_proxy(server_config) assert {:ok, conn} = @@ -145,22 +147,4 @@ defmodule Mint.UnsafeProxyOptsTest do recv_tcp_request_head(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/support/mint/test_certificates.ex b/test/support/mint/test_certificates.ex new file mode 100644 index 00000000..b8addb20 --- /dev/null +++ b/test/support/mint/test_certificates.ex @@ -0,0 +1,24 @@ +defmodule Mint.TestCertificates do + @moduledoc false + + # Generates a fresh CA and server certificate chain at runtime, with a + # server certificate valid for "localhost" (SAN), so tests can exercise + # verify_peer against local servers without committed fixtures that expire. + def 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