From f25cfff2866f41e4285fc3abdad3cda466710d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 20 Sep 2026 22:57:22 +0200 Subject: [PATCH 1/3] Check HTTP/2 response bodies against the content-length header The content-length header of an HTTP/2 response wasn't compared with the DATA frames, so a body that was cut short, or that kept going past the declared length, was delivered as a complete response. Non-numeric and conflicting content-length values were passed through as well. Each stream now records the request method and the declared length and counts the body bytes it receives. A body that exceeds the declared length, or that ends (through END_STREAM on DATA, on the headers or on trailers) at a different length, is a stream error with PROTOCOL_ERROR, following RFC 9113 8.1.1. Identical duplicate content-length headers are accepted; differing values fail with :more_than_one_content_length_header and values that aren't digits fail with {:invalid_content_length_header, value}. Responses to HEAD and 204 and 304 responses must not have content, whatever their content-length header says, so they're tracked with a length of zero and a DATA frame on them is a stream error rather than body data. 2xx responses to CONNECT carry tunnel data and are exempt from the comparison. --- lib/mint/http2.ex | 165 ++++++++++++++++++----- test/mint/http2/conn_test.exs | 243 ++++++++++++++++++++++++++++++++++ 2 files changed, 376 insertions(+), 32 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 673f58b8..c46adbb3 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -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. + + * `:more_than_one_content_length_header` - 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. @@ -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} @@ -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 @@ -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) @@ -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 -> @@ -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 @@ -2179,6 +2214,54 @@ 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, :more_than_one_content_length_header} + not digits?(value) -> {:error, {:invalid_content_length_header, value}} + true -> {:ok, String.to_integer(value)} + end + end + end + + defp digits?(<>) when char in ?0..?9, do: true + defp digits?(<>) when char in ?0..?9, do: digits?(rest) + defp digits?(_other), do: false + + 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 @@ -2418,7 +2501,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) @@ -2428,6 +2514,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) @@ -2800,6 +2893,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(:more_than_one_content_length_header) 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 diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 12f4a901..f03db20b 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -1345,6 +1345,249 @@ defmodule Mint.HTTP2Test do end end + describe "response content-length" do + for {method, status} <- [{"HEAD", "200"}, {"GET", "204"}, {"GET", "304"}] do + test "DATA on a #{method} #{status} response is a stream error", %{conn: conn} do + assert {:ok, %HTTP2{} = conn, ref} = HTTP2.request(conn, unquote(method), "/", [], nil) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", unquote(status)}, {"content-length", "5"}], + [:end_headers]}, + data(stream_id: stream_id, data: "x", flags: set_flags(:data, [:end_stream])) + ]) + + assert [{:status, ^ref, _}, {:headers, ^ref, _}, {:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "must not have content" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + end + + test "a body matching the content-length header completes the request", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}, {"content-length", "5"}], + [:end_headers]}, + data(stream_id: stream_id, data: "hel", flags: set_flags(:data, [])), + data(stream_id: stream_id, data: "lo", flags: set_flags(:data, [:end_stream])) + ]) + + assert responses == [ + {:status, ref, 200}, + {:headers, ref, [{"content-length", "5"}]}, + {:data, ref, "hel"}, + {:data, ref, "lo"}, + {:done, ref} + ] + + assert HTTP2.open?(conn) + end + + test "a body shorter than the content-length header is a stream error", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}, {"content-length", "10"}], + [:end_headers]}, + data(stream_id: stream_id, data: "hi", flags: set_flags(:data, [:end_stream])) + ]) + + assert [ + {:status, ^ref, 200}, + {:headers, ^ref, _}, + {:data, ^ref, "hi"}, + {:error, ^ref, error} + ] = + responses + + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "body is 2 bytes but the content-length header value is 10" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + + test "a body longer than the content-length header is a stream error", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}, {"content-length", "1"}], + [:end_headers]}, + data(stream_id: stream_id, data: "hi", flags: set_flags(:data, [])) + ]) + + assert [{:status, ^ref, 200}, {:headers, ^ref, _}, {:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "exceeds the content-length header value of 1" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + + test "END_STREAM on the headers with a non-zero content-length is a stream error", + %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}, {"content-length", "5"}], + [:end_headers, :end_stream]} + ]) + + assert [{:status, ^ref, 200}, {:headers, ^ref, _}, {:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "body is 0 bytes but the content-length header value is 5" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + + test "trailers ending a body shorter than the content-length header are a stream error", + %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}, {"content-length", "3"}], + [:end_headers]}, + data(stream_id: stream_id, data: "hi", flags: set_flags(:data, [])), + {:headers, stream_id, [{"x-trailer", "v"}], [:end_headers, :end_stream]} + ]) + + assert [ + {:status, ^ref, 200}, + {:headers, ^ref, _}, + {:data, ^ref, "hi"}, + {:headers, ^ref, [{"x-trailer", "v"}]}, + {:error, ^ref, error} + ] = responses + + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "body is 2 bytes but the content-length header value is 3" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + + test "trailers ending a body matching the content-length header complete the request", + %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}, {"content-length", "2"}], + [:end_headers]}, + data(stream_id: stream_id, data: "hi", flags: set_flags(:data, [])), + {:headers, stream_id, [{"x-trailer", "v"}], [:end_headers, :end_stream]} + ]) + + assert [ + {:status, ^ref, 200}, + {:headers, ^ref, _}, + {:data, ^ref, "hi"}, + {:headers, ^ref, _}, + {:done, ^ref} + ] = + responses + + assert HTTP2.open?(conn) + end + + for {method, status} <- [{"HEAD", "200"}, {"GET", "204"}, {"GET", "304"}, {"CONNECT", "200"}] do + test "a #{status} response to #{method} ignores the content-length header", %{conn: conn} do + assert {:ok, conn, ref} = HTTP2.request(conn, unquote(method), "/", [], nil) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", unquote(status)}, {"content-length", "5"}], + [:end_headers, :end_stream]} + ]) + + assert [{:status, ^ref, _}, {:headers, ^ref, _}, {:done, ^ref}] = responses + assert HTTP2.open?(conn) + end + end + + test "identical content-length headers are accepted", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + headers = [{":status", "200"}, {"content-length", "2"}, {"content-length", "2"}] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, stream_id, headers, [:end_headers]}, + data(stream_id: stream_id, data: "hi", flags: set_flags(:data, [:end_stream])) + ]) + + assert [{:status, ^ref, 200}, {:headers, ^ref, _}, {:data, ^ref, "hi"}, {:done, ^ref}] = + responses + + assert HTTP2.open?(conn) + end + + test "different content-length headers are a stream error", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + headers = [{":status", "200"}, {"content-length", "1"}, {"content-length", "2"}] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [{:headers, stream_id, headers, [:end_headers, :end_stream]}]) + + assert [{:error, ^ref, error}] = responses + assert_http2_error error, :more_than_one_content_length_header + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + + for value <- ["abc", "-1", "", "1a", "+1"] do + test "a content-length header of #{inspect(value)} is a stream error", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + assert {:ok, %HTTP2{} = conn, responses} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}, {"content-length", unquote(value)}], + [:end_headers, :end_stream]} + ]) + + assert [{:error, ^ref, error}] = responses + assert_http2_error error, {:invalid_content_length_header, unquote(value)} + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + end + end + describe "interim responses (1xx)" do test "multiple before a single HEADERS", %{conn: conn} do info_status1 = Enum.random(100..199) From b562b48830508d2f1d1db7a314b4e568f40a9f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 24 Sep 2026 11:52:11 +0200 Subject: [PATCH 2/3] Rename the HTTP/2 content-length mismatch error to :disagreeing_content_length_headers HTTP/2 responses accept identical duplicate content-length headers and only fail when the values differ, so :more_than_one_content_length_header named a case that isn't an error. HTTP/1 keeps that name, since there a second content-length header is an error whatever its value. --- lib/mint/http2.ex | 6 +++--- test/mint/http2/conn_test.exs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index c46adbb3..92e27120 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -381,7 +381,7 @@ defmodule Mint.HTTP2 do * `{:invalid_content_length_header, value}` - when the `content-length` header of a response is not a non-negative integer. `value` is the received value. - * `:more_than_one_content_length_header` - when a response contains `content-length` + * `: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. @@ -2234,7 +2234,7 @@ defmodule Mint.HTTP2 do [value | rest] -> cond do - Enum.any?(rest, &(&1 != value)) -> {:error, :more_than_one_content_length_header} + Enum.any?(rest, &(&1 != value)) -> {:error, :disagreeing_content_length_headers} not digits?(value) -> {:error, {:invalid_content_length_header, value}} true -> {:ok, String.to_integer(value)} end @@ -2897,7 +2897,7 @@ defmodule Mint.HTTP2 do "invalid content-length header in the response: #{inspect(value)}" end - def format_error(:more_than_one_content_length_header) do + def format_error(:disagreeing_content_length_headers) do "the response contains content-length headers with different values" end diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index f03db20b..64b43232 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -1561,7 +1561,7 @@ defmodule Mint.HTTP2Test do stream_frames(conn, [{:headers, stream_id, headers, [:end_headers, :end_stream]}]) assert [{:error, ^ref, error}] = responses - assert_http2_error error, :more_than_one_content_length_header + assert_http2_error error, :disagreeing_content_length_headers assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] assert HTTP2.open?(conn) From 8519c25709d82ac7e7a7c4c228abdb6e9bec2750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 24 Sep 2026 11:54:11 +0200 Subject: [PATCH 3/3] Share the content-length digit check between HTTP/1 and HTTP/2 Mint.HTTP1.Parse and Mint.HTTP2 each had a private function checking that a content-length value is only ASCII digits. Both now call Mint.ParsingTools.only_digits?/1. --- lib/mint/http1/parse.ex | 8 +++----- lib/mint/http2.ex | 17 +++++++++-------- lib/mint/parsing_tools.ex | 8 ++++++++ test/mint/parsing_tools_test.exs | 17 +++++++++++++++++ 4 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 lib/mint/parsing_tools.ex create mode 100644 test/mint/parsing_tools_test.exs diff --git a/lib/mint/http1/parse.ex b/lib/mint/http1/parse.ex index 710653da..f4833bb6 100644 --- a/lib/mint/http1/parse.ex +++ b/lib/mint/http1/parse.ex @@ -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 @@ -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?(<>) when is_digit(char), do: true - defp only_digits?(<>) 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 diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 92e27120..acb6444d 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -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 @@ -2234,17 +2234,18 @@ defmodule Mint.HTTP2 do [value | rest] -> cond do - Enum.any?(rest, &(&1 != value)) -> {:error, :disagreeing_content_length_headers} - not digits?(value) -> {:error, {:invalid_content_length_header, value}} - true -> {:ok, String.to_integer(value)} + 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 digits?(<>) when char in ?0..?9, do: true - defp digits?(<>) when char in ?0..?9, do: digits?(rest) - defp digits?(_other), do: false - defp end_remote_stream(conn, stream, responses) do stream = conn.streams[stream.id] diff --git a/lib/mint/parsing_tools.ex b/lib/mint/parsing_tools.ex new file mode 100644 index 00000000..bc89d6eb --- /dev/null +++ b/lib/mint/parsing_tools.ex @@ -0,0 +1,8 @@ +defmodule Mint.ParsingTools do + @moduledoc false + + @spec only_digits?(binary()) :: boolean() + def only_digits?(<>) when char in ?0..?9, do: true + def only_digits?(<>) when char in ?0..?9, do: only_digits?(rest) + def only_digits?(_other), do: false +end diff --git a/test/mint/parsing_tools_test.exs b/test/mint/parsing_tools_test.exs new file mode 100644 index 00000000..0c4a3633 --- /dev/null +++ b/test/mint/parsing_tools_test.exs @@ -0,0 +1,17 @@ +defmodule Mint.ParsingToolsTest do + use ExUnit.Case, async: true + + import Mint.ParsingTools + + test "only_digits?/1" do + assert only_digits?("0") + assert only_digits?("1234567890") + + refute only_digits?("") + refute only_digits?("+1") + refute only_digits?("-1") + refute only_digits?("1a") + refute only_digits?(" 1") + refute only_digits?("1 ") + end +end