From f8ea6e1074fdf8746be40f2885bb9e6207fd5b7e Mon Sep 17 00:00:00 2001 From: Leonardo Scappatura <95079472+Leonard013@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:44:14 +0200 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_01NX8ygWTRa86kE8k8Yn9bpu --- lib/mint/unsafe_proxy.ex | 26 +++++- test/mint/unsafe_proxy_host_header_test.exs | 87 +++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 test/mint/unsafe_proxy_host_header_test.exs diff --git a/lib/mint/unsafe_proxy.ex b/lib/mint/unsafe_proxy.ex index 5c33a551..9068ebfa 100644 --- a/lib/mint/unsafe_proxy.ex +++ b/lib/mint/unsafe_proxy.ex @@ -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} @@ -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} -> String.downcase(name, :ascii) == "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 diff --git a/test/mint/unsafe_proxy_host_header_test.exs b/test/mint/unsafe_proxy_host_header_test.exs new file mode 100644 index 00000000..d95476da --- /dev/null +++ b/test/mint/unsafe_proxy_host_header_test.exs @@ -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:". + 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 From 71997810d75541e4cd3df29d5d3fde2ccd0d4537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 18 Jul 2026 12:55:14 -0700 Subject: [PATCH 2/2] Apply suggestion from @ericmj --- lib/mint/unsafe_proxy.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mint/unsafe_proxy.ex b/lib/mint/unsafe_proxy.ex index 9068ebfa..2b4657ac 100644 --- a/lib/mint/unsafe_proxy.ex +++ b/lib/mint/unsafe_proxy.ex @@ -212,7 +212,7 @@ defmodule Mint.UnsafeProxy do # 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} -> String.downcase(name, :ascii) == "host" end) 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]