Skip to content

Commit 0bb8d24

Browse files
authored
RFC Conformance: Enforce HTTP/2 trailer field section requirements
I had Claude check the code against various RFC conformance, and it flagged below. Claude helped with the desc. ## Summary Validate request trailers as an HTTP/2 field section and require the trailer HEADERS frame to carry `END_STREAM`. ## What was not conformant Bandit accepted a second request HEADERS frame without `END_STREAM`, logged and ignored its fields, and continued reading DATA. It also checked only for pseudo-headers in trailers, so the normal HTTP/2 rules for lowercase names, field values, connection-specific fields, and `TE` were not applied. [RFC 9113 section 8.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.1) says a trailer field section must terminate the stream. It specifically requires `END_STREAM` on the terminating HEADERS frame and says a trailer section without it is a malformed request. [RFC 9113 section 8.2.1](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.1), [RFC 9110 section 5.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.1), and [RFC 9113 section 8.2.2](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.2) define the field-name, field-value, and connection-specific field rules that apply to HTTP/2 field sections. ## Implementation - Reject trailer HEADERS unless the frame carries `END_STREAM`. - Continue rejecting pseudo-headers in trailers. - Apply Bandit's existing lowercase-name, field-value, connection-specific field, and `TE` validators to trailers before discarding them. - Enforce the full regular field-name token grammar and the HTTP/2 prohibition on edge SP or HTAB in trailer values. - Validate every member of every `TE` field line, while accepting repeated `trailers` members. - Match RFC 9113's exact connection-specific field list without rejecting unrelated extension fields by name. - Keep the existing behavior of accepting and ignoring a valid trailer section. - Treat a trailer field value that HPAX decodes as `nil` (an indexed static-table entry with no value) as the empty value it represents, instead of letting it crash validation (elixir-mint/hpax#27). This is intentionally a focused change. It does not add trailer exposure to Plug and does not alter valid request-body handling. ## Tests The protocol tests cover: - acceptance of a valid trailer section with `END_STREAM`, including an unrelated extension field named `trailers`; - rejection of a trailer section without `END_STREAM` with stream `PROTOCOL_ERROR`; - rejection of a connection-specific trailer field; - rejection when any of multiple `TE` field lines contains a value other than `trailers`; - rejection of invalid trailer field names; and - rejection of CR and edge whitespace in trailer field values; and - acceptance of a trailer field encoded as an indexed static-table entry with no value. Run: ```console mix test test/bandit/http2/protocol_test.exs ``` Verified against Bandit `main` at `b084b37` with Erlang/OTP 27.3 and Elixir 1.18.4: - `mix format --check-formatted` - `MIX_ENV=test mix compile --warnings-as-errors` - Full non-slow suite: 821 tests pass (the suite's one IPv6 server binding test needs an IPv6-capable host) - `mix credo --strict` and `mix dialyzer` pass on the combined tree of all nineteen prepared Bandit changes - h2spec 2.6.0 passes in full on the combined tree - HTTP/2 protocol suite: 184 tests pass
1 parent b084b37 commit 0bb8d24

2 files changed

Lines changed: 199 additions & 6 deletions

File tree

lib/bandit/http2/stream.ex

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,15 +219,25 @@ defmodule Bandit.HTTP2.Stream do
219219
# specific cases by RFC9113§8.2.2. We check those cases in a separate filter
220220
defp no_connection_headers!(headers, stream) do
221221
connection_headers =
222-
~w[connection keep-alive proxy-authenticate proxy-authorization proxy-connection trailers transfer-encoding upgrade]
222+
~w[connection keep-alive proxy-connection transfer-encoding upgrade]
223223

224224
if Enum.any?(headers, fn {key, _value} -> key in connection_headers end),
225225
do: stream_error!("Received connection-specific header", stream)
226226
end
227227

228228
# RFC9113§8.2.2 - TE header may be present if it contains exactly 'trailers'
229229
defp valid_te_header!(headers, stream) do
230-
if Bandit.Headers.get_header(headers, "te") not in [nil, "trailers"],
230+
invalid? =
231+
headers
232+
|> Enum.filter(fn {name, _value} -> name == "te" end)
233+
|> Enum.any?(fn {"te", value} ->
234+
case Plug.Conn.Utils.list(value) do
235+
[] -> true
236+
members -> Enum.any?(members, &(String.downcase(&1, :ascii) != "trailers"))
237+
end
238+
end)
239+
240+
if invalid?,
231241
do: stream_error!("Received invalid TE header", stream)
232242
end
233243

@@ -266,7 +276,6 @@ defmodule Bandit.HTTP2.Stream do
266276
when state in [:open, :local_closed] do
267277
case do_recv(stream, timeout) do
268278
{:headers, trailers, stream} ->
269-
no_pseudo_headers!(trailers, stream)
270279
Logger.warning("Ignoring trailers #{inspect(trailers)}", domain: [:bandit])
271280
do_read_data(stream, max_bytes, timeout, acc)
272281

@@ -297,6 +306,56 @@ defmodule Bandit.HTTP2.Stream do
297306
do: stream_error!("Received trailers with pseudo headers", stream)
298307
end
299308

309+
defp validate_trailers!(_headers, false, stream) do
310+
stream_error!("Received trailers without END_STREAM", stream)
311+
end
312+
313+
# RFC9113§8.1 - a trailer field section terminates the stream and is subject
314+
# to the same field-name and field-value requirements as other field sections
315+
defp validate_trailers!(headers, true, stream) do
316+
# HPAX decodes an indexed static-table entry with no value to a nil value; treat it
317+
# as the empty binary it represents (elixir-mint/hpax#27)
318+
headers =
319+
Enum.map(headers, fn
320+
{name, nil} -> {name, ""}
321+
header -> header
322+
end)
323+
324+
no_pseudo_headers!(headers, stream)
325+
headers_all_lowercase!(headers, stream)
326+
valid_trailer_field_names!(headers, stream)
327+
no_connection_headers!(headers, stream)
328+
valid_te_header!(headers, stream)
329+
valid_field_values!(headers, stream)
330+
valid_trailer_edge_whitespace!(headers, stream)
331+
end
332+
333+
defp valid_trailer_field_names!(headers, stream) do
334+
if Enum.any?(headers, fn {key, _value} -> not valid_trailer_field_name?(key) end),
335+
do: stream_error!("Received invalid trailer field name (RFC9113§8.2.1)", stream)
336+
end
337+
338+
defp valid_trailer_field_name?(<<>>), do: false
339+
defp valid_trailer_field_name?(name), do: valid_trailer_field_name_bytes?(name)
340+
341+
defp valid_trailer_field_name_bytes?(<<char, rest::binary>>)
342+
when char in ?a..?z or char in ?0..?9 or char in ~c"!#$%&'*+-.^_`|~",
343+
do: valid_trailer_field_name_bytes?(rest)
344+
345+
defp valid_trailer_field_name_bytes?(<<_char, _rest::binary>>), do: false
346+
defp valid_trailer_field_name_bytes?(<<>>), do: true
347+
348+
defp valid_trailer_edge_whitespace!(headers, stream) do
349+
if Enum.any?(headers, fn
350+
{_key, <<>>} ->
351+
false
352+
353+
{_key, value} ->
354+
:binary.first(value) in [0x09, 0x20] or :binary.last(value) in [0x09, 0x20]
355+
end),
356+
do: stream_error!("Field value contains invalid characters (RFC9113§8.2.1)", stream)
357+
end
358+
300359
defp do_recv(%@for{state: :idle} = stream, timeout) do
301360
receive do
302361
{:bandit, {:headers, headers, end_stream}} ->
@@ -319,6 +378,7 @@ defmodule Bandit.HTTP2.Stream do
319378
when state in [:open, :local_closed] do
320379
receive do
321380
{:bandit, {:headers, headers, end_stream}} ->
381+
validate_trailers!(headers, end_stream, stream)
322382
{:headers, headers, stream |> do_recv_headers() |> do_recv_end_stream(end_stream)}
323383

324384
{:bandit, {:data, data, end_stream}} ->
@@ -560,8 +620,9 @@ defmodule Bandit.HTTP2.Stream do
560620

561621
def ensure_completed(%@for{state: :local_closed} = stream) do
562622
receive do
563-
{:bandit, {:headers, _headers, true}} ->
564-
do_recv_end_stream(stream, true)
623+
{:bandit, {:headers, headers, end_stream}} ->
624+
validate_trailers!(headers, end_stream, stream)
625+
do_recv_end_stream(stream, end_stream)
565626

566627
{:bandit, {:data, data, true}} ->
567628
do_recv_data(stream, data, true) |> do_recv_end_stream(true)

test/bandit/http2/protocol_test.exs

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2073,7 +2073,9 @@ defmodule HTTP2ProtocolTest do
20732073

20742074
{:ok, ctx} = SimpleH2Client.send_simple_headers(socket, 1, :post, "/echo", context.port)
20752075
SimpleH2Client.send_body(socket, 1, false, "OK")
2076-
SimpleH2Client.send_headers(socket, 1, true, [{"x-trailer", "trailer"}], ctx)
2076+
# An extension field named "trailers" is not one of RFC9113§8.2.2's
2077+
# connection-specific fields and must not be rejected merely by name.
2078+
SimpleH2Client.send_headers(socket, 1, true, [{"trailers", "extension-value"}], ctx)
20772079

20782080
{:ok, 0, _} = SimpleH2Client.recv_window_update(socket)
20792081
{:ok, 1, _} = SimpleH2Client.recv_window_update(socket)
@@ -2084,6 +2086,136 @@ defmodule HTTP2ProtocolTest do
20842086
assert SimpleH2Client.connection_alive?(socket)
20852087
end
20862088

2089+
@tag :capture_log
2090+
test "accepts a trailer field encoded as an indexed entry with no value", context do
2091+
socket = SimpleH2Client.setup_connection(context)
2092+
2093+
{:ok, _ctx} = SimpleH2Client.send_simple_headers(socket, 1, :post, "/echo", context.port)
2094+
SimpleH2Client.send_body(socket, 1, false, "OK")
2095+
2096+
# Static-table index 58 (`user-agent`) as an indexed field, which HPAX decodes with
2097+
# a nil value (elixir-mint/hpax#27); as a trailer it must validate as an empty value
2098+
SimpleH2Client.send_frame(socket, 1, 0x05, 1, <<0xBA>>)
2099+
2100+
{:ok, 0, _} = SimpleH2Client.recv_window_update(socket)
2101+
{:ok, 1, _} = SimpleH2Client.recv_window_update(socket)
2102+
2103+
assert SimpleH2Client.successful_response?(socket, 1, false)
2104+
assert SimpleH2Client.recv_body(socket) == {:ok, 1, true, "OK"}
2105+
2106+
assert SimpleH2Client.connection_alive?(socket)
2107+
end
2108+
2109+
@tag :capture_log
2110+
test "rejects trailer HEADERS without END_STREAM", context do
2111+
socket = SimpleH2Client.setup_connection(context)
2112+
2113+
{:ok, ctx} = SimpleH2Client.send_simple_headers(socket, 1, :post, "/echo", context.port)
2114+
SimpleH2Client.send_body(socket, 1, false, "OK")
2115+
2116+
{:ok, 0, _} = SimpleH2Client.recv_window_update(socket)
2117+
{:ok, 1, _} = SimpleH2Client.recv_window_update(socket)
2118+
2119+
SimpleH2Client.send_headers(socket, 1, false, [{"x-trailer", "trailer"}], ctx)
2120+
2121+
assert SimpleH2Client.recv_rst_stream(socket) == {:ok, 1, 1}
2122+
assert SimpleH2Client.connection_alive?(socket)
2123+
2124+
assert_receive {:log, %{level: :error, msg: {:string, msg}, meta: %{stream_id: 1}}}, 500
2125+
assert msg == "** (Bandit.HTTP2.Errors.StreamError) Received trailers without END_STREAM"
2126+
end
2127+
2128+
@tag :capture_log
2129+
test "validates connection-specific fields in trailers", context do
2130+
socket = SimpleH2Client.setup_connection(context)
2131+
2132+
{:ok, ctx} = SimpleH2Client.send_simple_headers(socket, 1, :post, "/echo", context.port)
2133+
SimpleH2Client.send_body(socket, 1, false, "OK")
2134+
2135+
{:ok, 0, _} = SimpleH2Client.recv_window_update(socket)
2136+
{:ok, 1, _} = SimpleH2Client.recv_window_update(socket)
2137+
2138+
SimpleH2Client.send_headers(socket, 1, true, [{"connection", "close"}], ctx)
2139+
2140+
assert SimpleH2Client.recv_rst_stream(socket) == {:ok, 1, 1}
2141+
assert SimpleH2Client.connection_alive?(socket)
2142+
2143+
assert_receive {:log, %{level: :error, msg: {:string, msg}, meta: %{stream_id: 1}}}, 500
2144+
assert msg == "** (Bandit.HTTP2.Errors.StreamError) Received connection-specific header"
2145+
end
2146+
2147+
@tag :capture_log
2148+
test "validates every TE field line in trailers", context do
2149+
socket = SimpleH2Client.setup_connection(context)
2150+
2151+
{:ok, ctx} = SimpleH2Client.send_simple_headers(socket, 1, :post, "/echo", context.port)
2152+
SimpleH2Client.send_body(socket, 1, false, "OK")
2153+
2154+
{:ok, 0, _} = SimpleH2Client.recv_window_update(socket)
2155+
{:ok, 1, _} = SimpleH2Client.recv_window_update(socket)
2156+
2157+
SimpleH2Client.send_headers(socket, 1, true, [{"te", "trailers"}, {"te", "gzip"}], ctx)
2158+
2159+
assert SimpleH2Client.recv_rst_stream(socket) == {:ok, 1, 1}
2160+
assert SimpleH2Client.connection_alive?(socket)
2161+
2162+
assert_receive {:log, %{level: :error, msg: {:string, msg}, meta: %{stream_id: 1}}}, 500
2163+
assert msg == "** (Bandit.HTTP2.Errors.StreamError) Received invalid TE header"
2164+
end
2165+
2166+
@tag :capture_log
2167+
test "validates field values in trailers", context do
2168+
socket = SimpleH2Client.setup_connection(context)
2169+
2170+
{:ok, ctx} = SimpleH2Client.send_simple_headers(socket, 1, :post, "/echo", context.port)
2171+
SimpleH2Client.send_body(socket, 1, false, "OK")
2172+
2173+
{:ok, 0, _} = SimpleH2Client.recv_window_update(socket)
2174+
{:ok, 1, _} = SimpleH2Client.recv_window_update(socket)
2175+
2176+
SimpleH2Client.send_headers(socket, 1, true, [{"x-trailer", "bad\rvalue"}], ctx)
2177+
2178+
assert SimpleH2Client.recv_rst_stream(socket) == {:ok, 1, 1}
2179+
assert SimpleH2Client.connection_alive?(socket)
2180+
2181+
assert_receive {:log, %{level: :error, msg: {:string, msg}, meta: %{stream_id: 1}}}, 500
2182+
2183+
assert msg ==
2184+
"** (Bandit.HTTP2.Errors.StreamError) Field value contains invalid characters (RFC9113§8.2.1)"
2185+
end
2186+
2187+
@tag :capture_log
2188+
test "validates regular field-name grammar in trailers", context do
2189+
socket = SimpleH2Client.setup_connection(context)
2190+
2191+
{:ok, ctx} = SimpleH2Client.send_simple_headers(socket, 1, :post, "/echo", context.port)
2192+
SimpleH2Client.send_body(socket, 1, false, "OK")
2193+
2194+
{:ok, 0, _} = SimpleH2Client.recv_window_update(socket)
2195+
{:ok, 1, _} = SimpleH2Client.recv_window_update(socket)
2196+
2197+
SimpleH2Client.send_headers(socket, 1, true, [{"bad/name", "value"}], ctx)
2198+
2199+
assert SimpleH2Client.recv_rst_stream(socket) == {:ok, 1, 1}
2200+
assert SimpleH2Client.connection_alive?(socket)
2201+
end
2202+
2203+
@tag :capture_log
2204+
test "rejects edge whitespace in trailer field values", context do
2205+
socket = SimpleH2Client.setup_connection(context)
2206+
2207+
{:ok, ctx} = SimpleH2Client.send_simple_headers(socket, 1, :post, "/echo", context.port)
2208+
SimpleH2Client.send_body(socket, 1, false, "OK")
2209+
2210+
{:ok, 0, _} = SimpleH2Client.recv_window_update(socket)
2211+
{:ok, 1, _} = SimpleH2Client.recv_window_update(socket)
2212+
2213+
SimpleH2Client.send_headers(socket, 1, true, [{"x-trailer", "trailing "}], ctx)
2214+
2215+
assert SimpleH2Client.recv_rst_stream(socket) == {:ok, 1, 1}
2216+
assert SimpleH2Client.connection_alive?(socket)
2217+
end
2218+
20872219
@tag :capture_log
20882220
test "rejects HEADER frames sent as trailers that contain pseudo headers", context do
20892221
socket = SimpleH2Client.setup_connection(context)

0 commit comments

Comments
 (0)