Skip to content

Commit f8ea6e1

Browse files
Leonard013claude
andcommitted
Send origin Host header for plain-HTTP proxied requests
When connecting to an origin through a plain-HTTP (non-tunnel) proxy, Mint.UnsafeProxy rewrote the request target to the absolute origin URI but left the Host header unset, so Mint.HTTP1 defaulted it to the proxy's own host:port. Per RFC 7230 section 5.4 the Host header must identify the origin server; hackney and HTTPoison already do this. Set the Host header to the origin (dropping the port when it is the scheme default) in Mint.UnsafeProxy.request/5 before delegating to Mint.HTTP1, whose put_new leaves it intact. A caller-supplied Host header is still respected. Fixes #446. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NX8ygWTRa86kE8k8Yn9bpu
1 parent f0b72c0 commit f8ea6e1

2 files changed

Lines changed: 112 additions & 1 deletion

File tree

lib/mint/unsafe_proxy.ex

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ defmodule Mint.UnsafeProxy do
8181
body \\ nil
8282
) do
8383
path = request_line(conn, path)
84-
headers = headers ++ conn.proxy_headers
84+
headers = put_new_host_header(headers, conn) ++ conn.proxy_headers
8585

8686
case module.request(state, method, path, headers, body) do
8787
{:ok, state, request} -> {:ok, %{conn | state: state}, request}
@@ -204,4 +204,28 @@ defmodule Mint.UnsafeProxy do
204204
def request_body_window(%__MODULE__{module: module, state: state}, ref) do
205205
module.request_body_window(state, ref)
206206
end
207+
208+
# When proxying over plain HTTP, the request is sent to the proxy but its Host
209+
# header must identify the origin server, not the proxy (RFC 7230, sec. 5.4).
210+
# Mint.HTTP1 would otherwise default the Host header to the proxy's address,
211+
# since its connection points at the proxy. We set it here (unless the caller
212+
# already provided one) so Mint.HTTP1's `put_new` leaves the origin's Host in
213+
# place.
214+
defp put_new_host_header(headers, conn) do
215+
if Enum.any?(headers, fn {name, _value} -> String.downcase(name, :ascii) == "host" end) do
216+
headers
217+
else
218+
[{"Host", host_header_value(conn)} | headers]
219+
end
220+
end
221+
222+
# Mirrors the default-port handling in Mint.HTTP1: omit the port when it is the
223+
# default for the scheme.
224+
defp host_header_value(%UnsafeProxy{scheme: scheme, hostname: hostname, port: port}) do
225+
if URI.default_port(Atom.to_string(scheme)) == port do
226+
hostname
227+
else
228+
"#{hostname}:#{port}"
229+
end
230+
end
207231
end
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
defmodule Mint.UnsafeProxyHostHeaderTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Mint.HTTP
5+
6+
# Regression for #446. When proxying over plain HTTP, the request is sent to
7+
# the proxy but its Host header must identify the origin server, not the proxy.
8+
# These tests use a local TCP server as the "proxy" and inspect the raw bytes
9+
# it receives, so they need no live proxy or internet connection. They live in
10+
# their own module (rather than in unsafe_proxy_test.exs) so they run in the
11+
# default suite instead of being excluded by that module's `:proxy` tag.
12+
13+
test "sets the Host header to the origin, not the proxy" do
14+
request = proxied_request(:http, "example.com", 80, "GET", "/", [])
15+
16+
# The request line still targets the absolute origin URI (proxy form)...
17+
assert request =~ "GET http://example.com/ HTTP/1.1"
18+
19+
# ...but the Host header is the origin, not the proxy's "localhost:<port>".
20+
assert host_header(request) == "host: example.com"
21+
refute request =~ ~r/localhost:\d+/
22+
end
23+
24+
test "keeps the origin port in the Host header when it is not the default" do
25+
request = proxied_request(:http, "example.com", 8080, "GET", "/", [])
26+
27+
assert host_header(request) == "host: example.com:8080"
28+
end
29+
30+
test "does not override a caller-supplied Host header" do
31+
request = proxied_request(:http, "example.com", 80, "GET", "/", [{"host", "other.example"}])
32+
33+
assert host_header(request) == "host: other.example"
34+
end
35+
36+
# Connects to `host:port` through a local TCP server acting as the proxy, sends
37+
# one request, and returns the raw bytes the proxy received.
38+
defp proxied_request(scheme, host, port, method, path, headers) do
39+
proxy_port = start_capturing_proxy()
40+
41+
assert {:ok, conn} =
42+
HTTP.connect(scheme, host, port, proxy: {:http, "localhost", proxy_port, []})
43+
44+
assert {:ok, _conn, _ref} = HTTP.request(conn, method, path, headers, nil)
45+
46+
assert_receive {:proxy_request, request}, 2000
47+
request
48+
end
49+
50+
# Starts a one-shot TCP server that accepts a single connection, reads the
51+
# request head, and forwards the raw bytes back to the test process. It owns
52+
# the accepted socket and does its own passive `recv`, so there is no socket
53+
# ownership race with the test process.
54+
defp start_capturing_proxy do
55+
test_pid = self()
56+
57+
{:ok, listen_socket} =
58+
:gen_tcp.listen(0, mode: :binary, packet: :raw, active: false, reuseaddr: true)
59+
60+
{:ok, port} = :inet.port(listen_socket)
61+
62+
spawn_link(fn ->
63+
{:ok, socket} = :gen_tcp.accept(listen_socket)
64+
send(test_pid, {:proxy_request, recv_request_head(socket)})
65+
:gen_tcp.close(socket)
66+
:gen_tcp.close(listen_socket)
67+
end)
68+
69+
port
70+
end
71+
72+
defp recv_request_head(socket, acc \\ "") do
73+
if String.contains?(acc, "\r\n\r\n") do
74+
acc
75+
else
76+
{:ok, data} = :gen_tcp.recv(socket, 0, 2000)
77+
recv_request_head(socket, acc <> data)
78+
end
79+
end
80+
81+
defp host_header(request) do
82+
request
83+
|> String.split("\r\n")
84+
|> Enum.find(&(&1 |> String.downcase() |> String.starts_with?("host:")))
85+
|> String.downcase()
86+
end
87+
end

0 commit comments

Comments
 (0)