Skip to content

Commit 475da4c

Browse files
committed
Rewrite host header to match upstream URL
The proxy was forwarding the original host header from the inbound connection, causing 403s from services like Cloudflare that validate the Host header against the target domain. Now the host header is always derived from the upstream URL for both filtered conn headers and caller-supplied headers.
1 parent 161ee4d commit 475da4c

2 files changed

Lines changed: 74 additions & 10 deletions

File tree

lib/philter.ex

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,10 @@ defmodule Philter do
132132
callbacks. See `Philter.Handler` for the callback interface.
133133
134134
* `:headers` - Pre-assembled outbound request headers as `[{name, value}]`
135-
tuples. When provided, these headers are sent as-is to the upstream
136-
(no filtering of `conn.req_headers`, no hop-by-hop removal). When omitted,
137-
`conn.req_headers` are filtered (hop-by-hop removed, keys lowercased).
135+
tuples. When provided, these replace `conn.req_headers` (no hop-by-hop
136+
filtering). When omitted, `conn.req_headers` are filtered (hop-by-hop
137+
removed, keys lowercased). In both cases, the `host` header is rewritten
138+
to match the upstream server.
138139
139140
* `:finch_name` - Finch pool name. Default: configured value (see `Philter.Config`).
140141
@@ -174,7 +175,7 @@ defmodule Philter do
174175
path = resolve_path(opts, conn)
175176
upstream_url = build_upstream_url(upstream, path, conn.query_string)
176177
req_content_type = get_content_type(conn.req_headers)
177-
outbound_headers = resolve_outbound_headers(opts, conn)
178+
outbound_headers = build_outbound_headers(Keyword.get(opts, :headers), conn, upstream)
178179

179180
# Notify handler of request start
180181
case notify_request_started(handler, %{
@@ -372,10 +373,30 @@ defmodule Philter do
372373
end
373374
end
374375

375-
defp resolve_outbound_headers(opts, conn) do
376-
case Keyword.get(opts, :headers) do
377-
nil -> filter_request_headers(conn.req_headers)
378-
headers when is_list(headers) -> headers
376+
defp build_outbound_headers(nil, conn, upstream) do
377+
conn.req_headers
378+
|> filter_request_headers()
379+
|> put_host_header(extract_host(upstream))
380+
end
381+
382+
defp build_outbound_headers(headers, _conn, upstream) do
383+
put_host_header(headers, extract_host(upstream))
384+
end
385+
386+
defp put_host_header(headers, host) do
387+
Enum.reject(headers, &host_header?/1) ++ [{"host", host}]
388+
end
389+
390+
defp host_header?({k, _}), do: String.downcase(k) == "host"
391+
392+
defp extract_host(url) do
393+
uri = URI.parse(url)
394+
395+
case uri.port do
396+
nil -> uri.host
397+
80 -> uri.host
398+
443 -> uri.host
399+
port -> "#{uri.host}:#{port}"
379400
end
380401
end
381402

test/philter_test.exs

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ defmodule PhilterTest do
224224
Bypass.expect(bypass, "POST", "/api", fn conn ->
225225
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer test"]
226226
assert Plug.Conn.get_req_header(conn, "content-type") == ["application/json"]
227-
# Caller-supplied headers are sent as-is, no hop-by-hop filtering
227+
# Caller-supplied headers bypass hop-by-hop filtering (host is still rewritten)
228228
Plug.Conn.send_resp(conn, 200, "ok")
229229
end)
230230

@@ -265,6 +265,46 @@ defmodule PhilterTest do
265265
assert conn.status == 200
266266
end
267267

268+
test "rewrites host header to match upstream", %{bypass: bypass, upstream: upstream} do
269+
Bypass.expect(bypass, "GET", "/host-check", fn conn ->
270+
[host] = Plug.Conn.get_req_header(conn, "host")
271+
assert host == "localhost:#{bypass.port}"
272+
Plug.Conn.send_resp(conn, 200, "ok")
273+
end)
274+
275+
conn =
276+
conn(:get, "/host-check")
277+
|> Map.put(:host, "original-host.example.com")
278+
|> Philter.proxy(upstream: upstream, finch_name: Philter.TestFinch)
279+
280+
assert conn.status == 200
281+
end
282+
283+
test "rewrites host header when caller-supplied headers provided", %{
284+
bypass: bypass,
285+
upstream: upstream
286+
} do
287+
Bypass.expect(bypass, "GET", "/host-check", fn conn ->
288+
[host] = Plug.Conn.get_req_header(conn, "host")
289+
assert host == "localhost:#{bypass.port}"
290+
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer tok"]
291+
Plug.Conn.send_resp(conn, 200, "ok")
292+
end)
293+
294+
conn =
295+
conn(:get, "/host-check")
296+
|> Philter.proxy(
297+
upstream: upstream,
298+
finch_name: Philter.TestFinch,
299+
headers: [
300+
{"host", "wrong-host.example.com"},
301+
{"authorization", "Bearer tok"}
302+
]
303+
)
304+
305+
assert conn.status == 200
306+
end
307+
268308
test "passes caller-supplied headers to handle_request_started metadata", %{
269309
bypass: bypass,
270310
upstream: upstream
@@ -285,7 +325,10 @@ defmodule PhilterTest do
285325
)
286326

287327
assert_receive {:request_started, req_meta}
288-
assert req_meta.headers == custom_headers
328+
# Custom headers are present, plus host is rewritten to match upstream
329+
assert {"authorization", "Bearer tok"} in req_meta.headers
330+
assert {"x-custom", "val"} in req_meta.headers
331+
assert {"host", _} = List.keyfind(req_meta.headers, "host", 0)
289332
end
290333

291334
test "invokes handler on error with error info", %{upstream: _upstream} do

0 commit comments

Comments
 (0)