Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/mint/core/util.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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} ->
Expand Down
46 changes: 35 additions & 11 deletions lib/mint/tunnel_proxy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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

Expand Down
24 changes: 4 additions & 20 deletions test/mint/https_proxy_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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
130 changes: 121 additions & 9 deletions test/mint/tunnel_proxy_connect_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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} ->
Expand Down
22 changes: 3 additions & 19 deletions test/mint/unsafe_proxy_opts_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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} =
Expand Down Expand Up @@ -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
24 changes: 24 additions & 0 deletions test/support/mint/test_certificates.ex
Original file line number Diff line number Diff line change
@@ -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
Loading