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
26 changes: 25 additions & 1 deletion lib/mint/unsafe_proxy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ defmodule Mint.UnsafeProxy do
body \\ nil
) do
path = request_line(conn, path)
headers = headers ++ conn.proxy_headers
headers = put_new_host_header(headers, conn) ++ conn.proxy_headers

case module.request(state, method, path, headers, body) do
{:ok, state, request} -> {:ok, %{conn | state: state}, request}
Expand Down Expand Up @@ -204,4 +204,28 @@ defmodule Mint.UnsafeProxy do
def request_body_window(%__MODULE__{module: module, state: state}, ref) do
module.request_body_window(state, ref)
end

# When proxying over plain HTTP, the request is sent to the proxy but its Host
# header must identify the origin server, not the proxy (RFC 7230, sec. 5.4).
# Mint.HTTP1 would otherwise default the Host header to the proxy's address,
# since its connection points at the proxy. We set it here (unless the caller
# already provided one) so Mint.HTTP1's `put_new` leaves the origin's Host in
# place.
defp put_new_host_header(headers, conn) do
if Enum.any?(headers, fn {name, _value} -> Mint.Core.Headers.lower_raw(name) == "host" end) do
headers
else
[{"Host", host_header_value(conn)} | headers]
end
end

# Mirrors the default-port handling in Mint.HTTP1: omit the port when it is the
# default for the scheme.
defp host_header_value(%UnsafeProxy{scheme: scheme, hostname: hostname, port: port}) do
if URI.default_port(Atom.to_string(scheme)) == port do
hostname
else
"#{hostname}:#{port}"
end
end
end
87 changes: 87 additions & 0 deletions test/mint/unsafe_proxy_host_header_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
defmodule Mint.UnsafeProxyHostHeaderTest do
use ExUnit.Case, async: true

alias Mint.HTTP

# Regression for #446. When proxying over plain HTTP, the request is sent to
# the proxy but its Host header must identify the origin server, not the proxy.
# These tests 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 unsafe_proxy_test.exs) so they run in the
# default suite instead of being excluded by that module's `:proxy` tag.

test "sets the Host header to the origin, not the proxy" do
request = proxied_request(:http, "example.com", 80, "GET", "/", [])

# The request line still targets the absolute origin URI (proxy form)...
assert request =~ "GET http://example.com/ HTTP/1.1"

# ...but the Host header is the origin, not the proxy's "localhost:<port>".
assert host_header(request) == "host: example.com"
refute request =~ ~r/localhost:\d+/
end

test "keeps the origin port in the Host header when it is not the default" do
request = proxied_request(:http, "example.com", 8080, "GET", "/", [])

assert host_header(request) == "host: example.com:8080"
end

test "does not override a caller-supplied Host header" do
request = proxied_request(:http, "example.com", 80, "GET", "/", [{"host", "other.example"}])

assert host_header(request) == "host: other.example"
end

# Connects to `host:port` through a local TCP server acting as the proxy, sends
# one request, and returns the raw bytes the proxy received.
defp proxied_request(scheme, host, port, method, path, headers) do
proxy_port = start_capturing_proxy()

assert {:ok, conn} =
HTTP.connect(scheme, host, port, proxy: {:http, "localhost", proxy_port, []})

assert {:ok, _conn, _ref} = HTTP.request(conn, method, path, headers, nil)

assert_receive {:proxy_request, request}, 2000
request
end

# Starts a one-shot TCP server that accepts a single connection, reads the
# request head, and forwards the raw bytes back to the test process. It owns
# the accepted socket and does its own passive `recv`, so there is no socket
# ownership race with the test process.
defp start_capturing_proxy do
test_pid = self()

{: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, {:proxy_request, recv_request_head(socket)})
:gen_tcp.close(socket)
:gen_tcp.close(listen_socket)
end)

port
end

defp recv_request_head(socket, acc \\ "") do
if String.contains?(acc, "\r\n\r\n") do
acc
else
{:ok, data} = :gen_tcp.recv(socket, 0, 2000)
recv_request_head(socket, acc <> data)
end
end

defp host_header(request) do
request
|> String.split("\r\n")
|> Enum.find(&(&1 |> String.downcase() |> String.starts_with?("host:")))
|> String.downcase()
end
end
Loading