Browse Source

Add 'max_cancel_stream_rate' config for the rapid reset attack

Co-authored-by: Björn Svensson <bjorn.a.svensson@est.tech>
Viktor Söderqvist 1 year ago
parent
commit
42d87dd776
3 changed files with 83 additions and 3 deletions
  1. 11 0
      doc/src/manual/cowboy_http2.asciidoc
  2. 32 3
      src/cowboy_http2.erl
  3. 40 0
      test/security_SUITE.erl

+ 11 - 0
doc/src/manual/cowboy_http2.asciidoc

@@ -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(),
@@ -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
@@ -256,6 +265,8 @@ too many `WINDOW_UPDATE` frames.
 
 == Changelog
 
+* *2.11*: Add the option `max_cancel_stream_rate` to protect
+          against another flood scenario.
 * *2.9*: The `goaway_initial_timeout` and `goaway_complete_timeout`
          options were added.
 * *2.8*: The `active_n` option was added.

+ 32 - 3
src/cowboy_http2.erl

@@ -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(),
@@ -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(),
 
@@ -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}),
@@ -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.
 
@@ -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} ->

+ 40 - 0
test/security_SUITE.erl

@@ -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
 	],
@@ -72,12 +73,51 @@ 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, []}
 	]}]).
 
 %% Tests.
 
+http2_cancel_flood(Config) ->
+	doc("Confirm that Cowboy detects the rapid reset attack. (CVE-2023-44487)"),
+	do_http2_cancel_flood(Config, 1, 500),
+	do_http2_cancel_flood(Config, 10, 50),
+	do_http2_cancel_flood(Config, 500, 1),
+	ok.
+
+do_http2_cancel_flood(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.
+
 http2_data_dribble(Config) ->
 	doc("Request a very large response then update the window 1 byte at a time. (CVE-2019-9511)"),
 	{ok, Socket} = rfc7540_SUITE:do_handshake(Config),