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
110 changes: 92 additions & 18 deletions lib/bandit/headers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -102,39 +102,113 @@ defmodule Bandit.Headers do
end
end

# Covers IPv6 addresses, like `[::1]:4000` as defined in RFC3986.
# RFC9110§7.2 defines Host as uri-host [ ":" port ], importing uri-host from
# RFC3986§3.2.2. Bracketed hosts therefore have to be IPv6 or IPvFuture literals, while
# unbracketed hosts have to match reg-name (IPv4 addresses are also valid reg-names).
@spec parse_hostlike_header!(host_header :: binary()) ::
{Plug.Conn.host(), nil | Plug.Conn.port_number()}
def parse_hostlike_header!("[" <> _ = host_header) do
host_header
|> :binary.split("]:")
|> case do
[host, port] ->
case parse_integer(port) do
{port, ""} when is_port_number(port) -> {host <> "]", port}
_ -> raise Bandit.HTTPError, "Header contains invalid port"
def parse_hostlike_header!("[" <> rest) do
case :binary.split(rest, "]") do
[literal, suffix] ->
if valid_ip_literal?(literal) do
{"[" <> literal <> "]", parse_port_suffix!(suffix)}
else
raise Bandit.HTTPError, "Header contains invalid host"
end

[host] ->
{host, nil}
_ ->
raise Bandit.HTTPError, "Header contains invalid host"
end
end

def parse_hostlike_header!(host_header) do
host_header
|> :binary.split(":")
|> case do
case :binary.split(host_header, ":", [:global]) do
[host, port] ->
case parse_integer(port) do
{port, ""} when is_port_number(port) -> {host, port}
_ -> raise Bandit.HTTPError, "Header contains invalid port"
end
validate_reg_name!(host)
{host, parse_port!(port)}

[host] ->
validate_reg_name!(host)
{host, nil}

_ ->
raise Bandit.HTTPError, "Header contains invalid host"
end
end

defp parse_port_suffix!(""), do: nil
defp parse_port_suffix!(":" <> port), do: parse_port!(port)
defp parse_port_suffix!(_suffix), do: raise(Bandit.HTTPError, "Header contains invalid host")

# RFC3986§3.2.3 permits an empty port. HTTP and HTTPS assign the scheme's default in that
# case; callers that require an explicit port (such as CONNECT) enforce that separately.
defp parse_port!(""), do: nil

defp parse_port!(port) do
case parse_integer(port) do
{port, ""} when is_port_number(port) -> port
_ -> raise Bandit.HTTPError, "Header contains invalid port"
end
end

defp validate_reg_name!(host) do
if valid_reg_name?(host),
do: :ok,
else: raise(Bandit.HTTPError, "Header contains invalid host")
end

defp valid_reg_name?(<<>>), do: true

defp valid_reg_name?(<<"%", first, second, rest::binary>>)
when first in ?0..?9 or first in ?a..?f or first in ?A..?F do
if second in ?0..?9 or second in ?a..?f or second in ?A..?F,
do: valid_reg_name?(rest),
else: false
end

defp valid_reg_name?(<<char, rest::binary>>)
when char in ?a..?z or char in ?A..?Z or char in ?0..?9 or
char in ~c"-._~!$&'()*+,;=",
do: valid_reg_name?(rest)

defp valid_reg_name?(_host), do: false

defp valid_ip_literal?(literal) do
case :inet.parse_ipv6_address(:binary.bin_to_list(literal)) do
{:ok, _address} -> true
{:error, _reason} -> valid_ipvfuture?(literal)
end
end

defp valid_ipvfuture?(<<prefix, rest::binary>>) when prefix in [?v, ?V] do
case :binary.split(rest, ".") do
[version, address] when byte_size(version) > 0 and byte_size(address) > 0 ->
all_hexdigits?(version) and valid_ipvfuture_address?(address)

_ ->
false
end
end

defp valid_ipvfuture?(_literal), do: false

defp all_hexdigits?(<<>>), do: true

defp all_hexdigits?(<<char, rest::binary>>)
when char in ?0..?9 or char in ?a..?f or char in ?A..?F,
do: all_hexdigits?(rest)

defp all_hexdigits?(_value), do: false

defp valid_ipvfuture_address?(<<>>), do: true

defp valid_ipvfuture_address?(<<char, rest::binary>>)
when char in ?a..?z or char in ?A..?Z or char in ?0..?9 or
char in ~c"-._~!$&'()*+,;=:",
do: valid_ipvfuture_address?(rest)

defp valid_ipvfuture_address?(_address), do: false

@spec get_content_length(Plug.Conn.headers()) ::
{:ok, nil | non_neg_integer()} | {:error, String.t()}
def get_content_length(headers) do
Expand Down
6 changes: 4 additions & 2 deletions lib/bandit/http2/stream.ex
Original file line number Diff line number Diff line change
Expand Up @@ -149,16 +149,18 @@ defmodule Bandit.HTTP2.Stream do

defp build_request_target!(headers, stream) do
scheme = Bandit.Headers.get_header(headers, ":scheme")
{host, port} = get_host_and_port!(headers)
{host, port} = get_host_and_port!(headers, stream)
path = get_path!(headers, stream)
{scheme, host, port, path}
end

defp get_host_and_port!(headers) do
defp get_host_and_port!(headers, stream) do
case Bandit.Headers.get_header(headers, ":authority") do
authority when not is_nil(authority) -> Bandit.Headers.parse_hostlike_header!(authority)
nil -> {nil, nil}
end
rescue
_error in Bandit.HTTPError -> stream_error!("Received invalid :authority", stream)
end

# RFC9113§8.3.1 - path should be non-empty and absolute
Expand Down
17 changes: 17 additions & 0 deletions lib/bandit/pipeline.ex
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ defmodule Bandit.Pipeline do

@spec determine_host_and_port!(binary(), atom(), request_target(), Plug.Conn.headers()) ::
{Plug.Conn.host(), Plug.Conn.port_number()}
defp determine_host_and_port!(scheme, :"HTTP/1.1", {_, target_host, target_port, _}, headers) do
{header_host, header_port} = required_host_header!(headers)

case target_host do
nil -> {header_host, header_port || URI.default_port(scheme)}
target_host -> {to_string(target_host), target_port || URI.default_port(scheme)}
end
end

defp determine_host_and_port!(scheme, version, {_, nil, nil, _}, headers) do
case {Bandit.Headers.get_host_header(headers), version} do
{{:ok, nil}, :"HTTP/1.0"} ->
Expand All @@ -107,6 +116,14 @@ defmodule Bandit.Pipeline do
defp determine_host_and_port!(scheme, _version, {_, host, port, _}, _headers),
do: {to_string(host), port || URI.default_port(scheme)}

defp required_host_header!(headers) do
case Bandit.Headers.get_host_header(headers) do
{:ok, nil} -> request_error!("Unable to obtain host and port: No host header")
{:ok, host_header} -> Bandit.Headers.parse_hostlike_header!(host_header)
{:error, reason} -> request_error!("Unable to obtain host and port: #{reason}")
end
end

@spec determine_path_and_query(request_target()) :: {String.t(), nil | String.t()}
defp determine_path_and_query({_, _, _, :*}), do: {"*", nil}
defp determine_path_and_query({_, _, _, path}), do: split_path(path)
Expand Down
25 changes: 25 additions & 0 deletions test/bandit/headers_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,31 @@ defmodule Bandit.HeadersTest do
end
end

test "parses RFC 3986 uri-host forms" do
assert {"example.com", nil} = Headers.parse_hostlike_header!("example.com")
assert {"example%2Ecom", nil} = Headers.parse_hostlike_header!("example%2Ecom")
assert {"example.com", nil} = Headers.parse_hostlike_header!("example.com:")
assert {"[2001:db8::1]", nil} = Headers.parse_hostlike_header!("[2001:db8::1]")

assert {"[v1.example:transport]", nil} =
Headers.parse_hostlike_header!("[v1.example:transport]")
end

test "rejects values outside the RFC 3986 uri-host grammar" do
for host <- [
"example.com/path",
"user@example.com",
"bad%2",
"[not-an-ip-literal]",
"[::1]suffix",
"2001:db8::1"
] do
assert_raise(Bandit.HTTPError, "Header contains invalid host", fn ->
Headers.parse_hostlike_header!(host)
end)
end
end

test "returns error for invalid ports" do
for port <- @invalid_ports do
assert_raise(Bandit.HTTPError, @error_msg, fn ->
Expand Down
62 changes: 59 additions & 3 deletions test/bandit/http1/protocol_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ defmodule HTTP1ProtocolTest do
describe "absolute-form request target (RFC9112§3.2.2)" do
test "uses transport scheme even if it does not match request-line scheme", context do
client = SimpleHTTP1Client.tcp_client(context)
SimpleHTTP1Client.send(client, "GET", "https://banana/echo_components")
SimpleHTTP1Client.send(client, "GET", "https://banana/echo_components", ["host: banana"])
assert {:ok, "200 OK", _headers, body} = SimpleHTTP1Client.recv_reply(client)
assert Jason.decode!(body)["scheme"] == "http"
end
Expand Down Expand Up @@ -295,11 +295,59 @@ defmodule HTTP1ProtocolTest do
assert Jason.decode!(body)["host"] == "[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]"
end

test "does not require a host header set in HTTP/1.1 (RFC9112§3.2.2)", context do
@tag :capture_log
test "requires a host header in HTTP/1.1 (RFC9112§3.2)", context do
client = SimpleHTTP1Client.tcp_client(context)
SimpleHTTP1Client.send(client, "GET", "http://banana/echo_components")
assert {:ok, "400 Bad Request", _headers, _body} = SimpleHTTP1Client.recv_reply(client)

assert_receive {:log, %{level: :error, msg: {:string, msg}}}, 500
assert msg == "** (Bandit.HTTPError) Unable to obtain host and port: No host header"
end

@tag :capture_log
test "rejects multiple host headers in HTTP/1.1", context do
client = SimpleHTTP1Client.tcp_client(context)

SimpleHTTP1Client.send(client, "GET", "http://banana/echo_components", [
"host: banana",
"host: banana"
])

assert {:ok, "400 Bad Request", _headers, _body} = SimpleHTTP1Client.recv_reply(client)
end

@tag :capture_log
test "validates Host even when the absolute target supplies the authority", context do
client = SimpleHTTP1Client.tcp_client(context)

SimpleHTTP1Client.send(client, "GET", "http://banana/echo_components", [
"host: banana:not-a-port"
])

assert {:ok, "400 Bad Request", _headers, _body} = SimpleHTTP1Client.recv_reply(client)
end

@tag :capture_log
test "rejects invalid uri-host syntax in absolute-form Host fields", context do
for invalid_host <- ["bad/host", "user@host", "bad%2", "[not-an-ip]"] do
client = SimpleHTTP1Client.tcp_client(context)

SimpleHTTP1Client.send(client, "GET", "http://banana/echo_components", [
"host: #{invalid_host}"
])

assert {:ok, "400 Bad Request", _headers, _body} =
SimpleHTTP1Client.recv_reply(client)
end
end

test "treats an empty port as the scheme default", context do
client = SimpleHTTP1Client.tcp_client(context)
SimpleHTTP1Client.send(client, "GET", "/echo_components", ["host: example.com:"])

assert {:ok, "200 OK", _headers, body} = SimpleHTTP1Client.recv_reply(client)
assert Jason.decode!(body)["host"] == "banana"
assert Jason.decode!(body)["port"] == 80
end

test "derives port from the URI, even if it differs from host header", context do
Expand Down Expand Up @@ -388,6 +436,14 @@ defmodule HTTP1ProtocolTest do
echo_components(conn)
end

@tag :capture_log
test "requires a host header in HTTP/1.1", context do
client = SimpleHTTP1Client.tcp_client(context)

SimpleHTTP1Client.send(client, "CONNECT", "www.example.com:80")
assert {:ok, "400 Bad Request", _headers, _body} = SimpleHTTP1Client.recv_reply(client)
end

@tag :capture_log
test "returns 400 for authority request with a non CONNECT verb", context do
client = SimpleHTTP1Client.tcp_client(context)
Expand Down
20 changes: 20 additions & 0 deletions test/bandit/http2/protocol_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2298,6 +2298,26 @@ defmodule HTTP2ProtocolTest do
assert msg == "** (Bandit.HTTP2.Errors.StreamError) Received invalid TE header"
end

@tag :capture_log
test "returns a stream error for invalid :authority syntax", context do
socket = SimpleH2Client.setup_connection(context)

headers = [
{":method", "HEAD"},
{":path", "/"},
{":scheme", "https"},
{":authority", "user@example.com"}
]

SimpleH2Client.send_headers(socket, 1, true, headers)

assert SimpleH2Client.recv_rst_stream(socket) == {:ok, 1, 1}
assert SimpleH2Client.connection_alive?(socket)

assert_receive {:log, %{level: :error, msg: {:string, msg}, meta: %{stream_id: 1}}}, 500
assert msg == "** (Bandit.HTTP2.Errors.StreamError) Received invalid :authority"
end

@tag :capture_log
test "returns a stream error if :method pseudo header is missing", context do
socket = SimpleH2Client.setup_connection(context)
Expand Down
Loading