Skip to content

Commit b662d12

Browse files
authored
Merge commit from fork
A malicious or compromised HTTP/2 server could exhaust client memory by sending a HEADERS frame without END_HEADERS followed by an unbounded chain of CONTINUATION frames. Each fragment was appended to `conn.headers_being_processed` with no cap on size or count, and the block is only decoded once END_HEADERS arrives — which a hostile server simply never sends. A single connection could drive the client to OOM (~1 GiB from ~64k 16 KiB frames in the reported PoC). The client now bounds the accumulated header block by the locally advertised SETTINGS_MAX_HEADER_LIST_SIZE, which defaults to 256 KB (previously :infinity, i.e. unbounded). The limit is checked against the compressed accumulator as each fragment is parked, before it grows, and the connection is torn down with a PROTOCOL_ERROR GOAWAY once exceeded. The default is also advertised to the server so well-behaved peers never send oversized blocks; the receive-side check backstops misbehaving ones. Because the compressed accumulator is never larger than the header list it decodes to, the limit never rejects a header block that fits within the advertised size. The bound is tracked with a running byte count carried in `headers_being_processed`, so enforcement is O(1) per frame rather than rescanning the accumulator (which would be O(n^2) under a flood).
1 parent fad0914 commit b662d12

2 files changed

Lines changed: 123 additions & 8 deletions

File tree

lib/mint/http2.ex

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ defmodule Mint.HTTP2 do
156156
@default_max_frame_size 16_384
157157
@valid_max_frame_size_range @default_max_frame_size..16_777_215
158158

159+
# Default cap on the size of an inbound header block. Advertised to the
160+
# server as SETTINGS_MAX_HEADER_LIST_SIZE and enforced while accumulating
161+
# HEADERS/CONTINUATION fragments, so that a server cannot exhaust client
162+
# memory by streaming an unbounded chain of CONTINUATION frames.
163+
@default_max_header_list_size 256 * 1024
164+
159165
@valid_client_settings [
160166
:max_concurrent_streams,
161167
:initial_window_size,
@@ -235,7 +241,7 @@ defmodule Mint.HTTP2 do
235241
client_settings: %{
236242
max_concurrent_streams: 100,
237243
initial_window_size: @default_stream_window_size,
238-
max_header_list_size: :infinity,
244+
max_header_list_size: @default_max_header_list_size,
239245
max_frame_size: @default_max_frame_size,
240246
enable_push: true
241247
},
@@ -297,7 +303,12 @@ defmodule Mint.HTTP2 do
297303
* `:max_frame_size` - corresponds to `SETTINGS_MAX_FRAME_SIZE`. Tells what is the
298304
maximum size of an HTTP/2 frame for the peer that sends this setting.
299305
300-
* `:max_header_list_size` - corresponds to `SETTINGS_MAX_HEADER_LIST_SIZE`.
306+
* `:max_header_list_size` - corresponds to `SETTINGS_MAX_HEADER_LIST_SIZE`. For the
307+
client, this also bounds the size of an inbound header block (a HEADERS frame plus
308+
its trailing CONTINUATION frames): the connection is closed with a connection error
309+
if a server streams a header block larger than this value, which prevents a server
310+
from exhausting client memory with an unbounded chain of CONTINUATION frames.
311+
Defaults to `256 KB` for the client.
301312
302313
* `:enable_connect_protocol` - corresponds to `SETTINGS_ENABLE_CONNECT_PROTOCOL`.
303314
Sets whether the client may invoke the extended connect protocol which is used to
@@ -1140,7 +1151,9 @@ defmodule Mint.HTTP2 do
11401151
client_settings_params = Keyword.get(opts, :client_settings, [])
11411152

11421153
client_settings_params =
1143-
Keyword.put_new(client_settings_params, :initial_window_size, @default_stream_window_size)
1154+
client_settings_params
1155+
|> Keyword.put_new(:initial_window_size, @default_stream_window_size)
1156+
|> Keyword.put_new(:max_header_list_size, @default_max_header_list_size)
11441157

11451158
validate_client_settings!(client_settings_params)
11461159
# If the port is the default for the scheme, don't add it to the :authority pseudo-header
@@ -1728,7 +1741,7 @@ defmodule Mint.HTTP2 do
17281741
{nil, _frame} ->
17291742
:ok
17301743

1731-
{{stream_id, _, _}, continuation(stream_id: stream_id)} ->
1744+
{{stream_id, _, _, _}, continuation(stream_id: stream_id)} ->
17321745
:ok
17331746

17341747
_other ->
@@ -1906,7 +1919,7 @@ defmodule Mint.HTTP2 do
19061919
decode_hbf_and_add_responses(conn, responses, hbf, stream, end_stream?)
19071920
else
19081921
callback = &decode_hbf_and_add_responses(&1, &2, &3, &4, end_stream?)
1909-
conn = put_in(conn.headers_being_processed, {stream_id, hbf, callback})
1922+
conn = start_headers_being_processed(conn, stream_id, hbf, callback)
19101923
{conn, responses}
19111924
end
19121925
end
@@ -2237,7 +2250,7 @@ defmodule Mint.HTTP2 do
22372250
)
22382251
else
22392252
callback = &decode_push_promise_headers_and_add_response(&1, &2, &3, &4, promised_stream_id)
2240-
conn = put_in(conn.headers_being_processed, {stream_id, hbf, callback})
2253+
conn = start_headers_being_processed(conn, stream_id, hbf, callback)
22412254
{conn, responses}
22422255
end
22432256
end
@@ -2392,18 +2405,53 @@ defmodule Mint.HTTP2 do
23922405
assert_stream_in_state(conn, stream, [:open, :half_closed_local, :reserved_remote])
23932406
end
23942407

2395-
{^stream_id, hbf_acc, callback} = conn.headers_being_processed
2408+
{^stream_id, hbf_acc, callback, acc_size} = conn.headers_being_processed
23962409

23972410
if flag_set?(flags, :continuation, :end_headers) do
23982411
hbf = IO.iodata_to_binary([hbf_acc, hbf_chunk])
23992412
conn = put_in(conn.headers_being_processed, nil)
24002413
callback.(conn, responses, hbf, stream)
24012414
else
2402-
conn = put_in(conn.headers_being_processed, {stream_id, [hbf_acc, hbf_chunk], callback})
2415+
new_size = acc_size + byte_size(hbf_chunk)
2416+
conn = assert_header_block_within_max_size(conn, new_size)
2417+
2418+
conn =
2419+
put_in(
2420+
conn.headers_being_processed,
2421+
{stream_id, [hbf_acc, hbf_chunk], callback, new_size}
2422+
)
2423+
24032424
{conn, responses}
24042425
end
24052426
end
24062427

2428+
defp start_headers_being_processed(conn, stream_id, hbf, callback) do
2429+
hbf_size = byte_size(hbf)
2430+
conn = assert_header_block_within_max_size(conn, hbf_size)
2431+
put_in(conn.headers_being_processed, {stream_id, hbf, callback, hbf_size})
2432+
end
2433+
2434+
# The header block accumulated from a HEADERS frame and its trailing
2435+
# CONTINUATION frames is buffered (in compressed form) until END_HEADERS
2436+
# arrives. A server can withhold END_HEADERS and stream CONTINUATION frames
2437+
# indefinitely, so the buffered size is bounded by the locally advertised
2438+
# SETTINGS_MAX_HEADER_LIST_SIZE. The compressed accumulator is never larger
2439+
# than the uncompressed header list it decodes to, so this never rejects a
2440+
# header block that fits within the advertised limit.
2441+
defp assert_header_block_within_max_size(conn, size) do
2442+
case conn.client_settings.max_header_list_size do
2443+
:infinity ->
2444+
conn
2445+
2446+
max_size when size > max_size ->
2447+
debug_data = "header block exceeds SETTINGS_MAX_HEADER_LIST_SIZE of #{max_size} bytes"
2448+
send_connection_error!(conn, :protocol_error, debug_data)
2449+
2450+
_max_size ->
2451+
conn
2452+
end
2453+
end
2454+
24072455
## General helpers
24082456

24092457
defp send_connection_error!(conn, error_code, debug_data) do

test/mint/http2/conn_test.exs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,73 @@ defmodule Mint.HTTP2Test do
963963
end)
964964
end
965965

966+
@tag connect_options: [client_settings: [max_header_list_size: 1_000]]
967+
test "a flood of CONTINUATION frames past max_header_list_size is a connection error",
968+
%{conn: conn} do
969+
{conn, _ref} = open_request(conn)
970+
971+
assert_recv_frames [headers(stream_id: stream_id)]
972+
973+
# Each CONTINUATION is individually under the limit, but together they
974+
# accumulate past the advertised SETTINGS_MAX_HEADER_LIST_SIZE. The client
975+
# must refuse to buffer the header block without bound rather than growing
976+
# `headers_being_processed` until it runs out of memory.
977+
chunk = :binary.copy(<<0>>, 400)
978+
979+
assert {:error, %HTTP2{} = conn, error, []} =
980+
stream_frames(conn, [
981+
headers(stream_id: stream_id, hbf: "", flags: set_flags(:headers, [])),
982+
continuation(
983+
stream_id: stream_id,
984+
hbf: chunk,
985+
flags: set_flags(:continuation, [])
986+
),
987+
continuation(
988+
stream_id: stream_id,
989+
hbf: chunk,
990+
flags: set_flags(:continuation, [])
991+
),
992+
continuation(
993+
stream_id: stream_id,
994+
hbf: chunk,
995+
flags: set_flags(:continuation, [])
996+
)
997+
])
998+
999+
assert_http2_error error, {:protocol_error, debug_data}
1000+
assert debug_data =~ "SETTINGS_MAX_HEADER_LIST_SIZE"
1001+
1002+
assert_recv_frames [goaway(error_code: :protocol_error)]
1003+
1004+
refute HTTP2.open?(conn)
1005+
end
1006+
1007+
@tag connect_options: [client_settings: [max_header_list_size: 1_000]]
1008+
test "a single oversized HEADERS fragment past max_header_list_size is a connection error",
1009+
%{conn: conn} do
1010+
{conn, _ref} = open_request(conn)
1011+
1012+
assert_recv_frames [headers(stream_id: stream_id)]
1013+
1014+
oversized_hbf = :binary.copy(<<0>>, 2_000)
1015+
1016+
assert {:error, %HTTP2{} = conn, error, []} =
1017+
stream_frames(conn, [
1018+
headers(stream_id: stream_id, hbf: oversized_hbf, flags: set_flags(:headers, []))
1019+
])
1020+
1021+
assert_http2_error error, {:protocol_error, debug_data}
1022+
assert debug_data =~ "SETTINGS_MAX_HEADER_LIST_SIZE"
1023+
1024+
assert_recv_frames [goaway(error_code: :protocol_error)]
1025+
1026+
refute HTTP2.open?(conn)
1027+
end
1028+
1029+
test "client advertises a default SETTINGS_MAX_HEADER_LIST_SIZE", %{conn: conn} do
1030+
assert HTTP2.get_client_setting(conn, :max_header_list_size) == 256 * 1024
1031+
end
1032+
9661033
test ":authority pseudo-header includes port", %{conn: conn} do
9671034
{conn, _ref} = open_request(conn)
9681035

0 commit comments

Comments
 (0)