diff options
-rw-r--r-- | doc/src/manual/cowboy_http2.asciidoc | 42 | ||||
-rw-r--r-- | src/cowboy_http2.erl | 124 | ||||
-rw-r--r-- | src/cowboy_stream_h.erl | 11 | ||||
-rw-r--r-- | test/h2spec_SUITE.erl | 4 | ||||
-rw-r--r-- | test/rfc7540_SUITE.erl | 8 | ||||
-rw-r--r-- | test/security_SUITE.erl | 236 |
6 files changed, 375 insertions, 50 deletions
diff --git a/doc/src/manual/cowboy_http2.asciidoc b/doc/src/manual/cowboy_http2.asciidoc index e899289..2764705 100644 --- a/doc/src/manual/cowboy_http2.asciidoc +++ b/doc/src/manual/cowboy_http2.asciidoc @@ -26,11 +26,14 @@ opts() :: #{ initial_connection_window_size => 65535..16#7fffffff, initial_stream_window_size => 0..16#7fffffff, max_concurrent_streams => non_neg_integer() | infinity, + max_connection_buffer_size => non_neg_integer(), max_connection_window_size => 0..16#7fffffff, max_decode_table_size => non_neg_integer(), max_encode_table_size => non_neg_integer(), max_frame_size_received => 16384..16777215, max_frame_size_sent => 16384..16777215 | infinity, + max_received_frame_rate => {pos_integer(), timeout()}, + max_reset_stream_rate => {pos_integer(), timeout()}, max_stream_buffer_size => non_neg_integer(), max_stream_window_size => 0..16#7fffffff, preface_timeout => timeout(), @@ -38,6 +41,7 @@ opts() :: #{ sendfile => boolean(), settings_timeout => timeout(), stream_handlers => [module()], + stream_window_data_threshold => 0..16#7fffffff, stream_window_margin_size => 0..16#7fffffff, stream_window_update_threshold => 0..16#7fffffff } @@ -104,6 +108,12 @@ max_concurrent_streams (infinity):: Maximum number of concurrent streams allowed on the connection. +max_connection_buffer_size (16000000):: + +Maximum size of all stream buffers for this connection, in bytes. +This is a soft limit used to apply backpressure to handlers that +send data faster than the HTTP/2 connection allows. + max_connection_window_size (16#7fffffff):: Maximum connection window size in bytes. This is used as an upper bound @@ -137,6 +147,22 @@ following the client's advertised maximum. Note that actual frame sizes may be lower than the limit when there is not enough space left in the flow control window. +max_received_frame_rate ({1000, 10000}):: + +Maximum frame rate allowed per connection. The rate is expressed +as a tuple `{NumFrames, TimeMs}` indicating how many frames are +allowed over the given time period. This is similar to a supervisor +restart intensity/period. + +max_reset_stream_rate ({10, 10000}):: + +Maximum reset stream rate per connection. This can be used to +protect against misbehaving or malicious peers that do not follow +the protocol, leading to the server resetting streams, by limiting +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_stream_buffer_size (8000000):: Maximum stream buffer size in bytes. This is a soft limit used @@ -173,6 +199,13 @@ stream_handlers ([cowboy_stream_h]):: Ordered list of stream handlers that will handle all stream events. +stream_window_data_threshold (16384):: + +Window threshold in bytes below which Cowboy will not attempt +to send data, with one exception. When Cowboy has data to send +and the window is high enough, Cowboy will always send the data, +regardless of this option. + stream_window_margin_size (65535):: Extra amount in bytes to be added to the window size when @@ -193,9 +226,14 @@ too many `WINDOW_UPDATE` frames. `max_connection_window_size`, `max_stream_window_size`, `stream_window_margin_size` and `stream_window_update_threshold` to configure - behavior on sending WINDOW_UPDATE frames, and + behavior on sending WINDOW_UPDATE frames; + `max_connection_buffer_size` and `max_stream_buffer_size` to apply backpressure - when sending data too fast. + when sending data too fast; + `max_received_frame_rate` and `max_reset_stream_rate` + to protect against various flood scenarios; and + `stream_window_data_threshold` to control how small + the DATA frames that Cowboy sends can get. * *2.6*: The `proxy_header` and `sendfile` options were added. * *2.4*: Add the options `initial_connection_window_size`, `initial_stream_window_size`, `max_concurrent_streams`, diff --git a/src/cowboy_http2.erl b/src/cowboy_http2.erl index f98478a..c73c167 100644 --- a/src/cowboy_http2.erl +++ b/src/cowboy_http2.erl @@ -40,11 +40,14 @@ initial_stream_window_size => 0..16#7fffffff, logger => module(), max_concurrent_streams => non_neg_integer() | infinity, + max_connection_buffer_size => non_neg_integer(), max_connection_window_size => 0..16#7fffffff, max_decode_table_size => non_neg_integer(), max_encode_table_size => non_neg_integer(), max_frame_size_received => 16384..16777215, max_frame_size_sent => 16384..16777215 | infinity, + max_received_frame_rate => {pos_integer(), timeout()}, + max_reset_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(), @@ -55,6 +58,7 @@ settings_timeout => timeout(), shutdown_timeout => timeout(), stream_handlers => [module()], + stream_window_data_threshold => 0..16#7fffffff, stream_window_margin_size => 0..16#7fffffff, stream_window_update_threshold => 0..16#7fffffff, tracer_callback => cowboy_tracer_h:tracer_callback(), @@ -99,6 +103,14 @@ http2_status :: sequence | settings | upgrade | connected | closing, http2_machine :: cow_http2_machine:http2_machine(), + %% HTTP/2 frame rate flood protection. + frame_rate_num :: non_neg_integer(), + frame_rate_time :: integer(), + + %% HTTP/2 reset stream flood protection. + reset_rate_num :: non_neg_integer(), + reset_rate_time :: integer(), + %% Flow requested for all streams. flow = 0 :: non_neg_integer(), @@ -147,16 +159,28 @@ init(Parent, Ref, Socket, Transport, ProxyHeader, Opts) -> binary() | undefined, binary()) -> ok. init(Parent, Ref, Socket, Transport, ProxyHeader, Opts, Peer, Sock, Cert, Buffer) -> {ok, Preface, HTTP2Machine} = cow_http2_machine:init(server, Opts), - State = set_timeout(#state{parent=Parent, ref=Ref, socket=Socket, + State = set_timeout(init_rate_limiting(#state{parent=Parent, ref=Ref, socket=Socket, transport=Transport, proxy_header=ProxyHeader, opts=Opts, peer=Peer, sock=Sock, cert=Cert, - http2_status=sequence, http2_machine=HTTP2Machine}), + http2_status=sequence, http2_machine=HTTP2Machine})), Transport:send(Socket, Preface), case Buffer of <<>> -> loop(State, Buffer); _ -> parse(State, Buffer) end. +init_rate_limiting(State=#state{opts=Opts}) -> + {FrameRateNum, FrameRatePeriod} = maps:get(max_received_frame_rate, Opts, {1000, 10000}), + {ResetRateNum, ResetRatePeriod} = maps:get(max_reset_stream_rate, Opts, {10, 10000}), + CurrentTime = erlang:monotonic_time(millisecond), + State#state{ + frame_rate_num=FrameRateNum, frame_rate_time=add_period(CurrentTime, FrameRatePeriod), + reset_rate_num=ResetRateNum, reset_rate_time=add_period(CurrentTime, ResetRatePeriod) + }. + +add_period(_, infinity) -> infinity; +add_period(Time, Period) -> Time + Period. + %% @todo Add an argument for the request body. -spec init(pid(), ranch:ref(), inet:socket(), module(), ranch_proxy_header:proxy_info() | undefined, cowboy:opts(), @@ -179,7 +203,7 @@ init(Parent, Ref, Socket, Transport, ProxyHeader, Opts, Peer, Sock, Cert, Buffer <<"connection">> => <<"Upgrade">>, <<"upgrade">> => <<"h2c">> }, ?MODULE, undefined}), %% @todo undefined or #{}? - State = set_timeout(State2#state{http2_status=sequence}), + State = set_timeout(init_rate_limiting(State2#state{http2_status=sequence})), Transport:send(Socket, Preface), case Buffer of <<>> -> loop(State, Buffer); @@ -258,9 +282,9 @@ parse(State=#state{http2_status=Status, http2_machine=HTTP2Machine, streams=Stre MaxFrameSize = cow_http2_machine:get_local_setting(max_frame_size, HTTP2Machine), case cow_http2:parse(Data, MaxFrameSize) of {ok, Frame, Rest} -> - parse(frame(State, Frame), Rest); + parse(frame_rate(State, Frame), Rest); {ignore, Rest} -> - parse(ignored_frame(State), Rest); + parse(frame_rate(State, ignore), Rest); {stream_error, StreamID, Reason, Human, Rest} -> parse(reset_stream(State, StreamID, {stream_error, Reason, Human}), Rest); Error = {connection_error, _, _} -> @@ -272,6 +296,30 @@ parse(State=#state{http2_status=Status, http2_machine=HTTP2Machine, streams=Stre loop(State, Data) end. +%% Frame rate flood protection. + +frame_rate(State0=#state{opts=Opts, frame_rate_num=Num0, frame_rate_time=Time}, Frame) -> + {Result, State} = case Num0 - 1 of + 0 -> + CurrentTime = erlang:monotonic_time(millisecond), + if + CurrentTime < Time -> + {error, State0}; + true -> + %% When the option has a period of infinity we cannot reach this clause. + {Num, Period} = maps:get(max_received_frame_rate, Opts, {1000, 10000}), + {ok, State0#state{frame_rate_num=Num, frame_rate_time=CurrentTime + Period}} + end; + Num -> + {ok, State0#state{frame_rate_num=Num}} + end, + case {Result, Frame} of + {ok, ignore} -> ignored_frame(State); + {ok, _} -> frame(State, Frame); + {error, _} -> terminate(State, {connection_error, enhance_your_calm, + 'Frame rate larger than configuration allows. Flood? (CVE-2019-9512, CVE-2019-9515, CVE-2019-9518)'}) + end. + %% Frames received. %% We do nothing when receiving a lingering DATA frame. @@ -763,27 +811,42 @@ send_data_frame(State=#state{socket=Socket, transport=Transport, %% We do this by comparing the HTTP2Machine buffer state before/after for %% the relevant streams. maybe_send_data_alarm(State=#state{opts=Opts, http2_machine=HTTP2Machine}, HTTP2Machine0, StreamID) -> - {ok, BufferSizeBefore} = cow_http2_machine:get_stream_local_buffer_size(StreamID, HTTP2Machine0), + ConnBufferSizeBefore = cow_http2_machine:get_connection_local_buffer_size(HTTP2Machine0), + ConnBufferSizeAfter = cow_http2_machine:get_connection_local_buffer_size(HTTP2Machine), + {ok, StreamBufferSizeBefore} = cow_http2_machine:get_stream_local_buffer_size(StreamID, HTTP2Machine0), %% When the stream ends up closed after it finished sending data, %% we do not want to trigger an alarm. We act as if the buffer %% size did not change. - BufferSizeAfter = case cow_http2_machine:get_stream_local_buffer_size(StreamID, HTTP2Machine) of + StreamBufferSizeAfter = case cow_http2_machine:get_stream_local_buffer_size(StreamID, HTTP2Machine) of {ok, BSA} -> BSA; - {error, closed} -> BufferSizeBefore + {error, closed} -> StreamBufferSizeBefore end, - MaxBufferSize = maps:get(max_stream_buffer_size, Opts, 8000000), - %% I do not want to document these internal_events yet. I am not yet + MaxConnBufferSize = maps:get(max_connection_buffer_size, Opts, 16000000), + MaxStreamBufferSize = maps:get(max_stream_buffer_size, Opts, 8000000), + %% I do not want to document these internal events yet. I am not yet %% convinced it should be {alarm, Name, on|off} and not {internal_event, E} - %% or something else entirely. + %% or something else entirely. Though alarms are probably right. if - BufferSizeBefore >= MaxBufferSize, BufferSizeAfter < MaxBufferSize -> - info(State, StreamID, {alarm, stream_buffer_full, off}); - BufferSizeBefore < MaxBufferSize, BufferSizeAfter >= MaxBufferSize -> - info(State, StreamID, {alarm, stream_buffer_full, on}); + ConnBufferSizeBefore >= MaxConnBufferSize, ConnBufferSizeAfter < MaxConnBufferSize -> + connection_alarm(State, connection_buffer_full, off); + ConnBufferSizeBefore < MaxConnBufferSize, ConnBufferSizeAfter >= MaxConnBufferSize -> + connection_alarm(State, connection_buffer_full, on); + StreamBufferSizeBefore >= MaxStreamBufferSize, StreamBufferSizeAfter < MaxStreamBufferSize -> + stream_alarm(State, StreamID, stream_buffer_full, off); + StreamBufferSizeBefore < MaxStreamBufferSize, StreamBufferSizeAfter >= MaxStreamBufferSize -> + stream_alarm(State, StreamID, stream_buffer_full, on); true -> State end. +connection_alarm(State0=#state{streams=Streams}, Name, Value) -> + lists:foldl(fun(StreamID, State) -> + stream_alarm(State, StreamID, Name, Value) + end, State0, maps:keys(Streams)). + +stream_alarm(State, StreamID, Name, Value) -> + info(State, StreamID, {alarm, Name, Value}). + %% Terminate a stream or the connection. %% We may have to cancel streams even if we receive multiple @@ -855,18 +918,41 @@ terminate_all_streams(State, [{StreamID, #stream{state=StreamState}}|Tail], Reas terminate_all_streams(State, Tail, Reason). %% @todo Don't send an RST_STREAM if one was already sent. -reset_stream(State=#state{socket=Socket, transport=Transport, +reset_stream(State0=#state{socket=Socket, transport=Transport, http2_machine=HTTP2Machine0}, StreamID, Error) -> Reason = case Error of {internal_error, _, _} -> internal_error; {stream_error, Reason0, _} -> Reason0 end, Transport:send(Socket, cow_http2:rst_stream(StreamID, Reason)), - case cow_http2_machine:reset_stream(StreamID, HTTP2Machine0) of + State1 = case cow_http2_machine:reset_stream(StreamID, HTTP2Machine0) of {ok, HTTP2Machine} -> - terminate_stream(State#state{http2_machine=HTTP2Machine}, StreamID, Error); + terminate_stream(State0#state{http2_machine=HTTP2Machine}, StreamID, Error); {error, not_found} -> - terminate_stream(State, StreamID, Error) + terminate_stream(State0, StreamID, Error) + end, + case reset_rate(State1) of + {ok, State} -> + State; + error -> + terminate(State1, {connection_error, enhance_your_calm, + 'Stream reset rate larger than configuration allows. Flood? (CVE-2019-9514)'}) + end. + +reset_rate(State0=#state{opts=Opts, reset_rate_num=Num0, reset_rate_time=Time}) -> + case Num0 - 1 of + 0 -> + CurrentTime = erlang:monotonic_time(millisecond), + if + CurrentTime < Time -> + error; + true -> + %% When the option has a period of infinity we cannot reach this clause. + {Num, Period} = maps:get(max_reset_stream_rate, Opts, {10, 10000}), + {ok, State0#state{reset_rate_num=Num, reset_rate_time=CurrentTime + Period}} + end; + Num -> + {ok, State0#state{reset_rate_num=Num}} end. stop_stream(State=#state{http2_machine=HTTP2Machine}, StreamID) -> diff --git a/src/cowboy_stream_h.erl b/src/cowboy_stream_h.erl index cc9e271..9397726 100644 --- a/src/cowboy_stream_h.erl +++ b/src/cowboy_stream_h.erl @@ -220,8 +220,8 @@ info(StreamID, Response={response, _, _, _}, State) -> do_info(StreamID, Response, [Response], State#state{expect=undefined}); info(StreamID, Headers={headers, _, _}, State) -> do_info(StreamID, Headers, [Headers], State#state{expect=undefined}); -%% Sending data involves the data message and the stream_buffer_full alarm. -%% We stop sending acks when the alarm is on. +%% Sending data involves the data message, the stream_buffer_full alarm +%% and the connection_buffer_full alarm. We stop sending acks when an alarm is on. info(StreamID, Data={data, _, _}, State0=#state{pid=Pid, stream_body_status=Status}) -> State = case Status of normal -> @@ -233,10 +233,13 @@ info(StreamID, Data={data, _, _}, State0=#state{pid=Pid, stream_body_status=Stat State0 end, do_info(StreamID, Data, [Data], State); -info(StreamID, Alarm={alarm, stream_buffer_full, on}, State) -> +info(StreamID, Alarm={alarm, Name, on}, State) + when Name =:= connection_buffer_full; Name =:= stream_buffer_full -> do_info(StreamID, Alarm, [], State#state{stream_body_status=blocking}); -info(StreamID, Alarm={alarm, stream_buffer_full, off}, State=#state{pid=Pid, stream_body_status=Status}) -> +info(StreamID, Alarm={alarm, Name, off}, State=#state{pid=Pid, stream_body_status=Status}) + when Name =:= connection_buffer_full; Name =:= stream_buffer_full -> _ = case Status of + normal -> ok; blocking -> ok; blocked -> Pid ! {data_ack, self()} end, diff --git a/test/h2spec_SUITE.erl b/test/h2spec_SUITE.erl index 2ed9df8..13beea6 100644 --- a/test/h2spec_SUITE.erl +++ b/test/h2spec_SUITE.erl @@ -35,7 +35,9 @@ init_per_suite(Config) -> true -> cowboy_test:init_http(h2spec, #{ env => #{dispatch => init_dispatch()}, - max_concurrent_streams => 100 + max_concurrent_streams => 100, + %% Disable the DATA threshold for this test suite. + stream_window_data_threshold => 0 }, Config) end end. diff --git a/test/rfc7540_SUITE.erl b/test/rfc7540_SUITE.erl index 4f27dfa..ebd9341 100644 --- a/test/rfc7540_SUITE.erl +++ b/test/rfc7540_SUITE.erl @@ -34,11 +34,15 @@ groups() -> init_per_group(Name = clear, Config) -> cowboy_test:init_http(Name, #{ - env => #{dispatch => cowboy_router:compile(init_routes(Config))} + env => #{dispatch => cowboy_router:compile(init_routes(Config))}, + %% Disable the DATA threshold for this test suite. + stream_window_data_threshold => 0 }, Config); init_per_group(Name = tls, Config) -> cowboy_test:init_http2(Name, #{ - env => #{dispatch => cowboy_router:compile(init_routes(Config))} + env => #{dispatch => cowboy_router:compile(init_routes(Config))}, + %% Disable the DATA threshold for this test suite. + stream_window_data_threshold => 0 }, Config). end_per_group(Name, _) -> diff --git a/test/security_SUITE.erl b/test/security_SUITE.erl index 4d8a68c..684b78a 100644 --- a/test/security_SUITE.erl +++ b/test/security_SUITE.erl @@ -30,7 +30,28 @@ all() -> cowboy_test:common_all(). groups() -> - cowboy_test:common_groups(ct_helper:all(?MODULE)). + Tests = [nc_rand, nc_zero], + H1Tests = [slowloris, slowloris_chunks], + H2CTests = [ + http2_data_dribble, + http2_empty_frame_flooding_data, + http2_empty_frame_flooding_headers_continuation, + http2_empty_frame_flooding_push_promise, + http2_ping_flood, + http2_reset_flood, + http2_settings_flood, + http2_zero_length_header_leak + ], + [ + {http, [parallel], Tests ++ H1Tests}, + {https, [parallel], Tests ++ H1Tests}, + {h2, [parallel], Tests}, + {h2c, [parallel], Tests ++ H2CTests}, + {http_compress, [parallel], Tests ++ H1Tests}, + {https_compress, [parallel], Tests ++ H1Tests}, + {h2_compress, [parallel], Tests}, + {h2c_compress, [parallel], Tests ++ H2CTests} + ]. init_per_suite(Config) -> ct_helper:create_static_dir(config(priv_dir, Config) ++ "/static"), @@ -49,11 +70,200 @@ end_per_group(Name, _) -> init_dispatch(_) -> cowboy_router:compile([{"localhost", [ - {"/", hello_h, []} + {"/", hello_h, []}, + {"/echo/:key", echo_h, []}, + {"/resp/:key[/:arg]", resp_h, []} ]}]). %% Tests. +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), + %% Send a GET request for a very large response. + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/resp/stream_body/loop">>} + ]), + ok = gen_tcp:send(Socket, cow_http2:headers(1, fin, HeadersBlock)), + %% Receive a response with a few DATA frames draining the window. + {ok, <<SkipLen:24, 1:8, _:8, 1:32>>} = gen_tcp:recv(Socket, 9, 1000), + {ok, _} = gen_tcp:recv(Socket, SkipLen, 1000), + {ok, <<16384:24, 0:8, 0:8, 1:32, _:16384/unit:8>>} = gen_tcp:recv(Socket, 9 + 16384, 1000), + {ok, <<16384:24, 0:8, 0:8, 1:32, _:16384/unit:8>>} = gen_tcp:recv(Socket, 9 + 16384, 1000), + {ok, <<16384:24, 0:8, 0:8, 1:32, _:16384/unit:8>>} = gen_tcp:recv(Socket, 9 + 16384, 1000), + {ok, <<16383:24, 0:8, 0:8, 1:32, _:16383/unit:8>>} = gen_tcp:recv(Socket, 9 + 16383, 1000), + %% Send WINDOW_UPDATE frames with a value of 1. The server should + %% not attempt to send data until the window is over a configurable threshold. + ok = gen_tcp:send(Socket, [ + cow_http2:window_update(1), + cow_http2:window_update(1, 1) + ]), + {error, timeout} = gen_tcp:recv(Socket, 0, 1000), + ok. + +http2_empty_frame_flooding_data(Config) -> + doc("Confirm that Cowboy detects empty DATA frame flooding. (CVE-2019-9518)"), + {ok, Socket} = rfc7540_SUITE:do_handshake(Config), + %% Send a POST request followed by many empty DATA frames. + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"POST">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/echo/read_body">>} + ]), + ok = gen_tcp:send(Socket, cow_http2:headers(1, nofin, HeadersBlock)), + _ = [gen_tcp:send(Socket, cow_http2:data(1, nofin, <<>>)) || _ <- lists:seq(1, 2000)], + %% When Cowboy detects a flood it must close the connection. + %% We skip WINDOW_UPDATE frames sent when Cowboy starts to read the body. + case gen_tcp:recv(Socket, 43, 6000) of + {ok, <<_:26/unit:8, _:24, 7:8, _:72, 11:32>>} -> + 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_empty_frame_flooding_headers_continuation(Config) -> + doc("Confirm that Cowboy detects empty HEADERS/CONTINUATION frame flooding. (CVE-2019-9518)"), + {ok, Socket} = rfc7540_SUITE:do_handshake(Config), + %% Send many empty HEADERS/CONTINUATION frames before the headers. + ok = gen_tcp:send(Socket, <<0:24, 1:8, 0:9, 1:31>>), + _ = [gen_tcp:send(Socket, <<0:24, 9:8, 0:9, 1:31>>) || _ <- lists:seq(1, 2000)], + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"POST">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/">>} + ]), + Len = iolist_size(HeadersBlock), + _ = gen_tcp:send(Socket, [<<Len:24, 9:8, 0:5, 1:1, 0:1, 1:1, 0:1, 1:31>>, HeadersBlock]), + %% When Cowboy detects a flood it must close the connection. + case gen_tcp:recv(Socket, 17, 6000) of + {ok, <<_:24, 7:8, _:72, 11:32>>} -> + 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_empty_frame_flooding_push_promise(Config) -> + doc("Confirm that Cowboy detects empty PUSH_PROMISE frame flooding. (CVE-2019-9518)"), + {ok, Socket} = rfc7540_SUITE:do_handshake(Config), + %% Send a HEADERS frame to which we will attach a PUSH_PROMISE. + %% We use nofin in order to keep the stream alive. + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/">>} + ]), + ok = gen_tcp:send(Socket, cow_http2:headers(1, nofin, HeadersBlock)), + %% Send nofin PUSH_PROMISE frame without any data. + ok = gen_tcp:send(Socket, <<4:24, 5:8, 0:8, 0:1, 1:31, 0:1, 3:31>>), + %% Receive a PROTOCOL_ERROR connection error. + %% + %% Cowboy rejects all PUSH_PROMISE frames therefore no flooding + %% can take place. + {ok, <<_:24, 7:8, _:72, 1:32>>} = gen_tcp:recv(Socket, 17, 6000), + ok. + +%% @todo http2_internal_data_buffering(Config) -> I do not know how to test this. +% doc("Request many very large responses, with a larger than necessary window size, " +% "but do not attempt to read from the socket. (CVE-2019-9517)"), + +http2_ping_flood(Config) -> + doc("Confirm that Cowboy detects PING floods. (CVE-2019-9512)"), + {ok, Socket} = rfc7540_SUITE:do_handshake(Config), + %% Flood the server with PING frames. + _ = [gen_tcp:send(Socket, cow_http2:ping(0)) || _ <- lists:seq(1, 2000)], + %% Receive a number of PING ACK frames in return, following by the closing of the connection. + try + [case gen_tcp:recv(Socket, 17, 6000) of + {ok, <<8:24, 6:8, _:7, 1:1, _:32, 0:64>>} -> ok; + {ok, <<_:24, 7:8, _:72, 11:32>>} -> throw(goaway); + %% We also accept the connection being closed immediately, + %% which may happen because we send the GOAWAY right before closing. + {error, closed} -> throw(goaway) + end || _ <- lists:seq(1, 2000)], + error(flood_successful) + catch throw:goaway -> + ok + end. + +http2_reset_flood(Config) -> + doc("Confirm that Cowboy detects reset floods. (CVE-2019-9514)"), + {ok, Socket} = rfc7540_SUITE:do_handshake(Config), + %% Flood the server with HEADERS frames without a :method pseudo-header. + {HeadersBlock, _} = cow_hpack:encode([ + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/">>} + ]), + _ = [gen_tcp:send(Socket, cow_http2:headers(ID, fin, HeadersBlock)) || ID <- lists:seq(1, 100, 2)], + %% Receive a number of RST_STREAM frames in return, following by the closing of the connection. + try + [case gen_tcp:recv(Socket, 13, 6000) of + {ok, <<_:24, 3:8, _:8, ID:32, 1:32>>} -> ok; + {ok, <<_:24, 7:8, _:72>>} -> + {ok, <<11:32>>} = gen_tcp:recv(Socket, 4, 1000), + throw(goaway); + %% We also accept the connection being closed immediately, + %% which may happen because we send the GOAWAY right before closing. + {error, closed} -> + throw(goaway) + end || ID <- lists:seq(1, 100, 2)], + error(flood_successful) + catch throw:goaway -> + 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 + +http2_settings_flood(Config) -> + doc("Confirm that Cowboy detects SETTINGS floods. (CVE-2019-9515)"), + {ok, Socket} = rfc7540_SUITE:do_handshake(Config), + %% Flood the server with empty SETTINGS frames. + _ = [gen_tcp:send(Socket, cow_http2:settings(#{})) || _ <- lists:seq(1, 2000)], + %% Receive a number of SETTINGS ACK frames in return, following by the closing of the connection. + try + [case gen_tcp:recv(Socket, 9, 6000) of + {ok, <<0:24, 4:8, 0:7, 1:1, 0:32>>} -> ok; + {ok, <<_:24, 7:8, _:40>>} -> + {ok, <<_:32, 11:32>>} = gen_tcp:recv(Socket, 8, 1000), + throw(goaway); + %% We also accept the connection being closed immediately, + %% which may happen because we send the GOAWAY right before closing. + {error, closed} -> + throw(goaway) + end || _ <- lists:seq(1, 2000)], + error(flood_successful) + catch throw:goaway -> + ok + end. + +http2_zero_length_header_leak(Config) -> + doc("Confirm that Cowboy rejects HEADERS frame with a 0-length header name. (CVE-2019-9516)"), + {ok, Socket} = rfc7540_SUITE:do_handshake(Config), + %% Send a GET request with a 0-length header name. + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/">>}, + {<<>>, <<"CVE-2019-9516">>} + ]), + ok = gen_tcp:send(Socket, cow_http2:headers(1, fin, HeadersBlock)), + %% Receive a PROTOCOL_ERROR stream error. + {ok, <<_:24, 3:8, _:8, 1:32, 1:32>>} = gen_tcp:recv(Socket, 13, 6000), + ok. + nc_rand(Config) -> doc("Throw random garbage at the server, then check if it's still up."), do_nc(Config, "/dev/urandom"). @@ -67,9 +277,9 @@ do_nc(Config, Input) -> Nc = os:find_executable("nc"), case {Cat, Nc} of {false, _} -> - {skip, {not_found, cat}}; + {skip, "The cat executable was not found."}; {_, false} -> - {skip, {not_found, nc}}; + {skip, "The nc executable was not found."}; _ -> StrPort = integer_to_list(config(port, Config)), _ = [ @@ -84,15 +294,6 @@ do_nc(Config, Input) -> slowloris(Config) -> doc("Send request headers one byte at a time. " "Confirm that the connection gets closed."), - _ = case config(protocol, Config) of - http -> - do_http_slowloris(Config); - http2 -> - %% @todo Write an equivalent test for HTTP2. - ok - end. - -do_http_slowloris(Config) -> Client = raw_open(Config), try [begin @@ -107,15 +308,6 @@ do_http_slowloris(Config) -> end. slowloris_chunks(Config) -> - _ = case config(protocol, Config) of - http -> - do_http_slowloris_chunks(Config); - http2 -> - %% @todo Write an equivalent test for HTTP2. - ok - end. - -do_http_slowloris_chunks(Config) -> doc("Send request headers one line at a time. " "Confirm that the connection gets closed."), Client = raw_open(Config), |