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
8 changes: 3 additions & 5 deletions lib/mint/http1/parse.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule Mint.HTTP1.Parse do
@moduledoc false

alias Mint.ParsingTools

# Bound the parse work and keep the chunk size within an unsigned 64-bit value.
@max_chunk_size_digits 16

Expand Down Expand Up @@ -102,17 +104,13 @@ defmodule Mint.HTTP1.Parse do
def content_length_header(string) do
trimmed = String.trim_trailing(string)

if only_digits?(trimmed) do
if ParsingTools.only_digits?(trimmed) do
{:ok, String.to_integer(trimmed)}
else
{:error, {:invalid_content_length_header, string}}
end
end

defp only_digits?(<<char>>) when is_digit(char), do: true
defp only_digits?(<<char, rest::binary>>) when is_digit(char), do: only_digits?(rest)
defp only_digits?(_other), do: false

def connection_header(string) do
split_into_downcase_tokens(string)
end
Expand Down
168 changes: 135 additions & 33 deletions lib/mint/http2.ex
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ defmodule Mint.HTTP2 do

import Mint.HTTP2.Frame, except: [encode: 1, decode_next: 1, inspect: 1]

alias Mint.{HTTPError, TransportError}
alias Mint.{HTTPError, ParsingTools, TransportError}
alias Mint.Types
alias Mint.Core.{Headers, Util}
alias Mint.HTTP2.Frame
Expand Down Expand Up @@ -378,6 +378,12 @@ defmodule Mint.HTTP2 do
for example because it contains control characters. `name` is the name of the
header and `value` is the invalid value.

* `{:invalid_content_length_header, value}` - when the `content-length` header of a
response is not a non-negative integer. `value` is the received value.

* `:disagreeing_content_length_headers` - when a response contains `content-length`
headers with different values.

* `:unprocessed` - when a request was closed because it was not processed by the server.
When this error is returned, it means that the server hasn't processed the request at all,
so it's safe to retry the given request on a different or new connection.
Expand Down Expand Up @@ -601,7 +607,7 @@ defmodule Mint.HTTP2 do
|> add_default_headers(body)
|> sort_pseudo_headers_to_front()

{conn, stream_id, ref} = open_stream(conn)
{conn, stream_id, ref} = open_stream(conn, method)
{conn, payload} = encode_request_payload(conn, stream_id, headers, body)
conn = send!(conn, payload)
{:ok, conn, ref}
Expand Down Expand Up @@ -1356,7 +1362,7 @@ defmodule Mint.HTTP2 do
end
end

defp open_stream(conn) do
defp open_stream(conn, method) do
max_concurrent_streams = conn.server_settings.max_concurrent_streams

if conn.open_client_stream_count >= max_concurrent_streams do
Expand All @@ -1379,7 +1385,10 @@ defmodule Mint.HTTP2 do
# Current remaining receive window for this stream, tracked
# independently from the peak so that refills can be batched.
receive_window_remaining: conn.client_settings.initial_window_size,
received_first_headers?: false
received_first_headers?: false,
method: method,
content_length: nil,
body_size: 0
}

conn = put_in(conn.streams[stream.id], stream)
Expand Down Expand Up @@ -1874,13 +1883,29 @@ defmodule Mint.HTTP2 do
case Map.fetch(conn.streams, stream_id) do
{:ok, stream} ->
assert_stream_in_state(conn, stream, [:open, :half_closed_local])
responses = [{:data, stream.ref, data} | responses]
body_size = stream.body_size + byte_size(data)

if flag_set?(flags, :data, :end_stream) do
conn = close_stream!(conn, stream.id, :remote_end_stream)
{conn, [{:done, stream.ref} | responses]}
if stream.content_length && body_size > stream.content_length do
conn = close_stream!(conn, stream.id, :protocol_error)

debug_data =
if stream.content_length == 0 do
"received DATA for a response that must not have content"
else
"the response body exceeds the content-length header value of " <>
"#{stream.content_length}"
end

{conn, [{:error, stream.ref, wrap_error({:protocol_error, debug_data})} | responses]}
else
{conn, responses}
conn = put_in(conn.streams[stream.id].body_size, body_size)
responses = [{:data, stream.ref, data} | responses]

if flag_set?(flags, :data, :end_stream) do
end_remote_stream(conn, stream, responses)
else
{conn, responses}
end
end

:error ->
Expand Down Expand Up @@ -2035,46 +2060,56 @@ defmodule Mint.HTTP2 do
end

[{":status", status} | headers] when not received_first_headers? ->
conn = put_in(conn.streams[stream.id].received_first_headers?, true)
status = String.to_integer(status)
headers = join_cookie_headers(headers)
new_responses = [{:headers, ref, headers}, {:status, ref, status} | responses]

cond do
# :reserved_remote means that this was a promised stream. As soon as headers come,
# the stream goes in the :half_closed_local state (unless it's not allowed because
# of the client's max concurrent streams limit, or END_STREAM is set).
stream.state == :reserved_remote ->
case response_content_length(stream, status, headers) do
{:ok, content_length} ->
conn =
update_in(
conn.streams[stream.id],
&%{&1 | received_first_headers?: true, content_length: content_length}
)

new_responses = [{:headers, ref, headers}, {:status, ref, status} | responses]

cond do
conn.open_server_stream_count >= conn.client_settings.max_concurrent_streams ->
conn = close_stream!(conn, stream.id, :refused_stream)
{conn, responses}
# :reserved_remote means that this was a promised stream. As soon as headers come,
# the stream goes in the :half_closed_local state (unless it's not allowed because
# of the client's max concurrent streams limit, or END_STREAM is set).
stream.state == :reserved_remote ->
cond do
conn.open_server_stream_count >= conn.client_settings.max_concurrent_streams ->
conn = close_stream!(conn, stream.id, :refused_stream)
{conn, responses}

end_stream? ->
end_remote_stream(conn, stream, new_responses)

true ->
conn = update_in(conn.open_server_stream_count, &(&1 + 1))
conn = update_in(conn.reserved_server_stream_count, &(&1 - 1))
conn = put_in(conn.streams[stream.id].state, :half_closed_local)
{conn, new_responses}
end

end_stream? ->
conn = close_stream!(conn, stream.id, :remote_end_stream)
{conn, [{:done, ref} | new_responses]}
end_remote_stream(conn, stream, new_responses)

true ->
conn = update_in(conn.open_server_stream_count, &(&1 + 1))
conn = update_in(conn.reserved_server_stream_count, &(&1 - 1))
conn = put_in(conn.streams[stream.id].state, :half_closed_local)
{conn, new_responses}
end

end_stream? ->
conn = close_stream!(conn, stream.id, :remote_end_stream)
{conn, [{:done, ref} | new_responses]}

true ->
{conn, new_responses}
{:error, reason} ->
conn = close_stream!(conn, stream.id, :protocol_error)
{conn, [{:error, ref, wrap_error(reason)} | responses]}
end

# Trailer headers. We don't care about the :status header here.
headers when received_first_headers? ->
if end_stream? do
conn = close_stream!(conn, stream.id, :remote_end_stream)
headers = headers |> Headers.remove_unallowed_trailer() |> join_cookie_headers()
{conn, [{:done, ref}, {:headers, ref, headers} | responses]}
end_remote_stream(conn, stream, [{:headers, ref, headers} | responses])
else
# Trailer headers must set the END_STREAM flag because they're
# the last thing allowed on the stream (other than RST_STREAM and
Expand Down Expand Up @@ -2179,6 +2214,55 @@ defmodule Mint.HTTP2 do
defp field_value_chars?(<<_char, rest::binary>>), do: field_value_chars?(rest)
defp field_value_chars?(<<>>), do: true

# RFC 9113 8.1.1: a response with content is malformed if the sum of the DATA
# frame payload lengths doesn't equal the content-length header value. Responses
# to HEAD and 204 and 304 responses must not have content, whatever their
# content-length header says, and 2xx responses to CONNECT carry tunnel data.
defp response_content_length(%{method: method}, status, headers) do
cond do
method == "HEAD" -> {:ok, 0}
status in [204, 304] -> {:ok, 0}
method == "CONNECT" and status in 200..299 -> {:ok, nil}
true -> content_length(headers)
end
end

defp content_length(headers) do
case for {"content-length", value} <- headers, do: value do
[] ->
{:ok, nil}

[value | rest] ->
cond do
Enum.any?(rest, &(&1 != value)) ->
{:error, :disagreeing_content_length_headers}

not ParsingTools.only_digits?(value) ->
{:error, {:invalid_content_length_header, value}}

true ->
{:ok, String.to_integer(value)}
end
end
end

defp end_remote_stream(conn, stream, responses) do
stream = conn.streams[stream.id]

if stream.content_length in [nil, stream.body_size] do
conn = close_stream!(conn, stream.id, :remote_end_stream)
{conn, [{:done, stream.ref} | responses]}
else
conn = close_stream!(conn, stream.id, :protocol_error)

debug_data =
"the response body is #{stream.body_size} bytes but the content-length header " <>
"value is #{stream.content_length}"

{conn, [{:error, stream.ref, wrap_error({:protocol_error, debug_data})} | responses]}
end
end

defp join_cookie_headers(headers) do
# If we have 0 or 1 Cookie headers, we just use the old list of headers.
case Enum.split_with(headers, fn {name, _value} -> name == "cookie" end) do
Expand Down Expand Up @@ -2418,7 +2502,10 @@ defmodule Mint.HTTP2 do
send_window_size: conn.server_settings.initial_window_size,
receive_window_size: conn.client_settings.initial_window_size,
receive_window_remaining: conn.client_settings.initial_window_size,
received_first_headers?: false
received_first_headers?: false,
method: promised_method(headers),
content_length: nil,
body_size: 0
}

conn = put_in(conn.streams[promised_stream.id], promised_stream)
Expand All @@ -2428,6 +2515,13 @@ defmodule Mint.HTTP2 do
end
end

defp promised_method(headers) do
case List.keyfind(headers, ":method", 0) do
{":method", method} -> method
nil -> nil
end
end

defp refuse_promised_stream(conn, promised_stream_id) do
if open?(conn) do
rst_stream_frame = rst_stream(stream_id: promised_stream_id, error_code: :refused_stream)
Expand Down Expand Up @@ -2800,6 +2894,14 @@ defmodule Mint.HTTP2 do
"invalid value for header #{inspect(name)} in the response: #{inspect(value)}"
end

def format_error({:invalid_content_length_header, value}) do
"invalid content-length header in the response: #{inspect(value)}"
end

def format_error(:disagreeing_content_length_headers) do
"the response contains content-length headers with different values"
end

def format_error({:server_closed_request, error_code}) do
"server closed request with error code #{inspect(error_code)}"
end
Expand Down
8 changes: 8 additions & 0 deletions lib/mint/parsing_tools.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
defmodule Mint.ParsingTools do
@moduledoc false

@spec only_digits?(binary()) :: boolean()
def only_digits?(<<char>>) when char in ?0..?9, do: true
def only_digits?(<<char, rest::binary>>) when char in ?0..?9, do: only_digits?(rest)
def only_digits?(_other), do: false
end
Loading
Loading