diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 5c84e8d8..8a2cd51d 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -2080,6 +2080,13 @@ defmodule Mint.HTTP2 do # https://httpwg.org/specs/rfc9113.html#HttpFraming [{":status", <> = status} | headers] -> cond do + # RFC 9113 8.6: HTTP/2 does not support the 101 status code. + status == "101" -> + conn = close_stream!(conn, stream.id, :protocol_error) + debug_data = "the 101 (Switching Protocols) status code is not supported in HTTP/2" + error = wrap_error({:protocol_error, debug_data}) + {conn, [{:error, stream.ref, error} | responses]} + end_stream? -> conn = close_stream!(conn, stream.id, :protocol_error) debug_data = "informational response (1xx) must not have the END_STREAM flag set" @@ -2217,14 +2224,32 @@ defmodule Mint.HTTP2 do cond do not valid_field_name?(name) -> {:error, {:invalid_header_name, name}} not valid_field_value?(value) -> {:error, {:invalid_header_value, name, value}} + connection_specific?(name) -> {:error, connection_specific_error(name)} true -> validate_response_headers(rest, trailers?, status?, true) end end - # RFC 9110 15: status-code = 3DIGIT - defp valid_status?(<>) when a in ?0..?9 and b in ?0..?9 and c in ?0..?9, do: true + # RFC 9110 15: status-code = 3DIGIT, with values in the range 100-999. + defp valid_status?(<>) when a in ?1..?9 and b in ?0..?9 and c in ?0..?9, do: true defp valid_status?(_other), do: false + # RFC 9113 8.2.2: a message with connection-specific header fields is malformed. + # "te" is only allowed in requests, with the "trailers" value. + @connection_specific_headers [ + "connection", + "keep-alive", + "proxy-connection", + "te", + "transfer-encoding", + "upgrade" + ] + + defp connection_specific?(name), do: name in @connection_specific_headers + + defp connection_specific_error(name) do + {:protocol_error, "connection-specific header #{inspect(name)} is not allowed in HTTP/2"} + end + # RFC 9113 8.2.1: a field name must not contain characters in 0x00-0x20, 0x41-0x5A # (uppercase letters) or 0x7F-0xFF, and only a pseudo-header field can contain a colon. defp valid_field_name?(<<>>), do: false @@ -2333,14 +2358,11 @@ defmodule Mint.HTTP2 do # If we receive RST_STREAM then the stream is definitely closed. # We won't send anything else on the stream so we can simply delete # it, so that if we get things like DATA on that stream we error out. + # Streams are removed as soon as the server ends them, so a RST_STREAM on a + # stream we still track means the response is incomplete, whatever the code. conn = delete_stream(conn, stream) - - if error_code == :no_error do - {conn, [{:done, stream.ref} | responses]} - else - error = wrap_error({:server_closed_request, error_code}) - {conn, [{:error, stream.ref, error} | responses]} - end + error = wrap_error({:server_closed_request, error_code}) + {conn, [{:error, stream.ref, error} | responses]} :error -> {conn, responses} diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 8b4310b2..a2534f36 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -359,6 +359,36 @@ defmodule Mint.HTTP2Test do end describe "closed streams" do + for phase <- [:before_the_headers, :during_the_body] do + test "RST_STREAM with NO_ERROR #{phase} is an error", %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + conn = + if unquote(phase) == :during_the_body do + assert {:ok, %HTTP2{} = conn, + [{:status, ^ref, 200}, {:headers, ^ref, _}, {:data, ^ref, "x"}]} = + stream_frames(conn, [ + {:headers, stream_id, [{":status", "200"}, {"content-length", "5"}], + [:end_headers]}, + data(stream_id: stream_id, data: "x") + ]) + + conn + else + conn + end + + assert {:ok, %HTTP2{} = conn, [{:error, ^ref, error}]} = + stream_frames(conn, [rst_stream(stream_id: stream_id, error_code: :no_error)]) + + assert_http2_error error, {:server_closed_request, :no_error} + refute Map.has_key?(conn.streams, stream_id) + assert HTTP2.open?(conn) + end + end + test "server closes a stream with RST_STREAM", %{conn: conn} do {conn, ref} = open_request(conn) @@ -1231,7 +1261,7 @@ defmodule Mint.HTTP2Test do end describe "response header validation" do - for status <- ["abc", "", "+200", "2000", "20", "200 ", " 200", "1ab"] do + for status <- ["abc", "", "+200", "2000", "20", "200 ", " 200", "1ab", "000", "099"] do test "an invalid :status of #{inspect(status)} is a stream error", %{conn: conn} do {conn, ref} = open_request(conn) @@ -1250,6 +1280,89 @@ defmodule Mint.HTTP2Test do end end + for {name, value} <- [ + {"connection", "close"}, + {"keep-alive", "timeout=5"}, + {"proxy-connection", "keep-alive"}, + {"transfer-encoding", "chunked"}, + {"upgrade", "websocket"}, + {"te", "gzip"}, + {"te", "trailers"} + ] do + test "the connection-specific header #{name}: #{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"}, {unquote(name), unquote(value)}], + [:end_headers]} + ]) + + assert [{:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "connection-specific header #{inspect(unquote(name))}" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + end + + test "a connection-specific header in an informational response 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", "103"}, {"te", "trailers"}], [:end_headers]} + ]) + + assert [{:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "connection-specific header \"te\"" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + + test "a connection-specific header in trailers 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"}], [:end_headers]}, + {:headers, stream_id, [{"te", "trailers"}], [: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 =~ "connection-specific header \"te\"" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + + test "a 101 status 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", "101"}], [:end_headers]}]) + + assert [{:error, ^ref, error}] = responses + assert_http2_error error, {:protocol_error, debug_data} + assert debug_data =~ "the 101 (Switching Protocols) status code is not supported in HTTP/2" + + assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)] + assert HTTP2.open?(conn) + end + for name <- ["Foo", "fo o", "", "foo:bar", "f\x7Fo", "f\xC3\xA4"] do test "an invalid header name #{inspect(name)} is a stream error", %{conn: conn} do {conn, ref} = open_request(conn) @@ -1694,7 +1807,7 @@ defmodule Mint.HTTP2Test do info_hbf = server_encode_headers([ - {":status", "101"}, + {":status", "102"}, {"x-info-header1", "this is an info"} ])