Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 'max_cancel_stream_rate' config for the rapid reset attack #1617

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/src/manual/cowboy_http2.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ opts() :: #{
max_frame_size_sent => 16384..16777215 | infinity,
max_received_frame_rate => {pos_integer(), timeout()},
max_reset_stream_rate => {pos_integer(), timeout()},
max_cancel_stream_rate => {pos_integer(), timeout()},
max_stream_buffer_size => non_neg_integer(),
max_stream_window_size => 0..16#7fffffff,
preface_timeout => timeout(),
Expand Down Expand Up @@ -198,6 +199,14 @@ the number of streams that can be reset over a certain time period.
The rate is expressed as a tuple `{NumResets, TimeMs}`. This is
similar to a supervisor restart intensity/period.

max_cancel_stream_rate ({500, 10000})::

Maximum cancel stream rate per connection. This can be used to
protect against misbehaving or malicious peers, by limiting the
number of streams that the peer can reset over a certain time period.
The rate is expressed as a tuple `{NumCancels, TimeMs}`. This is
similar to a supervisor restart intensity/period.

max_stream_buffer_size (8000000)::

Maximum stream buffer size in bytes. This is a soft limit used
Expand Down
35 changes: 32 additions & 3 deletions src/cowboy_http2.erl
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
max_frame_size_sent => 16384..16777215 | infinity,
max_received_frame_rate => {pos_integer(), timeout()},
max_reset_stream_rate => {pos_integer(), timeout()},
max_cancel_stream_rate => {pos_integer(), timeout()},
max_stream_buffer_size => non_neg_integer(),
max_stream_window_size => 0..16#7fffffff,
metrics_callback => cowboy_metrics_h:metrics_callback(),
Expand Down Expand Up @@ -114,6 +115,10 @@
reset_rate_num :: undefined | pos_integer(),
reset_rate_time :: undefined | integer(),

%% HTTP/2 rapid reset attack protection.
cancel_rate_num :: undefined | pos_integer(),
cancel_rate_time :: undefined | integer(),

%% Flow requested for all streams.
flow = 0 :: non_neg_integer(),

Expand Down Expand Up @@ -173,9 +178,11 @@ init(Parent, Ref, Socket, Transport, ProxyHeader, Opts, Peer, Sock, Cert, Buffer
_ -> parse(State, Buffer)
end.

init_rate_limiting(State) ->
init_rate_limiting(State0) ->
CurrentTime = erlang:monotonic_time(millisecond),
init_reset_rate_limiting(init_frame_rate_limiting(State, CurrentTime), CurrentTime).
State1 = init_frame_rate_limiting(State0, CurrentTime),
State2 = init_reset_rate_limiting(State1, CurrentTime),
init_cancel_rate_limiting(State2, CurrentTime).

init_frame_rate_limiting(State=#state{opts=Opts}, CurrentTime) ->
{FrameRateNum, FrameRatePeriod} = maps:get(max_received_frame_rate, Opts, {10000, 10000}),
Expand All @@ -189,6 +196,12 @@ init_reset_rate_limiting(State=#state{opts=Opts}, CurrentTime) ->
reset_rate_num=ResetRateNum, reset_rate_time=add_period(CurrentTime, ResetRatePeriod)
}.

init_cancel_rate_limiting(State=#state{opts=Opts}, CurrentTime) ->
{CancelRateNum, CancelRatePeriod} = maps:get(max_cancel_stream_rate, Opts, {500, 10000}),
State#state{
cancel_rate_num=CancelRateNum, cancel_rate_time=add_period(CurrentTime, CancelRatePeriod)
}.

add_period(_, infinity) -> infinity;
add_period(Time, Period) -> Time + Period.

Expand Down Expand Up @@ -568,11 +581,27 @@ rst_stream_frame(State=#state{streams=Streams0, children=Children0}, StreamID, R
{#stream{state=StreamState}, Streams} ->
terminate_stream_handler(State, StreamID, Reason, StreamState),
Children = cowboy_children:shutdown(Children0, StreamID),
State#state{streams=Streams, children=Children};
cancel_rate_limit(State#state{streams=Streams, children=Children});
error ->
State
end.

cancel_rate_limit(State0=#state{cancel_rate_num=Num0, cancel_rate_time=Time}) ->
case Num0 - 1 of
0 ->
CurrentTime = erlang:monotonic_time(millisecond),
if
CurrentTime < Time ->
terminate(State0, {connection_error, enhance_your_calm,
'Stream cancel rate larger than configuration allows. Flood? (CVE-2023-44487)'});
true ->
%% When the option has a period of infinity we cannot reach this clause.
init_cancel_rate_limiting(State0, CurrentTime)
end;
Num ->
State0#state{cancel_rate_num=Num}
end.

ignored_frame(State=#state{http2_machine=HTTP2Machine0}) ->
case cow_http2_machine:ignored_frame(HTTP2Machine0) of
{ok, HTTP2Machine} ->
Expand Down
40 changes: 40 additions & 0 deletions test/security_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ groups() ->
http2_empty_frame_flooding_push_promise,
http2_ping_flood,
http2_reset_flood,
http2_cancel_flood,
http2_settings_flood,
http2_zero_length_header_leak
],
Expand Down Expand Up @@ -72,6 +73,7 @@ init_dispatch(_) ->
cowboy_router:compile([{"localhost", [
{"/", hello_h, []},
{"/echo/:key", echo_h, []},
{"/delay_hello", delay_hello_h, 1000},
{"/long_polling", long_polling_h, []},
{"/resp/:key[/:arg]", resp_h, []}
]}]).
Expand Down Expand Up @@ -229,6 +231,44 @@ http2_reset_flood(Config) ->
ok
end.

http2_cancel_flood(Config) ->
doc("Confirm that Cowboy detects the rapid reset attack. (CVE-2023-44487)"),
http2_cancel_flood_helper(Config, 1, 500),
http2_cancel_flood_helper(Config, 10, 50),
http2_cancel_flood_helper(Config, 500, 1),
ok.

http2_cancel_flood_helper(Config, NumStreamsPerBatch, NumBatches) ->
{ok, Socket} = rfc7540_SUITE:do_handshake(Config),
{HeadersBlock, _} = cow_hpack:encode([
{<<":method">>, <<"GET">>},
{<<":scheme">>, <<"http">>},
{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
{<<":path">>, <<"/delay_hello">>}
]),
AllStreamIDs = lists:seq(1, NumBatches * NumStreamsPerBatch * 2, 2),
_ = lists:foldl(
fun (_BatchNumber, AvailableStreamIDs) ->
%% Take a bunch of IDs from the available stream IDs.
%% Send HEADERS for all these and then cancel them.
{IDs, RemainingStreamIDs} = lists:split(NumStreamsPerBatch, AvailableStreamIDs),
_ = gen_tcp:send(Socket, [cow_http2:headers(ID, fin, HeadersBlock) || ID <- IDs]),
_ = gen_tcp:send(Socket, [<<4:24, 3:8, 0:8, ID:32, 8:32>> || ID <- IDs]),
RemainingStreamIDs
end,
AllStreamIDs,
lists:seq(1, NumBatches, 1)),
%% When Cowboy detects a flood it must close the connection.
case gen_tcp:recv(Socket, 17, 6000) of
{ok, <<_:24, 7:8, 0:8, 0:32, _LastStreamId:32, 11:32>>} ->
%% GOAWAY with error code 11 = enhance your calm
ok;
%% We also accept the connection being closed immediately,
%% which may happen because we send the GOAWAY right before closing.
{error, closed} ->
ok
end.

%% @todo If we ever implement the PRIORITY mechanism, this test should
%% be implemented as well. CVE-2019-9513 https://www.kb.cert.org/vuls/id/605641/
%% http2_resource_loop
Expand Down
Loading