diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex index 45ae5315..31f3c5e4 100644 --- a/lib/mint/http2.ex +++ b/lib/mint/http2.ex @@ -126,6 +126,7 @@ defmodule Mint.HTTP2 do """ import Mint.HTTP2.Frame, except: [encode: 1, decode_next: 1, inspect: 1] + import Mint.HTTP1.Parse, only: [is_alpha: 1, is_digit: 1, is_hex_digit: 1] alias Mint.{HTTPError, ParsingTools, TransportError} alias Mint.Types @@ -2743,10 +2744,16 @@ defmodule Mint.HTTP2 do pseudo[":scheme"] in [nil, ""] -> "missing or empty :scheme pseudo-header in promised request" + not valid_scheme?(pseudo[":scheme"]) -> + "invalid :scheme pseudo-header in promised request" + pseudo[":authority"] in [nil, ""] -> "missing or empty :authority pseudo-header in promised request" - not String.starts_with?(pseudo[":path"] || "", "/") -> + not valid_authority?(pseudo[":scheme"], pseudo[":authority"]) -> + "invalid :authority pseudo-header in promised request" + + not valid_path?(pseudo[":path"]) -> "missing or invalid :path pseudo-header in promised request" pseudo[":method"] not in ["GET", "HEAD"] -> @@ -2762,6 +2769,113 @@ defmodule Mint.HTTP2 do end end + # RFC 3986 3.1: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + defp valid_scheme?(<>) when is_alpha(char), do: uri_chars?(rest, :scheme) + defp valid_scheme?(_scheme), do: false + + # RFC 3986 3.2: authority = [ userinfo "@" ] host [ ":" port ]. RFC 9113 8.3.1 + # forbids userinfo for the "http" and "https" schemes, and RFC 9110 4.2.1 and + # 4.2.2 forbid an empty host for them. + defp valid_authority?(scheme, authority) do + http? = String.downcase(scheme, :ascii) in ["http", "https"] + + case String.split(authority, "@", parts: 2) do + [host_and_port] -> + valid_host_and_port?(host_and_port, http?) + + [userinfo, host_and_port] -> + not http? and uri_chars?(userinfo, :userinfo) and + valid_host_and_port?(host_and_port, http?) + end + end + + defp valid_host_and_port?("[" <> rest, _http?) do + case String.split(rest, "]", parts: 2) do + [ip_literal, port] -> valid_ip_literal?(ip_literal) and valid_port?(port) + [_rest] -> false + end + end + + defp valid_host_and_port?(host_and_port, http?) do + {reg_name, port} = + case String.split(host_and_port, ":", parts: 2) do + [reg_name] -> {reg_name, ""} + [reg_name, port] -> {reg_name, ":" <> port} + end + + # An IPv4address is also a valid reg-name. + (reg_name != "" or not http?) and uri_chars?(reg_name, :reg_name) and valid_port?(port) + end + + # RFC 3986 3.2.3: port = *DIGIT, after a ":". + defp valid_port?(""), do: true + defp valid_port?(":" <> port), do: port == "" or ParsingTools.only_digits?(port) + defp valid_port?(_port), do: false + + # RFC 3986 3.2.2: IP-literal = "[" ( IPv6address / IPvFuture ) "]", where + # IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ). RFC 6874 2 + # allows an IPv6address to be followed by "%25" and a zone ID. + defp valid_ip_literal?(<>) when v in ~c"vV" do + case String.split(rest, ".", parts: 2) do + [version, address] -> + version != "" and uri_chars?(version, :hex) and address != "" and + uri_chars?(address, :ipvfuture) + + [_rest] -> + false + end + end + + defp valid_ip_literal?(ip_literal) do + case String.split(ip_literal, "%25", parts: 2) do + [address] -> + ipv6_address?(address) + + [address, zone_id] -> + ipv6_address?(address) and zone_id != "" and uri_chars?(zone_id, :zone_id) + end + end + + # :inet.parse_ipv6strict_address/1 accepts a zone ID after a bare "%" and signs + # in embedded IPv4 octets, which the RFC 3986 3.2.2 IPv6address grammar doesn't + # allow, so the address is first restricted to HEXDIG, ":" and ".". + defp ipv6_address?(address) do + uri_chars?(address, :ipv6) and + match?({:ok, _}, :inet.parse_ipv6strict_address(:binary.bin_to_list(address))) + end + + # RFC 9113 8.3.1: :path is the RFC 9110 absolute-path, optionally followed by "?" + # and the RFC 3986 3.4 query, so it's made of pchar, "/" and "?" characters. + defp valid_path?("/" <> rest), do: uri_chars?(rest, :path) + defp valid_path?(_path), do: false + + # Checks that a binary is made of characters of the given class, and of RFC 3986 + # 2.1 pct-encoded characters for the classes that allow them. + defp uri_chars?(<>, class) + when class in [:userinfo, :reg_name, :zone_id, :path] and is_hex_digit(hex1) and + is_hex_digit(hex2), + do: uri_chars?(rest, class) + + defp uri_chars?(<>, class), + do: uri_char?(char, class) and uri_chars?(rest, class) + + defp uri_chars?(<<>>, _class), do: true + + # RFC 3986 2.3 unreserved and 2.2 sub-delims characters. + defguardp is_unreserved(char) when is_alpha(char) or is_digit(char) or char in ~c"-._~" + defguardp is_sub_delim(char) when char in ~c"!$&'()*+,;=" + + defp uri_char?(char, :scheme), do: is_alpha(char) or is_digit(char) or char in ~c"+-." + defp uri_char?(char, :hex), do: is_hex_digit(char) + defp uri_char?(char, :ipv6), do: is_hex_digit(char) or char in ~c":." + defp uri_char?(char, :zone_id), do: is_unreserved(char) + defp uri_char?(char, :reg_name), do: is_unreserved(char) or is_sub_delim(char) + + defp uri_char?(char, class) when class in [:userinfo, :ipvfuture], + do: is_unreserved(char) or is_sub_delim(char) or char == ?: + + defp uri_char?(char, :path), do: is_unreserved(char) or is_sub_delim(char) or char in ~c":@/?" + defp validate_promised_fields([], pseudo, _regular?), do: {:ok, pseudo} defp validate_promised_fields([{":" <> _ = name, value} | rest], pseudo, regular?) do diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 40d4f7e7..f4f4647b 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -2561,6 +2561,30 @@ defmodule Mint.HTTP2Test do {":authority", "localhost"}, {":path", "/"}, {"content-length", "zero"} + ], + space_in_path: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/a b"} + ], + fragment_in_path: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/a#fragment"} + ], + invalid_percent_encoding_in_path: [ + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "localhost"}, + {":path", "/a%2"} + ], + scheme_starting_with_digit: [ + {":method", "GET"}, + {":scheme", "1https"}, + {":authority", "localhost"}, + {":path", "/"} ] ] do test "a PUSH_PROMISE with #{variant} in the promised request resets the promised stream", @@ -2613,6 +2637,105 @@ defmodule Mint.HTTP2Test do end end + # RFC 6874 2: a zone ID is made of unreserved and percent-encoded characters. + invalid_zone_id_authorities = + for char <- ~c"!$&'()*+,;=", do: {"https", "[fe80::1%25en#{<>}0]"} + + for {scheme, authority} <- + [ + {"https", "user@localhost"}, + {"http", "user@localhost"}, + {"HTTPS", "user@localhost"}, + {"https", "localhost:abc"}, + {"https", "[::1"}, + {"https", "[garbage]"}, + {"https", "localhost:80:90"}, + {"https", "a[b]"}, + {"https", "[v.z]"}, + {"https", "]]]"}, + {"https", ":443"}, + {"https", "%zz"}, + {"https", "[fe80::1%en0]"}, + {"https", "[::ffff:192.+1.2.3]"}, + {"https", "[::ffff:01.2.3.4]"}, + {"https", "[fe80::1%25]"}, + {"https", "[v1.a%20]"}, + {"https", "[vg.x]"}, + {"ftp", "us[er@localhost"} + ] ++ invalid_zone_id_authorities do + test "a PUSH_PROMISE with :scheme #{scheme} and :authority #{inspect(authority)} " <> + "resets the promised stream", + %{conn: conn} do + {conn, _ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + promised_headers = [ + {":method", "GET"}, + {":scheme", unquote(scheme)}, + {":authority", unquote(authority)}, + {":path", "/"} + ] + + assert {:ok, %HTTP2{} = conn, []} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers), + promised_stream_id: 2, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + assert_recv_frames [rst_stream(stream_id: 2, error_code: :protocol_error)] + refute Map.has_key?(conn.streams, 2) + assert HTTP2.open?(conn) + end + end + + for {scheme, authority, path} <- [ + {"https", "[::1]:8443", "/"}, + {"https", "[fe80::1%25en0]:443", "/"}, + {"https", "[fe80::1%25en%210]", "/"}, + {"https", "[::ffff:192.1.2.3]", "/"}, + {"https", "[v1.fe80::a+en1]", "/"}, + {"https", "localhost:", "/"}, + {"https", "%41bc", "/"}, + {"https", "127.0.0.1:80", "/"}, + {"HTTPS", "localhost", "/"}, + {"ftp", "user@localhost", "/"}, + {"ftp", ":21", "/"}, + {"coap+tcp-1.0", "localhost", "//a/b;c=d/~e:f@g!$&'()*+,?h=i%20j/?k"} + ] do + test "a PUSH_PROMISE with :scheme #{scheme}, :authority #{inspect(authority)} and " <> + ":path #{inspect(path)} is accepted", + %{conn: conn} do + {conn, ref} = open_request(conn) + + assert_recv_frames [headers(stream_id: stream_id)] + + promised_headers = [ + {":method", "GET"}, + {":scheme", unquote(scheme)}, + {":authority", unquote(authority)}, + {":path", unquote(path)} + ] + + assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, _promised_ref, ^promised_headers}]} = + stream_frames(conn, [ + push_promise( + stream_id: stream_id, + hbf: server_encode_headers(promised_headers), + promised_stream_id: 2, + flags: set_flags(:push_promise, [:end_headers]) + ) + ]) + + refute_receive {:ssl, _socket, _data}, 100 + assert HTTP2.open?(conn) + end + end + for {name, promised_stream_id} <- [ {"zero", 0}, {"odd", 3},