diff options
Diffstat (limited to 'test')
50 files changed, 5510 insertions, 404 deletions
diff --git a/test/compress_SUITE.erl b/test/compress_SUITE.erl index a25c427..a6a100c 100644 --- a/test/compress_SUITE.erl +++ b/test/compress_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -23,12 +23,20 @@ %% ct. all() -> - [ + All = [ {group, http_compress}, {group, https_compress}, {group, h2_compress}, - {group, h2c_compress} - ]. + {group, h2c_compress}, + {group, h3_compress} + ], + %% Don't run HTTP/3 tests on Windows for now. + case os:type() of + {win32, _} -> + All -- [{group, h3_compress}]; + _ -> + All + end. groups() -> cowboy_test:common_groups(ct_helper:all(?MODULE)). @@ -37,7 +45,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Routes. @@ -67,7 +75,7 @@ gzip_accept_encoding_malformed(Config) -> {200, Headers, _} = do_get("/reply/large", [{<<"accept-encoding">>, <<";">>}], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -76,7 +84,7 @@ gzip_accept_encoding_missing(Config) -> {200, Headers, _} = do_get("/reply/large", [], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -85,7 +93,7 @@ gzip_accept_encoding_no_gzip(Config) -> {200, Headers, _} = do_get("/reply/large", [{<<"accept-encoding">>, <<"compress">>}], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -94,7 +102,7 @@ gzip_accept_encoding_not_supported(Config) -> {200, Headers, _} = do_get("/reply/large", [{<<"accept-encoding">>, <<"application/gzip">>}], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -105,7 +113,18 @@ gzip_reply_content_encoding(Config) -> %% We set the content-encoding to compress; without actually compressing. {_, <<"compress">>} = lists:keyfind(<<"content-encoding">>, 1, Headers), %% The reply didn't include a vary header. - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), + ok. + +gzip_reply_etag(Config) -> + doc("Reply with etag header; get an uncompressed response."), + {200, Headers, _} = do_get("/reply/etag", + [{<<"accept-encoding">>, <<"gzip">>}], Config), + %% We set a strong etag. + {_, <<"\"STRONK\"">>} = lists:keyfind(<<"etag">>, 1, Headers), + %% The reply didn't include a vary header. + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100000">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -125,7 +144,7 @@ gzip_reply_sendfile(Config) -> {200, Headers, Body} = do_get("/reply/sendfile", [{<<"accept-encoding">>, <<"gzip">>}], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), ct:log("Body received:~n~p~n", [Body]), ok. @@ -134,7 +153,7 @@ gzip_reply_small_body(Config) -> {200, Headers, _} = do_get("/reply/small", [{<<"accept-encoding">>, <<"gzip">>}], Config), false = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), {_, <<"100">>} = lists:keyfind(<<"content-length">>, 1, Headers), ok. @@ -170,7 +189,16 @@ gzip_stream_reply_content_encoding(Config) -> {200, Headers, Body} = do_get("/stream_reply/content-encoding", [{<<"accept-encoding">>, <<"gzip">>}], Config), {_, <<"compress">>} = lists:keyfind(<<"content-encoding">>, 1, Headers), - false = lists:keyfind(<<"vary">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), + 100000 = iolist_size(Body), + ok. + +gzip_stream_reply_etag(Config) -> + doc("Stream reply with etag header; get an uncompressed response."), + {200, Headers, Body} = do_get("/stream_reply/etag", + [{<<"accept-encoding">>, <<"gzip">>}], Config), + {_, <<"\"STRONK\"">>} = lists:keyfind(<<"etag">>, 1, Headers), + {_, <<"accept-encoding">>} = lists:keyfind(<<"vary">>, 1, Headers), 100000 = iolist_size(Body), ok. diff --git a/test/cowboy_ct_hook.erl b/test/cowboy_ct_hook.erl index 7d5a889..e76ec21 100644 --- a/test/cowboy_ct_hook.erl +++ b/test/cowboy_ct_hook.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2014-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2014-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/cowboy_test.erl b/test/cowboy_test.erl index 7ebe618..5a8fb13 100644 --- a/test/cowboy_test.erl +++ b/test/cowboy_test.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2014-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2014-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -27,45 +27,92 @@ init_http(Ref, ProtoOpts, Config) -> init_https(Ref, ProtoOpts, Config) -> Opts = ct_helper:get_certs_from_ets(), - {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}], ProtoOpts), + {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}, {verify, verify_none}], ProtoOpts), Port = ranch:get_port(Ref), [{ref, Ref}, {type, ssl}, {protocol, http}, {port, Port}, {opts, Opts}|Config]. init_http2(Ref, ProtoOpts, Config) -> Opts = ct_helper:get_certs_from_ets(), - {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}], ProtoOpts), + {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}, {verify, verify_none}], ProtoOpts), Port = ranch:get_port(Ref), [{ref, Ref}, {type, ssl}, {protocol, http2}, {port, Port}, {opts, Opts}|Config]. +%% @todo This will probably require TransOpts as argument. +init_http3(Ref, ProtoOpts, Config) -> + %% @todo Quicer does not currently support non-file cert/key, + %% so we use quicer test certificates for now. + %% @todo Quicer also does not support cacerts which means + %% we currently have no authentication based security. + DataDir = filename:dirname(filename:dirname(config(data_dir, Config))) + ++ "/rfc9114_SUITE_data", + TransOpts = #{ + socket_opts => [ + {certfile, DataDir ++ "/server.pem"}, + {keyfile, DataDir ++ "/server.key"} + ] + }, + {ok, Listener} = cowboy:start_quic(Ref, TransOpts, ProtoOpts), + {ok, {_, Port}} = quicer:sockname(Listener), + %% @todo Keep listener information around in a better place. + persistent_term:put({cowboy_test_quic, Ref}, Listener), + [{ref, Ref}, {type, quic}, {protocol, http3}, {port, Port}, {opts, TransOpts}|Config]. + +stop_group(Ref) -> + case persistent_term:get({cowboy_test_quic, Ref}, undefined) of + undefined -> + cowboy:stop_listener(Ref); + Listener -> + quicer:close_listener(Listener) + end. + %% Common group of listeners used by most suites. common_all() -> - [ + All = [ {group, http}, {group, https}, {group, h2}, {group, h2c}, + {group, h3}, {group, http_compress}, {group, https_compress}, {group, h2_compress}, - {group, h2c_compress} - ]. + {group, h2c_compress}, + {group, h3_compress} + ], + %% Don't run HTTP/3 tests on Windows for now. + case os:type() of + {win32, _} -> + All -- [{group, h3}, {group, h3_compress}]; + _ -> + All + end. common_groups(Tests) -> Opts = case os:getenv("NO_PARALLEL") of false -> [parallel]; _ -> [] end, - [ + Groups = [ {http, Opts, Tests}, {https, Opts, Tests}, {h2, Opts, Tests}, {h2c, Opts, Tests}, + {h3, Opts, Tests}, {http_compress, Opts, Tests}, {https_compress, Opts, Tests}, {h2_compress, Opts, Tests}, - {h2c_compress, Opts, Tests} - ]. + {h2c_compress, Opts, Tests}, + {h3_compress, Opts, Tests} + ], + %% Don't run HTTP/3 tests on Windows for now. + case os:type() of + {win32, _} -> + Groups -- [{h3, Opts, Tests}, {h3_compress, Opts, Tests}]; + _ -> + Groups + end. + init_common_groups(Name = http, Config, Mod) -> init_http(Name, #{ @@ -84,6 +131,10 @@ init_common_groups(Name = h2c, Config, Mod) -> env => #{dispatch => Mod:init_dispatch(Config)} }, [{flavor, vanilla}|Config]), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_common_groups(Name = h3, Config, Mod) -> + init_http3(Name, #{ + env => #{dispatch => Mod:init_dispatch(Config)} + }, [{flavor, vanilla}|Config]); init_common_groups(Name = http_compress, Config, Mod) -> init_http(Name, #{ env => #{dispatch => Mod:init_dispatch(Config)}, @@ -104,7 +155,12 @@ init_common_groups(Name = h2c_compress, Config, Mod) -> env => #{dispatch => Mod:init_dispatch(Config)}, stream_handlers => [cowboy_compress_h, cowboy_stream_h] }, [{flavor, compress}|Config]), - lists:keyreplace(protocol, 1, Config1, {protocol, http2}). + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_common_groups(Name = h3_compress, Config, Mod) -> + init_http3(Name, #{ + env => #{dispatch => Mod:init_dispatch(Config)}, + stream_handlers => [cowboy_compress_h, cowboy_stream_h] + }, [{flavor, compress}|Config]). %% Support functions for testing using Gun. @@ -112,10 +168,14 @@ gun_open(Config) -> gun_open(Config, #{}). gun_open(Config, Opts) -> + TlsOpts = case proplists:get_value(no_cert, Config, false) of + true -> [{verify, verify_none}]; + false -> ct_helper:get_certs_from_ets() %% @todo Wrong in current quicer. + end, {ok, ConnPid} = gun:open("localhost", config(port, Config), Opts#{ retry => 0, transport => config(type, Config), - tls_opts => proplists:get_value(tls_opts, Config, []), + tls_opts => TlsOpts, protocols => [config(protocol, Config)] }), ConnPid. @@ -153,6 +213,12 @@ raw_recv_head(Socket, Transport, Buffer) -> Buffer end. +raw_recv_rest({raw_client, _, _}, Length, Buffer) when Length =:= byte_size(Buffer) -> + Buffer; +raw_recv_rest({raw_client, Socket, Transport}, Length, Buffer) when Length > byte_size(Buffer) -> + {ok, Data} = Transport:recv(Socket, Length - byte_size(Buffer), 10000), + << Buffer/binary, Data/binary >>. + raw_recv({raw_client, Socket, Transport}, Length, Timeout) -> Transport:recv(Socket, Length, Timeout). diff --git a/test/decompress_SUITE.erl b/test/decompress_SUITE.erl new file mode 100644 index 0000000..f61bb5d --- /dev/null +++ b/test/decompress_SUITE.erl @@ -0,0 +1,421 @@ +%% Copyright (c) 2024, jdamanalo <[email protected]> +%% Copyright (c) 2024, Loïc Hoguin <[email protected]> +%% +%% Permission to use, copy, modify, and/or distribute this software for any +%% purpose with or without fee is hereby granted, provided that the above +%% copyright notice and this permission notice appear in all copies. +%% +%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +-module(decompress_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). +-import(cowboy_test, [gun_open/1]). + +%% ct. + +all() -> + cowboy_test:common_all(). + +groups() -> + cowboy_test:common_groups(ct_helper:all(?MODULE)). + +init_per_group(Name = http, Config) -> + cowboy_test:init_http(Name, init_plain_opts(Config), Config); +init_per_group(Name = https, Config) -> + cowboy_test:init_http(Name, init_plain_opts(Config), Config); +init_per_group(Name = h2, Config) -> + cowboy_test:init_http2(Name, init_plain_opts(Config), Config); +init_per_group(Name = h2c, Config) -> + Config1 = cowboy_test:init_http(Name, init_plain_opts(Config), Config), + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, init_plain_opts(Config), Config); +init_per_group(Name = http_compress, Config) -> + cowboy_test:init_http(Name, init_compress_opts(Config), Config); +init_per_group(Name = https_compress, Config) -> + cowboy_test:init_http(Name, init_compress_opts(Config), Config); +init_per_group(Name = h2_compress, Config) -> + cowboy_test:init_http2(Name, init_compress_opts(Config), Config); +init_per_group(Name = h2c_compress, Config) -> + Config1 = cowboy_test:init_http(Name, init_compress_opts(Config), Config), + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3_compress, Config) -> + cowboy_test:init_http3(Name, init_compress_opts(Config), Config). + +end_per_group(Name, _) -> + cowboy:stop_listener(Name). + +init_plain_opts(Config) -> + #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config))}, + stream_handlers => [cowboy_decompress_h, cowboy_stream_h] + }. + +init_compress_opts(Config) -> + #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config))}, + stream_handlers => [cowboy_decompress_h, cowboy_compress_h, cowboy_stream_h] + }. + +init_routes(_) -> + [{'_', [ + {"/echo/:what", decompress_h, echo}, + {"/test/:what", decompress_h, test} + ]}]. + +%% Internal. + +do_post(Path, ReqHeaders, Body, Config) -> + ConnPid = gun_open(Config), + Ref = gun:post(ConnPid, Path, ReqHeaders, Body), + {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref), + {ok, ResponseBody} = case IsFin of + nofin -> gun:await_body(ConnPid, Ref); + fin -> {ok, <<>>} + end, + gun:close(ConnPid), + {Status, RespHeaders, ResponseBody}. + +create_gzip_bomb() -> + Z = zlib:open(), + zlib:deflateInit(Z, 9, deflated, 31, 8, default), + %% 1000 chunks of 100000 zeroes (100MB). + Bomb = do_create_gzip_bomb(Z, 1000), + zlib:deflateEnd(Z), + zlib:close(Z), + iolist_to_binary(Bomb). + +do_create_gzip_bomb(Z, 0) -> + zlib:deflate(Z, << >>, finish); +do_create_gzip_bomb(Z, N) -> + Data = <<0:800000>>, + Deflate = zlib:deflate(Z, Data), + [Deflate | do_create_gzip_bomb(Z, N - 1)]. + +%% Tests. + +content_encoding_none(Config) -> + doc("Requests without content-encoding are processed normally."), + Body = <<"test">>, + {200, _, Body} = do_post("/echo/normal", [], Body, Config), + %% The content-encoding header would be propagated, + %% but there was no content-encoding header to propagate. + {200, _, <<"undefined">>} = do_post("/test/content-encoding", [], Body, Config), + %% The content_decoded list is empty. + {200, _, <<"[]">>} = do_post("/test/content-decoded", [], Body, Config), + ok. + +content_encoding_malformed(Config) -> + doc("Requests with a malformed content-encoding are processed " + "as if no content-encoding was sent."), + Body = <<"test">>, + {200, _, Body} = do_post("/echo/normal", + [{<<"content-encoding">>, <<";">>}], Body, Config), + %% The content-encoding header is propagated. + {200, _, <<";">>} = do_post("/test/content-encoding", + [{<<"content-encoding">>, <<";">>}], Body, Config), + %% The content_decoded list is empty. + {200, _, <<"[]">>} = do_post("/test/content-decoded", + [{<<"content-encoding">>, <<";">>}], Body, Config), + ok. + +content_encoding_not_supported(Config) -> + doc("Requests with an unsupported content-encoding are processed " + "as if no content-encoding was sent."), + Body = <<"test">>, + {200, _, Body} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"compress">>}], Body, Config), + %% The content-encoding header is propagated. + {200, _, <<"compress">>} = do_post("/test/content-encoding", + [{<<"content-encoding">>, <<"compress">>}], Body, Config), + %% The content_decoded list is empty. + {200, _, <<"[]">>} = do_post("/test/content-decoded", + [{<<"content-encoding">>, <<"compress">>}], Body, Config), + ok. + +content_encoding_multiple(Config) -> + doc("Requests with multiple content-encoding values are processed " + "as if no content-encoding was sent."), + Body = <<"test">>, + {200, _, Body} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip, compress">>}], Body, Config), + %% The content-encoding header is propagated. + {200, _, <<"gzip, compress">>} = do_post("/test/content-encoding", + [{<<"content-encoding">>, <<"gzip, compress">>}], Body, Config), + %% The content_decoded list is empty. + {200, _, <<"[]">>} = do_post("/test/content-decoded", + [{<<"content-encoding">>, <<"gzip, compress">>}], Body, Config), + ok. + +decompress(Config) -> + doc("Requests with content-encoding set to gzip and gzipped data " + "are transparently decompressed."), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, _, Data} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% The content-encoding header is NOT propagated. + {200, _, <<"undefined">>} = do_post("/test/content-encoding", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% The content_decoded list contains <<"gzip">>. + {200, _, <<"[<<\"gzip\">>]">>} = do_post("/test/content-decoded", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + ok. + +decompress_error(Config) -> + doc("Requests with content-encoding set to gzip but the data " + "cannot be decoded are rejected with a 400 Bad Request error."), + Body = <<"test">>, + {400, _, _} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + ok. + +decompress_stream(Config) -> + doc("Requests with content-encoding set to gzip and gzipped data " + "are transparently decompressed, even when the data is streamed."), + %% Handler read length 1KB. Compressing 3KB should be enough to trigger more. + Data = crypto:strong_rand_bytes(3000), + Body = zlib:gzip(Data), + Size = byte_size(Body), + ConnPid = gun_open(Config), + Ref = gun:post(ConnPid, "/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}]), + gun:data(ConnPid, Ref, nofin, binary:part(Body, 0, Size div 2)), + timer:sleep(1000), + gun:data(ConnPid, Ref, fin, binary:part(Body, Size div 2, Size div 2 + Size rem 2)), + {response, IsFin, 200, _} = gun:await(ConnPid, Ref), + {ok, Data} = case IsFin of + nofin -> gun:await_body(ConnPid, Ref); + fin -> {ok, <<>>} + end, + gun:close(ConnPid), + %% The content-encoding header is NOT propagated. + ConnPid2 = gun_open(Config), + Ref2 = gun:post(ConnPid2, "/test/content-encoding", + [{<<"content-encoding">>, <<"gzip">>}]), + {response, nofin, 200, _} = gun:await(ConnPid2, Ref2), + {ok, <<"undefined">>} = gun:await_body(ConnPid2, Ref2), + gun:close(ConnPid2), + %% The content_decoded list contains <<"gzip">>. + ConnPid3 = gun_open(Config), + Ref3 = gun:post(ConnPid3, "/test/content-decoded", + [{<<"content-encoding">>, <<"gzip">>}]), + {response, nofin, 200, _} = gun:await(ConnPid3, Ref3), + {ok, <<"[<<\"gzip\">>]">>} = gun:await_body(ConnPid3, Ref3), + gun:close(ConnPid3). + +opts_decompress_enabled_false(Config0) -> + doc("Confirm that the decompress_enabled option can be set."), + Fun = case config(ref, Config0) of + HTTPS when HTTPS =:= https_compress; HTTPS =:= https -> init_https; + H2 when H2 =:= h2_compress; H2 =:= h2 -> init_http2; + _ -> init_http + end, + Config = cowboy_test:Fun(?FUNCTION_NAME, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config0))}, + stream_handlers => [cowboy_decompress_h, cowboy_stream_h], + decompress_enabled => false + }, Config0), + Data = <<"test">>, + Body = zlib:gzip(Data), + try + {200, Headers, Body} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% We do not set accept-encoding when we are disabled. + false = lists:keyfind(<<"accept-encoding">>, 1, Headers) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +set_options_decompress_enabled_false(Config) -> + doc("Confirm that the decompress_enabled option can be dynamically " + "set to false and the data received is not decompressed."), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Body} = do_post("/echo/decompress_disable", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% We do not set accept-encoding when we are disabled. + false = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +set_options_decompress_disable_in_the_middle(Config) -> + doc("Confirm that setting the decompress_enabled option dynamically " + "to false after starting to read the body does not disable decompression " + "and the data received is decompressed."), + Data = rand:bytes(1000000), + Body = zlib:gzip(Data), + %% Since we were not ignoring before starting to read, + %% we receive the entire body decompressed. + {200, Headers, Data} = do_post("/test/disable-in-the-middle", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% We do set accept-encoding when we are enabled, + %% even if an attempt to disable in the middle is ignored. + {_, _} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +set_options_decompress_enable_in_the_middle(Config0) -> + doc("Confirm that setting the decompress_enabled option dynamically " + "to true after starting to read the body does not enable decompression " + "and the data received is not decompressed."), + Fun = case config(ref, Config0) of + HTTPS when HTTPS =:= https_compress; HTTPS =:= https -> init_https; + H2 when H2 =:= h2_compress; H2 =:= h2 -> init_http2; + _ -> init_http + end, + Config = cowboy_test:Fun(?FUNCTION_NAME, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config0))}, + stream_handlers => [cowboy_decompress_h, cowboy_stream_h], + decompress_enabled => false + }, Config0), + Data = rand:bytes(1000000), + Body = zlib:gzip(Data), + try + %% Since we were ignoring before starting to read, + %% we receive the entire body compressed. + {200, Headers, Body} = do_post("/test/enable-in-the-middle", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + %% We do not set accept-encoding when we are disabled, + %% even if an attempt to enable in the middle is ignored. + false = lists:keyfind(<<"accept-encoding">>, 1, Headers) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +opts_decompress_ratio_limit(Config0) -> + doc("Confirm that the decompress_ratio_limit option can be set."), + Fun = case config(ref, Config0) of + HTTPS when HTTPS =:= https_compress; HTTPS =:= https -> init_https; + H2 when H2 =:= h2_compress; H2 =:= h2 -> init_http2; + _ -> init_http + end, + Config = cowboy_test:Fun(?FUNCTION_NAME, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config0))}, + stream_handlers => [cowboy_decompress_h, cowboy_stream_h], + decompress_ratio_limit => 1 + }, Config0), + %% Data must be big enough for compression to be effective, + %% so that ratio_limit=1 will fail. + Data = <<0:800>>, + Body = zlib:gzip(Data), + try + {413, _, _} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +set_options_decompress_ratio_limit(Config) -> + doc("Confirm that the decompress_ratio_limit option can be dynamically set."), + %% Data must be big enough for compression to be effective, + %% so that ratio_limit=1 will fail. + Data = <<0:800>>, + Body = zlib:gzip(Data), + {413, _, _} = do_post("/echo/decompress_ratio_limit", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + ok. + +gzip_bomb(Config) -> + doc("Confirm that requests are rejected with a 413 Payload Too Large " + "error when the ratio limit is exceeded."), + Body = create_gzip_bomb(), + {413, _, _} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + ok. + +set_accept_encoding_response(Config) -> + doc("Header accept-encoding must be set on valid response command. " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/echo/normal", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +set_accept_encoding_header(Config) -> + doc("Header accept-encoding must be set on valid header command. " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/header-command", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +add_accept_encoding_header_valid(Config) -> + doc("Supported content codings must be added to the accept-encoding " + "header if it already exists. (RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/accept-identity", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"identity, gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +override_accept_encoding_header_invalid(Config) -> + doc("When the stream handler cannot parse the accept-encoding header " + "found in the response, it overrides it."), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/invalid-header", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"gzip">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +override_accept_encoding_excluded(Config) -> + doc("The stream handler must ensure that the content encodings " + "it supports are not marked as unsupported in response headers. " + "The stream handler enables gzip when explicitly excluded. " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/reject-explicit-header", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"identity;q=1, gzip;q=1">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +%% *;q=0 will reject codings that are not listed. Supported codings +%% must always be enabled when the handler is used. +add_accept_encoding_excluded(Config) -> + doc("The stream handler must ensure that the content encodings " + "it supports are not marked as unsupported in response headers. " + "The stream handler enables gzip when implicitly excluded (*;q=0). " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/reject-implicit-header", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"gzip;q=1, identity;q=1, *;q=0">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +no_override_accept_coding_set_explicit(Config) -> + doc("Confirm that accept-encoding is not overridden when the " + "content encodings it supports are explicitly set. " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/accept-explicit-header", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"identity, gzip;q=0.5">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. + +no_override_accept_coding_set_implicit(Config) -> + doc("Confirm that accept-encoding is not overridden when the " + "content encodings it supports are implicitly set. " + "(RFC9110 12.5.3)"), + Data = <<"test">>, + Body = zlib:gzip(Data), + {200, Headers, Data} = do_post("/test/accept-implicit-header", + [{<<"content-encoding">>, <<"gzip">>}], Body, Config), + {_, <<"identity, *;q=0.5">>} = lists:keyfind(<<"accept-encoding">>, 1, Headers), + ok. diff --git a/test/examples_SUITE.erl b/test/examples_SUITE.erl index 0a3b0eb..e2327bc 100644 --- a/test/examples_SUITE.erl +++ b/test/examples_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2016-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -80,7 +80,7 @@ do_compile_and_start(Example, Config) -> %% TERM=dumb disables relx coloring. ct:log("~s~n", [os:cmd(Make ++ " -C " ++ Dir ++ " TERM=dumb")]), ct:log("~s~n", [os:cmd(Rel ++ " stop")]), - ct:log("~s~n", [os:cmd(Rel ++ " start")]), + ct:log("~s~n", [os:cmd(Rel ++ " daemon")]), timer:sleep(2000), ok. @@ -372,13 +372,16 @@ file_server(Config) -> do_file_server(Transport, Protocol, Config) -> %% Directory. {200, DirHeaders, <<"<!DOCTYPE html><html>", _/bits >>} = do_get(Transport, Protocol, "/", Config), - {_, <<"text/html">>} = lists:keyfind(<<"content-type">>, 1, DirHeaders), + {_, <<"text/html; charset=utf-8">>} = lists:keyfind(<<"content-type">>, 1, DirHeaders), _ = do_rest_get(Transport, Protocol, "/", <<"application/json">>, undefined, Config), %% Files. {200, _, _} = do_get(Transport, Protocol, "/small.mp4", Config), {200, _, _} = do_get(Transport, Protocol, "/small.ogv", Config), {200, _, _} = do_get(Transport, Protocol, "/test.txt", Config), {200, _, _} = do_get(Transport, Protocol, "/video.html", Config), + {200, _, _} = do_get(Transport, Protocol, + ["/", cow_uri:urlencode(<<"中文"/utf8>>), "/", cow_uri:urlencode(<<"中文.html"/utf8>>)], + Config), ok. %% Markdown middleware. diff --git a/test/h2spec_SUITE.erl b/test/h2spec_SUITE.erl index 08497e9..67ccf03 100644 --- a/test/h2spec_SUITE.erl +++ b/test/h2spec_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/handlers/compress_h.erl b/test/handlers/compress_h.erl index 27edbd3..658c834 100644 --- a/test/handlers/compress_h.erl +++ b/test/handlers/compress_h.erl @@ -19,6 +19,9 @@ init(Req0, State=reply) -> <<"content-encoding">> -> cowboy_req:reply(200, #{<<"content-encoding">> => <<"compress">>}, lists:duplicate(100000, $a), Req0); + <<"etag">> -> + cowboy_req:reply(200, #{<<"etag">> => <<"\"STRONK\"">>}, + lists:duplicate(100000, $a), Req0); <<"sendfile">> -> AppFile = code:where_is_file("cowboy.app"), Size = filelib:file_size(AppFile), @@ -34,6 +37,8 @@ init(Req0, State=stream_reply) -> stream_reply(#{}, Req0); <<"content-encoding">> -> stream_reply(#{<<"content-encoding">> => <<"compress">>}, Req0); + <<"etag">> -> + stream_reply(#{<<"etag">> => <<"\"STRONK\"">>}, Req0); <<"sendfile">> -> Data = lists:duplicate(10000, $a), AppFile = code:where_is_file("cowboy.app"), diff --git a/test/handlers/decompress_h.erl b/test/handlers/decompress_h.erl new file mode 100644 index 0000000..deb6de0 --- /dev/null +++ b/test/handlers/decompress_h.erl @@ -0,0 +1,84 @@ +%% This module echoes a request body of to test +%% the cowboy_decompress_h stream handler. + +-module(decompress_h). + +-export([init/2]). + +init(Req0, State=echo) -> + case cowboy_req:binding(what, Req0) of + <<"decompress_disable">> -> + cowboy_req:cast({set_options, #{decompress_enabled => false}}, Req0); + <<"decompress_ratio_limit">> -> + cowboy_req:cast({set_options, #{decompress_ratio_limit => 0.5}}, Req0); + <<"normal">> -> ok + end, + {ok, Body, Req1} = read_body(Req0), + Req = cowboy_req:reply(200, #{}, Body, Req1), + {ok, Req, State}; +init(Req0, State=test) -> + Req = test(Req0, cowboy_req:binding(what, Req0)), + {ok, Req, State}. + +test(Req, <<"content-encoding">>) -> + cowboy_req:reply(200, #{}, + cowboy_req:header(<<"content-encoding">>, Req, <<"undefined">>), + Req); +test(Req, <<"content-decoded">>) -> + cowboy_req:reply(200, #{}, + io_lib:format("~0p", [maps:get(content_decoded, Req, undefined)]), + Req); +test(Req0, <<"disable-in-the-middle">>) -> + {Status, Data, Req1} = cowboy_req:read_body(Req0, #{length => 1000}), + cowboy_req:cast({set_options, #{decompress_enabled => false}}, Req1), + {ok, Body, Req} = do_read_body(Status, Req1, Data), + cowboy_req:reply(200, #{}, Body, Req); +test(Req0, <<"enable-in-the-middle">>) -> + {Status, Data, Req1} = cowboy_req:read_body(Req0, #{length => 1000}), + cowboy_req:cast({set_options, #{decompress_enabled => true}}, Req1), + {ok, Body, Req} = do_read_body(Status, Req1, Data), + cowboy_req:reply(200, #{}, Body, Req); +test(Req0, <<"header-command">>) -> + {ok, Body, Req1} = read_body(Req0), + Req = cowboy_req:stream_reply(200, #{}, Req1), + cowboy_req:stream_body(Body, fin, Req); +test(Req0, <<"accept-identity">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<"identity">>}, + Body, Req); +test(Req0, <<"invalid-header">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<";">>}, + Body, Req); +test(Req0, <<"reject-explicit-header">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<"identity, gzip;q=0">>}, + Body, Req); +test(Req0, <<"reject-implicit-header">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<"identity, *;q=0">>}, + Body, Req); +test(Req0, <<"accept-explicit-header">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<"identity, gzip;q=0.5">>}, + Body, Req); +test(Req0, <<"accept-implicit-header">>) -> + {ok, Body, Req} = read_body(Req0), + cowboy_req:reply(200, + #{<<"accept-encoding">> => <<"identity, *;q=0.5">>}, + Body, Req). + +read_body(Req0) -> + {Status, Data, Req} = cowboy_req:read_body(Req0, #{length => 1000}), + do_read_body(Status, Req, Data). + +do_read_body(more, Req0, Acc) -> + {Status, Data, Req} = cowboy_req:read_body(Req0), + do_read_body(Status, Req, << Acc/binary, Data/binary >>); +do_read_body(ok, Req, Acc) -> + {ok, Acc, Req}. diff --git a/test/handlers/echo_h.erl b/test/handlers/echo_h.erl index 1b672d1..d04d531 100644 --- a/test/handlers/echo_h.erl +++ b/test/handlers/echo_h.erl @@ -25,6 +25,8 @@ echo(<<"read_body">>, Req0, Opts) -> timer:sleep(500), cowboy_req:read_body(Req0); <<"/full", _/bits>> -> read_body(Req0, <<>>); + <<"/auto-sync", _/bits>> -> read_body_auto_sync(Req0, <<>>); + <<"/auto-async", _/bits>> -> read_body_auto_async(Req0, <<>>); <<"/length", _/bits>> -> {_, _, Req1} = read_body(Req0, <<>>), Length = cowboy_req:body_length(Req1), @@ -84,6 +86,7 @@ echo(<<"match">>, Req, Opts) -> Fields = [binary_to_atom(F, latin1) || F <- Fields0], Value = case Type of <<"qs">> -> cowboy_req:match_qs(Fields, Req); + <<"qs_with_constraints">> -> cowboy_req:match_qs([{id, integer}], Req); <<"cookies">> -> cowboy_req:match_cookies(Fields, Req); <<"body_qs">> -> %% Note that the Req should not be discarded but for the @@ -122,6 +125,25 @@ read_body(Req0, Acc) -> {more, Data, Req} -> read_body(Req, << Acc/binary, Data/binary >>) end. +read_body_auto_sync(Req0, Acc) -> + Opts = #{length => auto, period => infinity}, + case cowboy_req:read_body(Req0, Opts) of + {ok, Data, Req} -> {ok, << Acc/binary, Data/binary >>, Req}; + {more, Data, Req} -> read_body_auto_sync(Req, << Acc/binary, Data/binary >>) + end. + +read_body_auto_async(Req, Acc) -> + read_body_auto_async(Req, make_ref(), Acc). + +read_body_auto_async(Req, ReadBodyRef, Acc) -> + cowboy_req:cast({read_body, self(), ReadBodyRef, auto, infinity}, Req), + receive + {request_body, ReadBodyRef, nofin, Data} -> + read_body_auto_async(Req, ReadBodyRef, <<Acc/binary, Data/binary>>); + {request_body, ReadBodyRef, fin, _, Data} -> + {ok, <<Acc/binary, Data/binary>>, Req} + end. + value_to_iodata(V) when is_integer(V) -> integer_to_binary(V); value_to_iodata(V) when is_atom(V) -> atom_to_binary(V, latin1); value_to_iodata(V) when is_list(V); is_tuple(V); is_map(V) -> io_lib:format("~999999p", [V]); diff --git a/test/handlers/generate_etag_h.erl b/test/handlers/generate_etag_h.erl index 97ee82b..b9e1302 100644 --- a/test/handlers/generate_etag_h.erl +++ b/test/handlers/generate_etag_h.erl @@ -34,6 +34,9 @@ generate_etag(Req=#{qs := <<"binary-weak-unquoted">>}, State) -> generate_etag(Req=#{qs := <<"binary-strong-unquoted">>}, State) -> ct_helper_error_h:ignore(cow_http_hd, parse_etag, 1), {<<"etag-header-value">>, Req, State}; +%% Returning 'undefined' to indicate no etag. +generate_etag(Req=#{qs := <<"undefined">>}, State) -> + {undefined, Req, State}; %% Simulate the callback being missing in other cases. generate_etag(#{qs := <<"missing">>}, _) -> no_call. diff --git a/test/handlers/loop_handler_endless_h.erl b/test/handlers/loop_handler_endless_h.erl new file mode 100644 index 0000000..d8c8ab5 --- /dev/null +++ b/test/handlers/loop_handler_endless_h.erl @@ -0,0 +1,25 @@ +%% This module implements a loop handler that streams endless data. + +-module(loop_handler_endless_h). + +-export([init/2]). +-export([info/3]). + +init(Req0, #{delay := Delay} = Opts) -> + case cowboy_req:header(<<"x-test-pid">>, Req0) of + BinPid when is_binary(BinPid) -> + Pid = list_to_pid(binary_to_list(BinPid)), + Pid ! {Pid, self(), init}, + ok; + _ -> + ok + end, + erlang:send_after(Delay, self(), timeout), + Req = cowboy_req:stream_reply(200, Req0), + {cowboy_loop, Req, Opts}. + +info(timeout, Req, State) -> + cowboy_req:stream_body(<<0:10000/unit:8>>, nofin, Req), + %% Equivalent to a 0 timeout. + self() ! timeout, + {ok, Req, State}. diff --git a/test/handlers/loop_handler_timeout_hibernate_h.erl b/test/handlers/loop_handler_timeout_hibernate_h.erl new file mode 100644 index 0000000..0485208 --- /dev/null +++ b/test/handlers/loop_handler_timeout_hibernate_h.erl @@ -0,0 +1,30 @@ +%% This module implements a loop handler that first +%% sets a timeout, then hibernates, then ensures +%% that the timeout initially set no longer triggers. +%% If everything goes fine a 200 is returned. If the +%% timeout triggers again a 299 is. + +-module(loop_handler_timeout_hibernate_h). + +-export([init/2]). +-export([info/3]). +-export([terminate/3]). + +init(Req, _) -> + self() ! message1, + {cowboy_loop, Req, undefined, 100}. + +info(message1, Req, State) -> + erlang:send_after(200, self(), message2), + {ok, Req, State, hibernate}; +info(message2, Req, State) -> + erlang:send_after(200, self(), message3), + %% Don't set a timeout now. + {ok, Req, State}; +info(message3, Req, State) -> + {stop, cowboy_req:reply(200, Req), State}; +info(timeout, Req, State) -> + {stop, cowboy_req:reply(<<"299 OK!">>, Req), State}. + +terminate(stop, _, _) -> + ok. diff --git a/test/handlers/loop_handler_timeout_info_h.erl b/test/handlers/loop_handler_timeout_info_h.erl new file mode 100644 index 0000000..7a1ccba --- /dev/null +++ b/test/handlers/loop_handler_timeout_info_h.erl @@ -0,0 +1,23 @@ +%% This module implements a loop handler that changes +%% the timeout value to 500ms after the first message +%% then sends itself another message after 1000ms. +%% It is expected to timeout, that is, reply a 299. + +-module(loop_handler_timeout_info_h). + +-export([init/2]). +-export([info/3]). +-export([terminate/3]). + +init(Req, _) -> + self() ! message, + {cowboy_loop, Req, undefined}. + +info(message, Req, State) -> + erlang:send_after(500, self(), message), + {ok, Req, State, 100}; +info(timeout, Req, State) -> + {stop, cowboy_req:reply(<<"299 OK!">>, Req), State}. + +terminate(stop, _, _) -> + ok. diff --git a/test/handlers/loop_handler_timeout_init_h.erl b/test/handlers/loop_handler_timeout_init_h.erl new file mode 100644 index 0000000..7908fda --- /dev/null +++ b/test/handlers/loop_handler_timeout_init_h.erl @@ -0,0 +1,23 @@ +%% This module implements a loop handler that reads +%% the request query for a timeout value, then sends +%% itself a message after 1000ms. It replies a 200 when +%% the message does not timeout and a 299 otherwise. + +-module(loop_handler_timeout_init_h). + +-export([init/2]). +-export([info/3]). +-export([terminate/3]). + +init(Req, _) -> + #{timeout := Timeout} = cowboy_req:match_qs([{timeout, int}], Req), + erlang:send_after(500, self(), message), + {cowboy_loop, Req, undefined, Timeout}. + +info(message, Req, State) -> + {stop, cowboy_req:reply(200, Req), State}; +info(timeout, Req, State) -> + {stop, cowboy_req:reply(<<"299 OK!">>, Req), State}. + +terminate(stop, _, _) -> + ok. diff --git a/test/handlers/resp_h.erl b/test/handlers/resp_h.erl index 8031d0e..6e9b5f7 100644 --- a/test/handlers/resp_h.erl +++ b/test/handlers/resp_h.erl @@ -30,6 +30,10 @@ do(<<"set_resp_cookie4">>, Req0, Opts) -> do(<<"set_resp_header">>, Req0, Opts) -> Req = cowboy_req:set_resp_header(<<"content-type">>, <<"text/plain">>, Req0), {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; +do(<<"set_resp_header_cookie">>, Req0, Opts) -> + ct_helper:ignore(cowboy_req, set_resp_header, 3), + Req = cowboy_req:set_resp_header(<<"set-cookie">>, <<"name=value">>, Req0), + {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; do(<<"set_resp_header_server">>, Req0, Opts) -> Req = cowboy_req:set_resp_header(<<"server">>, <<"nginx">>, Req0), {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; @@ -39,6 +43,12 @@ do(<<"set_resp_headers">>, Req0, Opts) -> <<"content-encoding">> => <<"compress">> }, Req0), {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; +do(<<"set_resp_headers_cookie">>, Req0, Opts) -> + ct_helper:ignore(cowboy_req, set_resp_headers, 2), + Req = cowboy_req:set_resp_headers(#{ + <<"set-cookie">> => <<"name=value">> + }, Req0), + {ok, cowboy_req:reply(200, #{}, "OK", Req), Opts}; do(<<"set_resp_headers_http11">>, Req0, Opts) -> Req = cowboy_req:set_resp_headers(#{ <<"connection">> => <<"custom-header, close">>, @@ -130,6 +140,10 @@ do(<<"inform2">>, Req0, Opts) -> <<"twice">> -> cowboy_req:inform(102, Req0), cowboy_req:inform(102, Req0); + <<"after_reply">> -> + ct_helper:ignore(cowboy_req, inform, 3), + Req1 = cowboy_req:reply(200, Req0), + cowboy_req:inform(102, Req1); Status -> cowboy_req:inform(binary_to_integer(Status), Req0) end, @@ -143,9 +157,16 @@ do(<<"inform3">>, Req0, Opts) -> <<"error">> -> ct_helper:ignore(cowboy_req, inform, 3), cowboy_req:inform(ok, Headers, Req0); + <<"set_cookie">> -> + ct_helper:ignore(cowboy_req, inform, 3), + cowboy_req:inform(102, #{<<"set-cookie">> => <<"name=value">>}, Req0); <<"twice">> -> cowboy_req:inform(102, Headers, Req0), cowboy_req:inform(102, Headers, Req0); + <<"after_reply">> -> + ct_helper:ignore(cowboy_req, inform, 3), + Req1 = cowboy_req:reply(200, Req0), + cowboy_req:inform(102, Headers, Req1); Status -> cowboy_req:inform(binary_to_integer(Status), Headers, Req0) end, @@ -161,6 +182,7 @@ do(<<"reply2">>, Req0, Opts) -> <<"twice">> -> ct_helper:ignore(cowboy_req, reply, 4), Req1 = cowboy_req:reply(200, Req0), + timer:sleep(100), cowboy_req:reply(200, Req1); Status -> cowboy_req:reply(binary_to_integer(Status), Req0) @@ -171,6 +193,9 @@ do(<<"reply3">>, Req0, Opts) -> <<"error">> -> ct_helper:ignore(cowboy_req, reply, 4), cowboy_req:reply(200, ok, Req0); + <<"set_cookie">> -> + ct_helper:ignore(cowboy_req, reply, 4), + cowboy_req:reply(200, #{<<"set-cookie">> => <<"name=value">>}, Req0); Status -> cowboy_req:reply(binary_to_integer(Status), #{<<"content-type">> => <<"text/plain">>}, Req0) @@ -181,11 +206,14 @@ do(<<"reply4">>, Req0, Opts) -> <<"error">> -> ct_helper:ignore(erlang, iolist_size, 1), cowboy_req:reply(200, #{}, ok, Req0); - <<"204body">> -> + <<"set_cookie">> -> ct_helper:ignore(cowboy_req, reply, 4), + cowboy_req:reply(200, #{<<"set-cookie">> => <<"name=value">>}, <<"OK">>, Req0); + <<"204body">> -> + ct_helper:ignore(cowboy_req, do_reply_ensure_no_body, 4), cowboy_req:reply(204, #{}, <<"OK">>, Req0); <<"304body">> -> - ct_helper:ignore(cowboy_req, reply, 4), + ct_helper:ignore(cowboy_req, do_reply_ensure_no_body, 4), cowboy_req:reply(304, #{}, <<"OK">>, Req0); Status -> cowboy_req:reply(binary_to_integer(Status), #{}, <<"OK">>, Req0) @@ -215,6 +243,14 @@ do(<<"stream_reply2">>, Req0, Opts) -> Req = cowboy_req:stream_reply(304, Req0), stream_body(Req), {ok, Req, Opts}; + <<"twice">> -> + ct_helper:ignore(cowboy_req, stream_reply, 3), + Req1 = cowboy_req:stream_reply(200, Req0), + timer:sleep(100), + %% We will crash here so the body shouldn't be sent. + Req = cowboy_req:stream_reply(200, Req1), + stream_body(Req), + {ok, Req, Opts}; Status -> Req = cowboy_req:stream_reply(binary_to_integer(Status), Req0), stream_body(Req), @@ -225,6 +261,9 @@ do(<<"stream_reply3">>, Req0, Opts) -> <<"error">> -> ct_helper:ignore(cowboy_req, stream_reply, 3), cowboy_req:stream_reply(200, ok, Req0); + <<"set_cookie">> -> + ct_helper:ignore(cowboy_req, stream_reply, 3), + cowboy_req:stream_reply(200, #{<<"set-cookie">> => <<"name=value">>}, Req0); Status -> cowboy_req:stream_reply(binary_to_integer(Status), #{<<"content-type">> => <<"text/plain">>}, Req0) @@ -380,6 +419,16 @@ do(<<"stream_trailers">>, Req0, Opts) -> <<"grpc-status">> => <<"0">> }, Req), {ok, Req, Opts}; + <<"set_cookie">> -> + ct_helper:ignore(cowboy_req, stream_trailers, 2), + Req = cowboy_req:stream_reply(200, #{ + <<"trailer">> => <<"set-cookie">> + }, Req0), + cowboy_req:stream_body(<<"Hello world!">>, nofin, Req), + cowboy_req:stream_trailers(#{ + <<"set-cookie">> => <<"name=value">> + }, Req), + {ok, Req, Opts}; _ -> Req = cowboy_req:stream_reply(200, #{ <<"trailer">> => <<"grpc-status">> @@ -403,6 +452,11 @@ do(<<"push">>, Req, Opts) -> <<"qs">> -> cowboy_req:push("/static/style.css", #{<<"accept">> => <<"text/css">>}, Req, #{qs => <<"server=cowboy&version=2.0">>}); + <<"after_reply">> -> + ct_helper:ignore(cowboy_req, push, 4), + Req1 = cowboy_req:reply(200, Req), + %% We will crash here so no need to worry about propagating Req1. + cowboy_req:push("/static/style.css", #{<<"accept">> => <<"text/css">>}, Req1); _ -> cowboy_req:push("/static/style.css", #{<<"accept">> => <<"text/css">>}, Req), %% The text/plain mime is not defined by default, so a 406 will be returned. diff --git a/test/handlers/stream_handler_h.erl b/test/handlers/stream_handler_h.erl index 370d15a..7a1e5ec 100644 --- a/test/handlers/stream_handler_h.erl +++ b/test/handlers/stream_handler_h.erl @@ -44,16 +44,16 @@ init_commands(_, _, #state{test=set_options_ignore_unknown}) -> ]; init_commands(_, _, State=#state{test=shutdown_on_stream_stop}) -> Spawn = init_process(false, State), - [{headers, 200, #{}}, {spawn, Spawn, 5000}, stop]; + [{spawn, Spawn, 5000}, {headers, 200, #{}}, stop]; init_commands(_, _, State=#state{test=shutdown_on_socket_close}) -> Spawn = init_process(false, State), - [{headers, 200, #{}}, {spawn, Spawn, 5000}]; + [{spawn, Spawn, 5000}, {headers, 200, #{}}]; init_commands(_, _, State=#state{test=shutdown_timeout_on_stream_stop}) -> Spawn = init_process(true, State), - [{headers, 200, #{}}, {spawn, Spawn, 2000}, stop]; + [{spawn, Spawn, 2000}, {headers, 200, #{}}, stop]; init_commands(_, _, State=#state{test=shutdown_timeout_on_socket_close}) -> Spawn = init_process(true, State), - [{headers, 200, #{}}, {spawn, Spawn, 2000}]; + [{spawn, Spawn, 2000}, {headers, 200, #{}}]; init_commands(_, _, State=#state{test=switch_protocol_after_headers}) -> [{headers, 200, #{}}, {switch_protocol, #{}, ?MODULE, State}]; init_commands(_, _, State=#state{test=switch_protocol_after_headers_data}) -> diff --git a/test/handlers/streamed_result_h.erl b/test/handlers/streamed_result_h.erl new file mode 100644 index 0000000..ea6f492 --- /dev/null +++ b/test/handlers/streamed_result_h.erl @@ -0,0 +1,20 @@ +-module(streamed_result_h). + +-export([init/2]). + +init(Req, Opts) -> + N = list_to_integer(binary_to_list(cowboy_req:binding(n, Req))), + Interval = list_to_integer(binary_to_list(cowboy_req:binding(interval, Req))), + chunked(N, Interval, Req, Opts). + +chunked(N, Interval, Req0, Opts) -> + Req = cowboy_req:stream_reply(200, Req0), + {ok, loop(N, Interval, Req), Opts}. + +loop(0, _Interval, Req) -> + ok = cowboy_req:stream_body("Finished!\n", fin, Req), + Req; +loop(N, Interval, Req) -> + ok = cowboy_req:stream_body(iolist_to_binary([integer_to_list(N), <<"\n">>]), nofin, Req), + timer:sleep(Interval), + loop(N-1, Interval, Req). diff --git a/test/handlers/ws_init_h.erl b/test/handlers/ws_init_h.erl index db5307b..bbe9ef9 100644 --- a/test/handlers/ws_init_h.erl +++ b/test/handlers/ws_init_h.erl @@ -36,7 +36,10 @@ do_websocket_init(State=reply_many_hibernate) -> do_websocket_init(State=reply_many_close) -> {[{text, "Hello"}, close], State}; do_websocket_init(State=reply_many_close_hibernate) -> - {[{text, "Hello"}, close], State, hibernate}. + {[{text, "Hello"}, close], State, hibernate}; +do_websocket_init(State=reply_trap_exit) -> + Text = "trap_exit: " ++ atom_to_list(element(2, process_info(self(), trap_exit))), + {[{text, Text}, close], State, hibernate}. websocket_handle(_, State) -> {[], State}. diff --git a/test/handlers/ws_ping_h.erl b/test/handlers/ws_ping_h.erl new file mode 100644 index 0000000..a5848fe --- /dev/null +++ b/test/handlers/ws_ping_h.erl @@ -0,0 +1,23 @@ +%% This module sends an empty ping to the client and +%% waits for a pong before sending a text frame. It +%% is used to confirm server-initiated pings work. + +-module(ws_ping_h). +-behavior(cowboy_websocket). + +-export([init/2]). +-export([websocket_init/1]). +-export([websocket_handle/2]). +-export([websocket_info/2]). + +init(Req, _) -> + {cowboy_websocket, Req, undefined}. + +websocket_init(State) -> + {[{ping, <<>>}], State}. + +websocket_handle(pong, State) -> + {[{text, <<"OK!!">>}], State}. + +websocket_info(_, State) -> + {[], State}. diff --git a/test/http2_SUITE.erl b/test/http2_SUITE.erl index fe6325d..d17508a 100644 --- a/test/http2_SUITE.erl +++ b/test/http2_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -29,7 +29,8 @@ init_dispatch(_) -> cowboy_router:compile([{"localhost", [ {"/", hello_h, []}, {"/echo/:key", echo_h, []}, - {"/resp_iolist_body", resp_iolist_body_h, []} + {"/resp_iolist_body", resp_iolist_body_h, []}, + {"/streamed_result/:n/:interval", streamed_result_h, []} ]}]). %% Do a prior knowledge handshake (function originally copied from rfc7540_SUITE). @@ -37,7 +38,8 @@ do_handshake(Config) -> do_handshake(#{}, Config). do_handshake(Settings, Config) -> - {ok, Socket} = gen_tcp:connect("localhost", config(port, Config), [binary, {active, false}]), + {ok, Socket} = gen_tcp:connect("localhost", config(port, Config), + [binary, {active, false}|proplists:get_value(tcp_opts, Config, [])]), %% Send a valid preface. ok = gen_tcp:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(Settings)]), %% Receive the server preface. @@ -61,7 +63,8 @@ idle_timeout(Config) -> {ok, Socket} = do_handshake([{port, Port}|Config]), timer:sleep(1000), %% Receive a GOAWAY frame back with NO_ERROR. - {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000) + {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -78,7 +81,8 @@ idle_timeout_infinity(Config) -> {ok, Socket} = do_handshake([{port, Port}|Config]), timer:sleep(1000), %% Don't receive a GOAWAY frame. - {error, timeout} = gen_tcp:recv(Socket, 17, 1000) + {error, timeout} = gen_tcp:recv(Socket, 17, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -107,11 +111,21 @@ idle_timeout_reset_on_data(Config) -> {ok, <<8:24, 6:8, 0:7, 1:1, 0:96>>} = gen_tcp:recv(Socket, 17, 1000), %% The connection goes away soon after we stop sending data. timer:sleep(1000), - {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000) + {ok, << _:24, 7:8, _:72, 0:32 >>} = gen_tcp:recv(Socket, 17, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. +idle_timeout_on_send(Config) -> + doc("Ensure the idle timeout is not reset when sending (by default)."), + http_SUITE:do_idle_timeout_on_send(Config, http2). + +idle_timeout_reset_on_send(Config) -> + doc("Ensure the reset_idle_timeout_on_send results in the " + "idle timeout resetting when sending ."), + http_SUITE:do_idle_timeout_reset_on_send(Config, http2). + inactivity_timeout(Config) -> doc("Terminate when the inactivity timeout is reached."), ProtoOpts = #{ @@ -124,7 +138,8 @@ inactivity_timeout(Config) -> {ok, Socket} = do_handshake([{port, Port}|Config]), receive after 1000 -> ok end, %% Receive a GOAWAY frame back with an INTERNAL_ERROR. - {ok, << _:24, 7:8, _:72, 2:32 >>} = gen_tcp:recv(Socket, 17, 1000) + {ok, << _:24, 7:8, _:72, 2:32 >>} = gen_tcp:recv(Socket, 17, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -148,7 +163,8 @@ initial_connection_window_size(Config) -> {ok, << 4:8, 0:40, _:Len/binary >>} = gen_tcp:recv(Socket, 6 + Len, 1000), %% Receive a WINDOW_UPDATE frame incrementing the connection window to 100000. {ok, <<4:24, 8:8, 0:41, Size:31>>} = gen_tcp:recv(Socket, 13, 1000), - ConfiguredSize = Size + 65535 + ConfiguredSize = Size + 65535, + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -191,7 +207,8 @@ max_frame_size_sent(Config) -> %% The DATA frames following must have lengths of 20000 %% and then 10000 due to the limit. {ok, <<20000:24, 0:8, _:40, _:20000/unit:8>>} = gen_tcp:recv(Socket, 20009, 6000), - {ok, <<10000:24, 0:8, _:40, _:10000/unit:8>>} = gen_tcp:recv(Socket, 10009, 6000) + {ok, <<10000:24, 0:8, _:40, _:10000/unit:8>>} = gen_tcp:recv(Socket, 10009, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -235,7 +252,7 @@ preface_timeout_infinity(Config) -> {'DOWN', Ref, process, Pid, Reason} -> error(Reason) after 1000 -> - ok + gen_tcp:close(Socket) end after cowboy:stop_listener(?FUNCTION_NAME) @@ -279,7 +296,7 @@ settings_timeout_infinity(Config) -> {'DOWN', Ref, process, Pid, Reason} -> error(Reason) after 1000 -> - ok + gen_tcp:close(Socket) end after cowboy:stop_listener(?FUNCTION_NAME) @@ -365,6 +382,10 @@ graceful_shutdown_timeout(Config) -> graceful_shutdown_listener(Config) -> doc("Check that connections are shut down gracefully when stopping a listener."), + TransOpts = #{ + socket_opts => [{port, 0}], + shutdown => 1000 %% Shorter timeout to make the test case faster. + }, Dispatch = cowboy_router:compile([{"localhost", [ {"/delay_hello", delay_hello_h, #{delay => 500, notify_received => self()}} @@ -372,13 +393,15 @@ graceful_shutdown_listener(Config) -> ProtoOpts = #{ env => #{dispatch => Dispatch} }, - {ok, Listener} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], ProtoOpts), + {ok, Listener} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts), Port = ranch:get_port(?FUNCTION_NAME), ConnPid = gun_open([{type, tcp}, {protocol, http2}, {port, Port}|Config]), Ref = gun:get(ConnPid, "/delay_hello"), %% Shutdown listener while the handlers are working. receive {request_received, <<"/delay_hello">>} -> ok end, ListenerMonitorRef = monitor(process, Listener), + %% Note: This call does not complete quickly and will + %% prevent other cowboy:stop_listener/1 calls to complete. ok = cowboy:stop_listener(?FUNCTION_NAME), receive {'DOWN', ListenerMonitorRef, process, Listener, _Reason} -> @@ -392,6 +415,10 @@ graceful_shutdown_listener(Config) -> graceful_shutdown_listener_timeout(Config) -> doc("Check that connections are shut down when gracefully stopping a listener times out."), + TransOpts = #{ + socket_opts => [{port, 0}], + shutdown => 1000 %% Shorter timeout to make the test case faster. + }, Dispatch = cowboy_router:compile([{"localhost", [ {"/long_delay_hello", delay_hello_h, #{delay => 10000, notify_received => self()}} @@ -401,13 +428,15 @@ graceful_shutdown_listener_timeout(Config) -> goaway_initial_timeout => 200, goaway_complete_timeout => 500 }, - {ok, Listener} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], ProtoOpts), + {ok, Listener} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts), Port = ranch:get_port(?FUNCTION_NAME), ConnPid = gun_open([{type, tcp}, {protocol, http2}, {port, Port}|Config]), Ref = gun:get(ConnPid, "/long_delay_hello"), %% Shutdown listener while the handlers are working. receive {request_received, <<"/long_delay_hello">>} -> ok end, ListenerMonitorRef = monitor(process, Listener), + %% Note: This call does not complete quickly and will + %% prevent other cowboy:stop_listener/1 calls to complete. ok = cowboy:stop_listener(?FUNCTION_NAME), receive {'DOWN', ListenerMonitorRef, process, Listener, _Reason} -> @@ -416,3 +445,73 @@ graceful_shutdown_listener_timeout(Config) -> %% Check that the slow request is aborted. {error, {stream_error, closed}} = gun:await(ConnPid, Ref), gun:close(ConnPid). + +send_timeout_close(Config) -> + doc("Check that connections are closed on send timeout."), + TransOpts = #{ + port => 0, + socket_opts => [ + {send_timeout, 100}, + {send_timeout_close, true}, + {sndbuf, 10} + ] + }, + Dispatch = cowboy_router:compile([{"localhost", [ + {"/endless", loop_handler_endless_h, #{delay => 100}} + ]}]), + ProtoOpts = #{ + env => #{dispatch => Dispatch}, + idle_timeout => infinity + }, + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts), + Port = ranch:get_port(?FUNCTION_NAME), + try + %% Connect a client that sends a request and waits indefinitely. + {ok, ClientSocket} = do_handshake([{port, Port}, + {tcp_opts, [{recbuf, 10}, {buffer, 10}, {active, false}]}|Config]), + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/endless">>}, + {<<"x-test-pid">>, pid_to_list(self())} + ]), + ok = gen_tcp:send(ClientSocket, [ + cow_http2:headers(1, fin, HeadersBlock), + %% Greatly increase the window to make sure we don't run + %% out of space before we get send timeouts. + cow_http2:window_update(10000000), + cow_http2:window_update(1, 10000000) + ]), + %% Wait for the handler to start then get its pid, + %% the remote connection's pid and socket. + StreamPid = receive + {Self, StreamPid0, init} when Self =:= self() -> + StreamPid0 + after 1000 -> + error(timeout) + end, + ServerPid = ct_helper:get_remote_pid_tcp(ClientSocket), + {links, ServerLinks} = process_info(ServerPid, links), + [ServerSocket] = [PidOrPort || PidOrPort <- ServerLinks, is_port(PidOrPort)], + %% Poll the socket repeatedly until it is closed by the server. + WaitClosedFun = + fun F(T) when T =< 0 -> + error({status, prim_inet:getstatus(ServerSocket)}); + F(T) -> + Snooze = 100, + case inet:sockname(ServerSocket) of + {error, _} -> + timer:sleep(Snooze); + {ok, _} -> + timer:sleep(Snooze), + F(T - Snooze) + end + end, + ok = WaitClosedFun(2000), + false = erlang:is_process_alive(StreamPid), + false = erlang:is_process_alive(ServerPid), + gen_tcp:close(ClientSocket) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. diff --git a/test/http_SUITE.erl b/test/http_SUITE.erl index d0c92e4..0325279 100644 --- a/test/http_SUITE.erl +++ b/test/http_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -24,6 +24,7 @@ -import(cowboy_test, [raw_open/1]). -import(cowboy_test, [raw_send/2]). -import(cowboy_test, [raw_recv_head/1]). +-import(cowboy_test, [raw_recv_rest/3]). -import(cowboy_test, [raw_recv/3]). -import(cowboy_test, [raw_expect_recv/2]). @@ -44,7 +45,8 @@ init_dispatch(_) -> {"/", hello_h, []}, {"/echo/:key", echo_h, []}, {"/resp/:key[/:arg]", resp_h, []}, - {"/set_options/:key", set_options_h, []} + {"/set_options/:key", set_options_h, []}, + {"/streamed_result/:n/:interval", streamed_result_h, []} ]}]). chunked_false(Config) -> @@ -88,7 +90,7 @@ chunked_one_byte_at_a_time(Config) -> "Transfer-encoding: chunked\r\n\r\n"), _ = [begin raw_send(Client, <<C>>), - timer:sleep(10) + timer:sleep(1) end || <<C>> <= ChunkedBody], Rest = case catch raw_recv_head(Client) of {'EXIT', _} -> error(closed); @@ -225,6 +227,68 @@ http10_keepalive_false(Config) -> cowboy:stop_listener(?FUNCTION_NAME) end. +idle_timeout_read_body(Config) -> + doc("Ensure the idle_timeout drops connections when the " + "connection is idle too long reading the request body."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 60000, + idle_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + _StreamRef = gun:post(ConnPid, "/echo/read_body", + #{<<"content-length">> => <<"12">>}), + {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +idle_timeout_read_body_pipeline(Config) -> + doc("Ensure the idle_timeout drops connections when the " + "connection is idle too long reading the request body."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 60000, + idle_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + StreamRef1 = gun:get(ConnPid, "/"), + StreamRef2 = gun:get(ConnPid, "/"), + _StreamRef3 = gun:post(ConnPid, "/echo/read_body", + #{<<"content-length">> => <<"12">>}), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef1), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +idle_timeout_skip_body(Config) -> + doc("Ensure the idle_timeout drops connections when the " + "connection is idle too long skipping the request body."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 60000, + idle_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + StreamRef = gun:post(ConnPid, "/", + #{<<"content-length">> => <<"12">>}), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef), + {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + idle_timeout_infinity(Config) -> doc("Ensure the idle_timeout option accepts the infinity value."), {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ @@ -245,12 +309,90 @@ idle_timeout_infinity(Config) -> {'DOWN', Ref, process, Pid, Reason} -> error(Reason) after 1000 -> - ok + gun:close(ConnPid) end after cowboy:stop_listener(?FUNCTION_NAME) end. +idle_timeout_on_send(Config) -> + doc("Ensure the idle timeout is not reset when sending (by default)."), + do_idle_timeout_on_send(Config, http). + +%% Also used by http2_SUITE. +do_idle_timeout_on_send(Config, Protocol) -> + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + idle_timeout => 1000 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, Protocol}, {port, Port}|Config]), + {ok, Protocol} = gun:await_up(ConnPid), + timer:sleep(500), + #{socket := Socket} = gun:info(ConnPid), + Pid = get_remote_pid_tcp(Socket), + StreamRef = gun:get(ConnPid, "/streamed_result/10/250"), + Ref = erlang:monitor(process, Pid), + receive + {gun_response, ConnPid, StreamRef, nofin, _Status, _Headers} -> + do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, false) + after 2000 -> + error(timeout) + end + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +idle_timeout_reset_on_send(Config) -> + doc("Ensure the reset_idle_timeout_on_send results in the " + "idle timeout resetting when sending ."), + do_idle_timeout_reset_on_send(Config, http). + +%% Also used by http2_SUITE. +do_idle_timeout_reset_on_send(Config, Protocol) -> + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + idle_timeout => 1000, + reset_idle_timeout_on_send => true + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, Protocol}, {port, Port}|Config]), + {ok, Protocol} = gun:await_up(ConnPid), + timer:sleep(500), + #{socket := Socket} = gun:info(ConnPid), + Pid = get_remote_pid_tcp(Socket), + StreamRef = gun:get(ConnPid, "/streamed_result/10/250"), + Ref = erlang:monitor(process, Pid), + receive + {gun_response, ConnPid, StreamRef, nofin, _Status, _Headers} -> + do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, true) + after 2000 -> + error(timeout) + end + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, ExpectCompletion) -> + receive + {gun_data, ConnPid, StreamRef, nofin, _Data} -> + do_idle_timeout_recv_loop(Ref, Pid, ConnPid, StreamRef, ExpectCompletion); + {gun_data, ConnPid, StreamRef, fin, _Data} when ExpectCompletion -> + gun:close(ConnPid); + {gun_data, ConnPid, StreamRef, fin, _Data} -> + gun:close(ConnPid), + error(completed); + {'DOWN', Ref, process, Pid, _} when ExpectCompletion -> + gun:close(ConnPid), + error(exited); + {'DOWN', Ref, process, Pid, _} -> + ok + after 2000 -> + error(timeout) + end. + persistent_term_router(Config) -> doc("The router can retrieve the routes from persistent_term storage."), case erlang:function_exported(persistent_term, get, 1) of @@ -274,6 +416,113 @@ do_persistent_term_router(Config) -> cowboy:stop_listener(?FUNCTION_NAME) end. +request_timeout(Config) -> + doc("Ensure the request_timeout drops connections when requests " + "fail to come in fast enough."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +request_timeout_pipeline(Config) -> + doc("Ensure the request_timeout drops connections when requests " + "fail to come in fast enough after pipelined requests went through."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), + {ok, http} = gun:await_up(ConnPid), + StreamRef1 = gun:get(ConnPid, "/"), + StreamRef2 = gun:get(ConnPid, "/"), + StreamRef3 = gun:get(ConnPid, "/"), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef1), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef3), + {error, {down, {shutdown, closed}}} = gun:await(ConnPid, undefined, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +request_timeout_skip_body(Config) -> + doc("Ensure the request_timeout drops connections when requests " + "fail to come in fast enough after skipping a request body."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, << + "POST / HTTP/1.1\r\n" + "host: localhost\r\n" + "content-length: 12\r\n\r\n" + >>), + Data = raw_recv_head(Client), + {'HTTP/1.1', 200, _, Rest0} = cow_http:parse_status_line(Data), + {Headers, Rest} = cow_http:parse_headers(Rest0), + {_, Len} = lists:keyfind(<<"content-length">>, 1, Headers), + <<"Hello world!">> = raw_recv_rest(Client, binary_to_integer(Len), Rest), + %% We then send the request data that should be skipped by Cowboy. + timer:sleep(100), + raw_send(Client, <<"Hello world!">>), + %% Connection should be closed by the request_timeout after that. + {error, closed} = raw_recv(Client, 1, 1000) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +request_timeout_skip_body_more(Config) -> + doc("Ensure the request_timeout drops connections when requests " + "fail to come in fast enough after skipping a request body."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + request_timeout => 500 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, << + "POST / HTTP/1.1\r\n" + "host: localhost\r\n" + "content-length: 12\r\n\r\n" + >>), + Data = raw_recv_head(Client), + {'HTTP/1.1', 200, _, Rest0} = cow_http:parse_status_line(Data), + {Headers, Rest} = cow_http:parse_headers(Rest0), + {_, Len} = lists:keyfind(<<"content-length">>, 1, Headers), + <<"Hello world!">> = raw_recv_rest(Client, binary_to_integer(Len), Rest), + %% We then send the request data that should be skipped by Cowboy. + timer:sleep(100), + raw_send(Client, <<"Hello world!">>), + %% Send the start of another request. + ok = raw_send(Client, << + "GET / HTTP/1.1\r\n" + "host: localhost\r\n" + %% Missing final \r\n on purpose. + >>), + %% Connection should be closed by the request_timeout after that. + %% We attempt to send a 408 response on a best effort basis so + %% that is accepted as well. + case raw_recv(Client, 13, 1000) of + {error, closed} -> ok; + {ok, <<"HTTP/1.1 408 ", _/bits>>} -> ok + end + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + request_timeout_infinity(Config) -> doc("Ensure the request_timeout option accepts the infinity value."), {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ @@ -292,7 +541,7 @@ request_timeout_infinity(Config) -> {'DOWN', Ref, process, Pid, Reason} -> error(Reason) after 1000 -> - ok + gun:close(ConnPid) end after cowboy:stop_listener(?FUNCTION_NAME) @@ -348,7 +597,8 @@ set_options_chunked_false_ignored(Config) -> %% is not disabled for that second request. StreamRef2 = gun:get(ConnPid, "/resp/stream_reply2/200"), {response, nofin, 200, Headers} = gun:await(ConnPid, StreamRef2), - {_, <<"chunked">>} = lists:keyfind(<<"transfer-encoding">>, 1, Headers) + {_, <<"chunked">>} = lists:keyfind(<<"transfer-encoding">>, 1, Headers), + gun:close(ConnPid) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -449,10 +699,10 @@ graceful_shutdown_connection(Config) -> doc("Check that the current request is handled before gracefully " "shutting down a connection."), Dispatch = cowboy_router:compile([{"localhost", [ + {"/hello", delay_hello_h, + #{delay => 0, notify_received => self()}}, {"/delay_hello", delay_hello_h, - #{delay => 500, notify_received => self()}}, - {"/long_delay_hello", delay_hello_h, - #{delay => 10000, notify_received => self()}} + #{delay => 1000, notify_received => self()}} ]}]), ProtoOpts = #{ env => #{dispatch => Dispatch} @@ -460,22 +710,27 @@ graceful_shutdown_connection(Config) -> {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], ProtoOpts), Port = ranch:get_port(?FUNCTION_NAME), try - ConnPid = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), - {ok, http} = gun:await_up(ConnPid), - #{socket := Socket} = gun:info(ConnPid), - CowboyConnPid = get_remote_pid_tcp(Socket), - CowboyConnRef = erlang:monitor(process, CowboyConnPid), - Ref1 = gun:get(ConnPid, "/delay_hello"), - Ref2 = gun:get(ConnPid, "/delay_hello"), - receive {request_received, <<"/delay_hello">>} -> ok end, + Client = raw_open([{type, tcp}, {port, Port}, {opts, []}|Config]), + ok = raw_send(Client, + "GET /delay_hello HTTP/1.1\r\n" + "Host: localhost\r\n\r\n" + "GET /hello HTTP/1.1\r\n" + "Host: localhost\r\n\r\n"), receive {request_received, <<"/delay_hello">>} -> ok end, + receive {request_received, <<"/hello">>} -> ok end, + CowboyConnPid = get_remote_pid_tcp(element(2, Client)), + CowboyConnRef = erlang:monitor(process, CowboyConnPid), ok = sys:terminate(CowboyConnPid, system_is_going_down), - {response, nofin, 200, RespHeaders} = gun:await(ConnPid, Ref1), - <<"close">> = proplists:get_value(<<"connection">>, RespHeaders), - {ok, RespBody} = gun:await_body(ConnPid, Ref1), - <<"Hello world!">> = iolist_to_binary(RespBody), - {error, {stream_error, _}} = gun:await(ConnPid, Ref2), - ok = gun_down(ConnPid), + Rest = case catch raw_recv_head(Client) of + {'EXIT', _} -> error(closed); + Data -> + {'HTTP/1.1', 200, _, Rest0} = cow_http:parse_status_line(Data), + {Headers, Rest1} = cow_http:parse_headers(Rest0), + <<"close">> = proplists:get_value(<<"connection">>, Headers), + Rest1 + end, + <<"Hello world!">> = raw_recv_rest(Client, byte_size(<<"Hello world!">>), Rest), + {error, closed} = raw_recv(Client, 0, 1000), receive {'DOWN', CowboyConnRef, process, CowboyConnPid, _Reason} -> ok @@ -486,6 +741,10 @@ graceful_shutdown_connection(Config) -> graceful_shutdown_listener(Config) -> doc("Check that connections are shut down gracefully when stopping a listener."), + TransOpts = #{ + socket_opts => [{port, 0}], + shutdown => 1000 %% Shorter timeout to make the test case faster. + }, Dispatch = cowboy_router:compile([{"localhost", [ {"/delay_hello", delay_hello_h, #{delay => 500, notify_received => self()}}, @@ -495,7 +754,7 @@ graceful_shutdown_listener(Config) -> ProtoOpts = #{ env => #{dispatch => Dispatch} }, - {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], ProtoOpts), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts), Port = ranch:get_port(?FUNCTION_NAME), ConnPid1 = gun_open([{type, tcp}, {protocol, http}, {port, Port}|Config]), Ref1 = gun:get(ConnPid1, "/delay_hello"), @@ -504,6 +763,8 @@ graceful_shutdown_listener(Config) -> %% Shutdown listener while the handlers are working. receive {request_received, <<"/delay_hello">>} -> ok end, receive {request_received, <<"/long_delay_hello">>} -> ok end, + %% Note: This call does not complete quickly and will + %% prevent other cowboy:stop_listener/1 calls to complete. ok = cowboy:stop_listener(?FUNCTION_NAME), %% Check that the 1st request is handled before shutting down. {response, nofin, 200, RespHeaders} = gun:await(ConnPid1, Ref1), @@ -514,3 +775,63 @@ graceful_shutdown_listener(Config) -> %% Check that the 2nd (very slow) request is not handled. {error, {stream_error, closed}} = gun:await(ConnPid2, Ref2), gun:close(ConnPid2). + +send_timeout_close(_Config) -> + doc("Check that connections are closed on send timeout."), + TransOpts = #{ + port => 0, + socket_opts => [ + {send_timeout, 100}, + {send_timeout_close, true}, + {sndbuf, 10} + ] + }, + Dispatch = cowboy_router:compile([{"localhost", [ + {"/endless", loop_handler_endless_h, #{delay => 100}} + ]}]), + ProtoOpts = #{ + env => #{dispatch => Dispatch}, + idle_timeout => infinity + }, + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, TransOpts, ProtoOpts), + Port = ranch:get_port(?FUNCTION_NAME), + try + %% Connect a client that sends a request and waits indefinitely. + {ok, ClientSocket} = gen_tcp:connect("localhost", Port, + [{recbuf, 10}, {buffer, 10}, {active, false}, {packet, 0}]), + ok = gen_tcp:send(ClientSocket, [ + "GET /endless HTTP/1.1\r\n", + "Host: localhost:", integer_to_list(Port), "\r\n", + "x-test-pid: ", pid_to_list(self()), "\r\n\r\n" + ]), + %% Wait for the handler to start then get its pid, + %% the remote connection's pid and socket. + StreamPid = receive + {Self, StreamPid0, init} when Self =:= self() -> + StreamPid0 + after 1000 -> + error(timeout) + end, + ServerPid = ct_helper:get_remote_pid_tcp(ClientSocket), + {links, ServerLinks} = process_info(ServerPid, links), + [ServerSocket] = [PidOrPort || PidOrPort <- ServerLinks, is_port(PidOrPort)], + %% Poll the socket repeatedly until it is closed by the server. + WaitClosedFun = + fun F(T) when T =< 0 -> + error({status, prim_inet:getstatus(ServerSocket)}); + F(T) -> + Snooze = 100, + case inet:sockname(ServerSocket) of + {error, _} -> + timer:sleep(Snooze); + {ok, _} -> + timer:sleep(Snooze), + F(T - Snooze) + end + end, + ok = WaitClosedFun(2000), + false = erlang:is_process_alive(StreamPid), + false = erlang:is_process_alive(ServerPid) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. diff --git a/test/loop_handler_SUITE.erl b/test/loop_handler_SUITE.erl index a7b5303..c5daaf8 100644 --- a/test/loop_handler_SUITE.erl +++ b/test/loop_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2011-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -32,7 +32,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Dispatch configuration. @@ -40,7 +40,10 @@ init_dispatch(_) -> cowboy_router:compile([{'_', [ {"/long_polling", long_polling_h, []}, {"/loop_body", loop_handler_body_h, []}, - {"/loop_timeout", loop_handler_timeout_h, []} + {"/loop_request_timeout", loop_handler_timeout_h, []}, + {"/loop_timeout_init", loop_handler_timeout_init_h, []}, + {"/loop_timeout_info", loop_handler_timeout_info_h, []}, + {"/loop_timeout_hibernate", loop_handler_timeout_hibernate_h, []} ]}]). %% Tests. @@ -79,6 +82,31 @@ long_polling_pipeline(Config) -> request_timeout(Config) -> doc("Ensure that the request_timeout isn't applied when a request is ongoing."), ConnPid = gun_open(Config), - Ref = gun:get(ConnPid, "/loop_timeout", [{<<"accept-encoding">>, <<"gzip">>}]), + Ref = gun:get(ConnPid, "/loop_request_timeout", [{<<"accept-encoding">>, <<"gzip">>}]), {response, nofin, 200, _} = gun:await(ConnPid, Ref, 10000), ok. + +timeout_hibernate(Config) -> + doc("Ensure that loop handler idle timeouts don't trigger after hibernate is returned."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/loop_timeout_hibernate", [{<<"accept-encoding">>, <<"gzip">>}]), + {response, fin, 200, _} = gun:await(ConnPid, Ref), + ok. + +timeout_info(Config) -> + doc("Ensure that loop handler idle timeouts trigger on time when set in info/3."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/loop_timeout_info", [{<<"accept-encoding">>, <<"gzip">>}]), + {response, fin, 299, _} = gun:await(ConnPid, Ref), + ok. + +timeout_init(Config) -> + doc("Ensure that loop handler idle timeouts trigger on time when set in init/2."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/loop_timeout_init?timeout=1000", + [{<<"accept-encoding">>, <<"gzip">>}]), + {response, fin, 200, _} = gun:await(ConnPid, Ref), + Ref2 = gun:get(ConnPid, "/loop_timeout_init?timeout=100", + [{<<"accept-encoding">>, <<"gzip">>}]), + {response, fin, 299, _} = gun:await(ConnPid, Ref2), + ok. diff --git a/test/metrics_SUITE.erl b/test/metrics_SUITE.erl index 74a259f..6a272f2 100644 --- a/test/metrics_SUITE.erl +++ b/test/metrics_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -44,6 +44,8 @@ init_per_group(Name = h2, Config) -> init_per_group(Name = h2c, Config) -> Config1 = cowboy_test:init_http(Name, init_plain_opts(Config), Config), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, init_plain_opts(Config), Config); init_per_group(Name = http_compress, Config) -> cowboy_test:init_http(Name, init_compress_opts(Config), Config); init_per_group(Name = https_compress, Config) -> @@ -52,10 +54,12 @@ init_per_group(Name = h2_compress, Config) -> cowboy_test:init_http2(Name, init_compress_opts(Config), Config); init_per_group(Name = h2c_compress, Config) -> Config1 = cowboy_test:init_http(Name, init_compress_opts(Config), Config), - lists:keyreplace(protocol, 1, Config1, {protocol, http2}). + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3_compress, Config) -> + cowboy_test:init_http3(Name, init_compress_opts(Config), Config). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_plain_opts(Config) -> #{ @@ -157,16 +161,24 @@ do_get(Path, UserData, Config) -> #{ ref := _, pid := From, - streamid := 1, - reason := normal, + streamid := StreamID, + reason := normal, %% @todo Getting h3_no_error here. req := #{}, informational := [], user_data := UserData } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. +do_check_streamid(StreamID, Config) -> + case config(protocol, Config) of + http -> 1 = StreamID; + http2 -> 1 = StreamID; + http3 -> 0 = StreamID + end. + post_body(Config) -> doc("Confirm metrics are correct for a normal POST request."), %% Perform a POST request. @@ -218,12 +230,13 @@ post_body(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := normal, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. @@ -273,12 +286,13 @@ no_resp_body(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := normal, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. @@ -291,7 +305,8 @@ early_error(Config) -> %% reason in both protocols. {Method, Headers, Status, Error} = case config(protocol, Config) of http -> {<<"GET">>, [{<<"host">>, <<"host:port">>}], 400, protocol_error}; - http2 -> {<<"TRACE">>, [], 501, no_error} + http2 -> {<<"TRACE">>, [], 501, no_error}; + http3 -> {<<"TRACE">>, [], 501, h3_no_error} end, Ref = gun:request(ConnPid, Method, "/", [ {<<"accept-encoding">>, <<"gzip">>}, @@ -305,7 +320,7 @@ early_error(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := {stream_error, Error, _}, partial_req := #{}, resp_status := Status, @@ -313,6 +328,7 @@ early_error(Config) -> early_error_time := _, resp_body_length := 0 } = Metrics, + do_check_streamid(StreamID, Config), ExpectedRespHeaders = maps:from_list(RespHeaders), %% All good! gun:close(ConnPid) @@ -321,7 +337,8 @@ early_error(Config) -> early_error_request_line(Config) -> case config(protocol, Config) of http -> do_early_error_request_line(Config); - http2 -> doc("There are no request lines in HTTP/2.") + http2 -> doc("There are no request lines in HTTP/2."); + http3 -> doc("There are no request lines in HTTP/3.") end. do_early_error_request_line(Config) -> @@ -341,7 +358,7 @@ do_early_error_request_line(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := {connection_error, protocol_error, _}, partial_req := #{}, resp_status := 400, @@ -349,6 +366,7 @@ do_early_error_request_line(Config) -> early_error_time := _, resp_body_length := 0 } = Metrics, + do_check_streamid(StreamID, Config), ExpectedRespHeaders = maps:from_list(RespHeaders), %% All good! ok @@ -362,7 +380,9 @@ stream_reply(Config) -> ws(Config) -> case config(protocol, Config) of http -> do_ws(Config); - http2 -> doc("It is not currently possible to switch to Websocket over HTTP/2.") + %% @todo The test can be implemented for HTTP/2. + http2 -> doc("It is not currently possible to switch to Websocket over HTTP/2."); + http3 -> {skip, "Gun does not currently support Websocket over HTTP/3."} end. do_ws(Config) -> @@ -405,7 +425,7 @@ do_ws(Config) -> #{ ref := _, pid := From, - streamid := 1, + streamid := StreamID, reason := switch_protocol, req := #{}, %% A 101 upgrade response was sent. @@ -420,6 +440,7 @@ do_ws(Config) -> }], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! ok end, @@ -438,7 +459,15 @@ error_response(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"x-test-pid">>, pid_to_list(self())} ]), - {response, fin, 500, RespHeaders} = gun:await(ConnPid, Ref, infinity), + Protocol = config(protocol, Config), + RespHeaders = case gun:await(ConnPid, Ref, infinity) of + {response, fin, 500, RespHeaders0} -> + RespHeaders0; + %% The RST_STREAM arrived before the start of the response. + %% See maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 -> + unknown + end, timer:sleep(100), %% Receive the metrics and validate them. receive @@ -463,25 +492,33 @@ error_response(Config) -> resp_headers := ExpectedRespHeaders, resp_body_length := 0 } = Metrics, - ExpectedRespHeaders = maps:from_list(RespHeaders), + case RespHeaders of + %% The HTTP/3 stream has reset too early so we can't + %% verify the response headers. + unknown -> + ok; + _ -> + ExpectedRespHeaders = maps:from_list(RespHeaders) + end, %% The request process executed normally. #{procs := Procs} = Metrics, [{_, #{ spawn := ProcSpawn, exit := ProcExit, - reason := {crash, _StackTrace} + reason := {crash, StackTrace} }}] = maps:to_list(Procs), true = ProcSpawn =< ProcExit, %% Confirm other metadata are as expected. #{ ref := _, pid := From, - streamid := 1, - reason := {internal_error, {'EXIT', _Pid, {crash, _StackTrace}}, 'Stream process crashed.'}, + streamid := StreamID, + reason := {internal_error, {'EXIT', _Pid, {crash, StackTrace}}, 'Stream process crashed.'}, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. @@ -495,7 +532,15 @@ error_response_after_reply(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"x-test-pid">>, pid_to_list(self())} ]), - {response, fin, 200, RespHeaders} = gun:await(ConnPid, Ref, infinity), + Protocol = config(protocol, Config), + RespHeaders = case gun:await(ConnPid, Ref, infinity) of + {response, fin, 200, RespHeaders0} -> + RespHeaders0; + %% The RST_STREAM arrived before the start of the response. + %% See maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 -> + unknown + end, timer:sleep(100), %% Receive the metrics and validate them. receive @@ -520,25 +565,33 @@ error_response_after_reply(Config) -> resp_headers := ExpectedRespHeaders, resp_body_length := 0 } = Metrics, - ExpectedRespHeaders = maps:from_list(RespHeaders), + case RespHeaders of + %% The HTTP/3 stream has reset too early so we can't + %% verify the response headers. + unknown -> + ok; + _ -> + ExpectedRespHeaders = maps:from_list(RespHeaders) + end, %% The request process executed normally. #{procs := Procs} = Metrics, [{_, #{ spawn := ProcSpawn, exit := ProcExit, - reason := {crash, _StackTrace} + reason := {crash, StackTrace} }}] = maps:to_list(Procs), true = ProcSpawn =< ProcExit, %% Confirm other metadata are as expected. #{ ref := _, pid := From, - streamid := 1, - reason := {internal_error, {'EXIT', _Pid, {crash, _StackTrace}}, 'Stream process crashed.'}, + streamid := StreamID, + reason := {internal_error, {'EXIT', _Pid, {crash, StackTrace}}, 'Stream process crashed.'}, req := #{}, informational := [], user_data := #{} } = Metrics, + do_check_streamid(StreamID, Config), %% All good! gun:close(ConnPid) end. diff --git a/test/misc_SUITE.erl b/test/misc_SUITE.erl index 6245636..c918321 100644 --- a/test/misc_SUITE.erl +++ b/test/misc_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -21,29 +21,29 @@ -import(cowboy_test, [gun_open/1]). all() -> - [{group, app}, {group, set_env}|cowboy_test:common_all()]. + [{group, app}, {group, env}|cowboy_test:common_all()]. groups() -> Common = ct_helper:all(?MODULE) - -- [restart_gracefully, set_env, set_env_missing], + -- [restart_gracefully, get_env, set_env, set_env_missing], [ {app, [], [restart_gracefully]}, - {set_env, [parallel], [set_env, set_env_missing]} + {env, [parallel], [get_env, set_env, set_env_missing]} |cowboy_test:common_groups(Common)]. init_per_group(Name=app, Config) -> cowboy_test:init_http(Name, #{ env => #{dispatch => init_dispatch(Config)} }, Config); -init_per_group(set_env, Config) -> +init_per_group(env, Config) -> Config; init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). -end_per_group(set_env, _) -> +end_per_group(env, _) -> ok; end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"localhost", [ @@ -84,6 +84,26 @@ router_invalid_path(Config) -> {response, _, 400, _} = gun:await(ConnPid, Ref), ok. +get_env(Config0) -> + doc("Ensure we can retrieve middleware environment values."), + Dispatch = init_dispatch(Config0), + _Config = cowboy_test:init_http(?FUNCTION_NAME, #{ + env => #{ + dispatch => Dispatch, + the_key => the_value + } + }, Config0), + try + Dispatch = cowboy:get_env(?FUNCTION_NAME, dispatch), + Dispatch = cowboy:get_env(?FUNCTION_NAME, dispatch, the_default), + the_value = cowboy:get_env(?FUNCTION_NAME, the_key), + the_value = cowboy:get_env(?FUNCTION_NAME, the_key, the_default), + {'EXIT', _} = (catch cowboy:get_env(?FUNCTION_NAME, missing_key)), + the_default = cowboy:get_env(?FUNCTION_NAME, missing_key, the_default) + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + set_env(Config0) -> doc("Live replace a middleware environment value."), Config = cowboy_test:init_http(?FUNCTION_NAME, #{ diff --git a/test/plain_handler_SUITE.erl b/test/plain_handler_SUITE.erl index e980d5b..756c0a6 100644 --- a/test/plain_handler_SUITE.erl +++ b/test/plain_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -39,7 +39,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Routes. @@ -58,8 +58,15 @@ crash_after_reply(Config) -> Ref = gun:get(ConnPid, "/crash/reply", [ {<<"accept-encoding">>, <<"gzip">>} ]), - {response, fin, 200, _} = gun:await(ConnPid, Ref), - {error, timeout} = gun:await(ConnPid, Ref, 1000), + Protocol = config(protocol, Config), + _ = case gun:await(ConnPid, Ref) of + {response, fin, 200, _} -> + {error, timeout} = gun:await(ConnPid, Ref, 1000); + %% See maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} + when Protocol =:= http3 -> + ok + end, gun:close(ConnPid). crash_before_reply(Config) -> diff --git a/test/proxy_header_SUITE.erl b/test/proxy_header_SUITE.erl index be6ab04..cb6ab47 100644 --- a/test/proxy_header_SUITE.erl +++ b/test/proxy_header_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -71,6 +71,30 @@ init_dispatch() -> %% Tests. +fail_gracefully_on_disconnect(Config) -> + doc("Probing a port must not generate a crash"), + {ok, Socket} = gen_tcp:connect("localhost", config(port, Config), + [binary, {active, false}, {packet, raw}]), + timer:sleep(50), + Pid = case config(type, Config) of + tcp -> ct_helper:get_remote_pid_tcp(Socket); + %% We connect to a TLS port using a TCP socket so we need + %% to first obtain the remote pid of the TCP socket, which + %% is a TLS socket on the server, and then get the real + %% remote pid from its state. + ssl -> ct_helper:get_remote_pid_tls_state(ct_helper:get_remote_pid_tcp(Socket)) + end, + Ref = erlang:monitor(process, Pid), + gen_tcp:close(Socket), + receive + {'DOWN', Ref, process, Pid, {shutdown, closed}} -> + ok; + {'DOWN', Ref, process, Pid, Reason} -> + error(Reason) + after 500 -> + error(timeout) + end. + v1_proxy_header(Config) -> doc("Confirm we can read the proxy header at the start of the connection."), ProxyInfo = #{ @@ -126,7 +150,8 @@ do_proxy_header_https(Config, ProxyInfo) -> {ok, Socket0} = gen_tcp:connect("localhost", config(port, Config), [binary, {active, false}, {packet, raw}]), ok = gen_tcp:send(Socket0, ranch_proxy_header:header(ProxyInfo)), - {ok, Socket} = ssl:connect(Socket0, [], 1000), + TlsOpts = ct_helper:get_certs_from_ets(), + {ok, Socket} = ssl:connect(Socket0, TlsOpts, 1000), do_proxy_header_http_common({raw_client, Socket, ssl}, ProxyInfo). do_proxy_header_http_common(Client, ProxyInfo) -> @@ -151,7 +176,9 @@ do_proxy_header_h2(Config, ProxyInfo) -> {ok, Socket0} = gen_tcp:connect("localhost", config(port, Config), [binary, {active, false}, {packet, raw}]), ok = gen_tcp:send(Socket0, ranch_proxy_header:header(ProxyInfo)), - {ok, Socket} = ssl:connect(Socket0, [{alpn_advertised_protocols, [<<"h2">>]}], 1000), + TlsOpts = ct_helper:get_certs_from_ets(), + {ok, Socket} = ssl:connect(Socket0, + [{alpn_advertised_protocols, [<<"h2">>]}|TlsOpts], 1000), do_proxy_header_h2_common({raw_client, Socket, ssl}, ProxyInfo). do_proxy_header_h2c(Config, ProxyInfo) -> diff --git a/test/req_SUITE.erl b/test/req_SUITE.erl index 352b2a0..9036cac 100644 --- a/test/req_SUITE.erl +++ b/test/req_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2016-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -46,7 +46,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Routes. @@ -57,13 +57,16 @@ init_dispatch(Config) -> {"/resp/:key[/:arg]", resp_h, []}, {"/multipart[/:key]", multipart_h, []}, {"/args/:key/:arg[/:default]", echo_h, []}, - {"/crash/:key/period", echo_h, #{length => 999999999, period => 1000, crash => true}}, + {"/crash/:key/period", echo_h, + #{length => 999999999, period => 1000, timeout => 5000, crash => true}}, {"/no-opts/:key", echo_h, #{crash => true}}, {"/opts/:key/length", echo_h, #{length => 1000}}, {"/opts/:key/period", echo_h, #{length => 999999999, period => 2000}}, {"/opts/:key/timeout", echo_h, #{timeout => 1000, crash => true}}, {"/100-continue/:key", echo_h, []}, {"/full/:key", echo_h, []}, + {"/auto-sync/:key", echo_h, []}, + {"/auto-async/:key", echo_h, []}, {"/spawn/:key", echo_h, []}, {"/no/:key", echo_h, []}, {"/direct/:key/[...]", echo_h, []}, @@ -104,13 +107,17 @@ do_get(Path, Config) -> do_get(Path, Headers, Config) -> ConnPid = gun_open(Config), Ref = gun:get(ConnPid, Path, [{<<"accept-encoding">>, <<"gzip">>}|Headers]), - {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref, infinity), - {ok, RespBody} = case IsFin of - nofin -> gun:await_body(ConnPid, Ref, infinity); - fin -> {ok, <<>>} - end, - gun:close(ConnPid), - {Status, RespHeaders, do_decode(RespHeaders, RespBody)}. + case gun:await(ConnPid, Ref, infinity) of + {response, IsFin, Status, RespHeaders} -> + {ok, RespBody} = case IsFin of + nofin -> gun:await_body(ConnPid, Ref, infinity); + fin -> {ok, <<>>} + end, + gun:close(ConnPid), + {Status, RespHeaders, do_decode(RespHeaders, RespBody)}; + {error, {stream_error, Error}} -> + Error + end. do_get_body(Path, Config) -> do_get_body(Path, [], Config). @@ -139,7 +146,9 @@ do_get_inform(Path, Config) -> fin -> {ok, <<>>} end, gun:close(ConnPid), - {InfoStatus, InfoHeaders, RespStatus, RespHeaders, do_decode(RespHeaders, RespBody)} + {InfoStatus, InfoHeaders, RespStatus, RespHeaders, do_decode(RespHeaders, RespBody)}; + {error, {stream_error, Error}} -> + Error end. do_decode(Headers, Body) -> @@ -181,25 +190,20 @@ bindings(Config) -> cert(Config) -> case config(type, Config) of tcp -> doc("TLS certificates can only be provided over TLS."); - ssl -> do_cert(Config) + ssl -> do_cert(Config); + quic -> do_cert(Config) end. -do_cert(Config0) -> +do_cert(Config) -> doc("A client TLS certificate was provided."), - {CaCert, Cert, Key} = ct_helper:make_certs(), - Config = [{tls_opts, [ - {cert, Cert}, - {key, Key}, - {cacerts, [CaCert]} - ]}|Config0], Cert = do_get_body("/cert", Config), Cert = do_get_body("/direct/cert", Config), ok. cert_undefined(Config) -> doc("No client TLS certificate was provided."), - <<"undefined">> = do_get_body("/cert", Config), - <<"undefined">> = do_get_body("/direct/cert", Config), + <<"undefined">> = do_get_body("/cert", [{no_cert, true}|Config]), + <<"undefined">> = do_get_body("/direct/cert", [{no_cert, true}|Config]), ok. header(Config) -> @@ -239,8 +243,10 @@ match_cookies(Config) -> <<"#{}">> = do_get_body("/match/cookies", [{<<"cookie">>, "a=b; c=d"}], Config), <<"#{a => <<\"b\">>}">> = do_get_body("/match/cookies/a", [{<<"cookie">>, "a=b; c=d"}], Config), <<"#{c => <<\"d\">>}">> = do_get_body("/match/cookies/c", [{<<"cookie">>, "a=b; c=d"}], Config), - <<"#{a => <<\"b\">>,c => <<\"d\">>}">> = do_get_body("/match/cookies/a/c", - [{<<"cookie">>, "a=b; c=d"}], Config), + case do_get_body("/match/cookies/a/c", [{<<"cookie">>, "a=b; c=d"}], Config) of + <<"#{a => <<\"b\">>,c => <<\"d\">>}">> -> ok; + <<"#{c => <<\"d\">>,a => <<\"b\">>}">> -> ok + end, %% Ensure match errors result in a 400 response. {400, _, _} = do_get("/match/cookies/a/c", [{<<"cookie">>, "a=b"}], Config), @@ -253,11 +259,21 @@ match_qs(Config) -> <<"#{}">> = do_get_body("/match/qs?a=b&c=d", Config), <<"#{a => <<\"b\">>}">> = do_get_body("/match/qs/a?a=b&c=d", Config), <<"#{c => <<\"d\">>}">> = do_get_body("/match/qs/c?a=b&c=d", Config), - <<"#{a => <<\"b\">>,c => <<\"d\">>}">> = do_get_body("/match/qs/a/c?a=b&c=d", Config), - <<"#{a => <<\"b\">>,c => true}">> = do_get_body("/match/qs/a/c?a=b&c", Config), - <<"#{a => true,c => <<\"d\">>}">> = do_get_body("/match/qs/a/c?a&c=d", Config), + case do_get_body("/match/qs/a/c?a=b&c=d", Config) of + <<"#{a => <<\"b\">>,c => <<\"d\">>}">> -> ok; + <<"#{c => <<\"d\">>,a => <<\"b\">>}">> -> ok + end, + case do_get_body("/match/qs/a/c?a=b&c", Config) of + <<"#{a => <<\"b\">>,c => true}">> -> ok; + <<"#{c => true,a => <<\"b\">>}">> -> ok + end, + case do_get_body("/match/qs/a/c?a&c=d", Config) of + <<"#{a => true,c => <<\"d\">>}">> -> ok; + <<"#{c => <<\"d\">>,a => true}">> -> ok + end, %% Ensure match errors result in a 400 response. {400, _, _} = do_get("/match/qs/a/c?a=b", [], Config), + {400, _, _} = do_get("/match/qs_with_constraints", [], Config), %% This function is tested more extensively through unit tests. ok. @@ -377,7 +393,8 @@ port(Config) -> Port = do_get_body("/direct/port", Config), ExpectedPort = case config(type, Config) of tcp -> <<"80">>; - ssl -> <<"443">> + ssl -> <<"443">>; + quic -> <<"443">> end, ExpectedPort = do_get_body("/port", [{<<"host">>, <<"localhost">>}], Config), ExpectedPort = do_get_body("/direct/port", [{<<"host">>, <<"localhost">>}], Config), @@ -403,7 +420,8 @@ do_scheme(Path, Config) -> Transport = config(type, Config), case do_get_body(Path, Config) of <<"http">> when Transport =:= tcp -> ok; - <<"https">> when Transport =:= ssl -> ok + <<"https">> when Transport =:= ssl -> ok; + <<"https">> when Transport =:= quic -> ok end. sock(Config) -> @@ -416,7 +434,8 @@ uri(Config) -> doc("Request URI building/modification."), Scheme = case config(type, Config) of tcp -> <<"http">>; - ssl -> <<"https">> + ssl -> <<"https">>; + quic -> <<"https">> end, SLen = byte_size(Scheme), Port = integer_to_binary(config(port, Config)), @@ -450,7 +469,8 @@ do_version(Path, Config) -> Protocol = config(protocol, Config), case do_get_body(Path, Config) of <<"HTTP/1.1">> when Protocol =:= http -> ok; - <<"HTTP/2">> when Protocol =:= http2 -> ok + <<"HTTP/2">> when Protocol =:= http2 -> ok; + <<"HTTP/3">> when Protocol =:= http3 -> ok end. %% Tests: Request body. @@ -504,11 +524,19 @@ read_body_period(Config) -> %% for 2 seconds. The test succeeds if we get some of the data back %% (meaning the function will have returned after the period ends). gun:data(ConnPid, Ref, nofin, Body), - {response, nofin, 200, _} = gun:await(ConnPid, Ref, infinity), - {data, _, Data} = gun:await(ConnPid, Ref, infinity), - %% We expect to read at least some data. - true = Data =/= <<>>, - gun:close(ConnPid). + Response = gun:await(ConnPid, Ref, infinity), + case Response of + {response, nofin, 200, _} -> + {data, _, Data} = gun:await(ConnPid, Ref, infinity), + %% We expect to read at least some data. + true = Data =/= <<>>, + gun:close(ConnPid); + %% We got a crash, likely because the environment + %% was overloaded and the timeout triggered. Try again. + {response, _, 500, _} -> + gun:close(ConnPid), + read_body_period(Config) + end. %% We expect a crash. do_read_body_timeout(Path, Body, Config) -> @@ -516,9 +544,21 @@ do_read_body_timeout(Path, Body, Config) -> Ref = gun:headers(ConnPid, "POST", Path, [ {<<"content-length">>, integer_to_binary(byte_size(Body))} ]), - {response, _, 500, _} = gun:await(ConnPid, Ref, infinity), + case gun:await(ConnPid, Ref, infinity) of + {response, _, 500, _} -> + ok; + %% See do_maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} -> + ok + end, gun:close(ConnPid). +read_body_auto(Config) -> + doc("Read the request body using auto mode."), + <<0:80000000>> = do_body("POST", "/auto-sync/read_body", [], <<0:80000000>>, Config), + <<0:80000000>> = do_body("POST", "/auto-async/read_body", [], <<0:80000000>>, Config), + ok. + read_body_spawn(Config) -> doc("Confirm we can use cowboy_req:read_body/1,2 from another process."), <<"hello world!">> = do_body("POST", "/spawn/read_body", [], "hello world!", Config), @@ -549,7 +589,8 @@ do_read_body_expect_100_continue(Path, Config) -> fin -> {ok, <<>>} end, gun:close(ConnPid), - do_decode(RespHeaders, RespBody). + do_decode(RespHeaders, RespBody), + ok. read_urlencoded_body(Config) -> doc("application/x-www-form-urlencoded request body."), @@ -576,8 +617,20 @@ do_read_urlencoded_body_too_large(Path, Body, Config) -> {<<"content-length">>, integer_to_binary(iolist_size(Body))} ]), gun:data(ConnPid, Ref, fin, Body), - {response, _, 413, _} = gun:await(ConnPid, Ref, infinity), - gun:close(ConnPid). + Response = gun:await(ConnPid, Ref, infinity), + gun:close(ConnPid), + case Response of + {response, _, 413, _} -> + ok; + %% We got the wrong crash, likely because the environment + %% was overloaded and the timeout triggered. Try again. + {response, _, 408, _} -> + do_read_urlencoded_body_too_large(Path, Body, Config); + %% Timing issues make it possible for the connection to be + %% closed before the data went through. We retry. + {error, {stream_error, {closed, {error,closed}}}} -> + do_read_urlencoded_body_too_large(Path, Body, Config) + end. read_urlencoded_body_too_long(Config) -> doc("application/x-www-form-urlencoded request body sent too slow. " @@ -592,25 +645,37 @@ do_read_urlencoded_body_too_long(Path, Body, Config) -> {<<"content-length">>, integer_to_binary(byte_size(Body) * 2)} ]), gun:data(ConnPid, Ref, nofin, Body), - {response, _, 408, RespHeaders} = gun:await(ConnPid, Ref, infinity), - _ = case config(protocol, Config) of - http -> + Protocol = config(protocol, Config), + case gun:await(ConnPid, Ref, infinity) of + {response, _, 408, RespHeaders} when Protocol =:= http -> %% 408 error responses should close HTTP/1.1 connections. - {_, <<"close">>} = lists:keyfind(<<"connection">>, 1, RespHeaders); - http2 -> - ok - end, - gun:close(ConnPid). + {_, <<"close">>} = lists:keyfind(<<"connection">>, 1, RespHeaders), + gun:close(ConnPid); + {response, _, 408, _} when Protocol =:= http2; Protocol =:= http3 -> + gun:close(ConnPid); + %% We must have hit the timeout due to busy CI environment. Retry. + {response, _, 500, _} -> + gun:close(ConnPid), + do_read_urlencoded_body_too_long(Path, Body, Config) + end. read_and_match_urlencoded_body(Config) -> doc("Read and match an application/x-www-form-urlencoded request body."), <<"#{}">> = do_body("POST", "/match/body_qs", [], "a=b&c=d", Config), <<"#{a => <<\"b\">>}">> = do_body("POST", "/match/body_qs/a", [], "a=b&c=d", Config), <<"#{c => <<\"d\">>}">> = do_body("POST", "/match/body_qs/c", [], "a=b&c=d", Config), - <<"#{a => <<\"b\">>,c => <<\"d\">>}">> - = do_body("POST", "/match/body_qs/a/c", [], "a=b&c=d", Config), - <<"#{a => <<\"b\">>,c => true}">> = do_body("POST", "/match/body_qs/a/c", [], "a=b&c", Config), - <<"#{a => true,c => <<\"d\">>}">> = do_body("POST", "/match/body_qs/a/c", [], "a&c=d", Config), + case do_body("POST", "/match/body_qs/a/c", [], "a=b&c=d", Config) of + <<"#{a => <<\"b\">>,c => <<\"d\">>}">> -> ok; + <<"#{c => <<\"d\">>,a => <<\"b\">>}">> -> ok + end, + case do_body("POST", "/match/body_qs/a/c", [], "a=b&c", Config) of + <<"#{a => <<\"b\">>,c => true}">> -> ok; + <<"#{c => true,a => <<\"b\">>}">> -> ok + end, + case do_body("POST", "/match/body_qs/a/c", [], "a&c=d", Config) of + <<"#{a => true,c => <<\"d\">>}">> -> ok; + <<"#{c => <<\"d\">>,a => true}">> -> ok + end, %% Ensure match errors result in a 400 response. {400, _} = do_body_error("POST", "/match/body_qs/a/c", [], "a=b", Config), %% Ensure parse errors result in a 400 response. @@ -768,18 +833,18 @@ set_resp_cookie(Config) -> doc("Response using set_resp_cookie."), %% Single cookie, no options. {200, Headers1, _} = do_get("/resp/set_resp_cookie3", Config), - {_, <<"mycookie=myvalue; Version=1">>} + {_, <<"mycookie=myvalue">>} = lists:keyfind(<<"set-cookie">>, 1, Headers1), %% Single cookie, with options. {200, Headers2, _} = do_get("/resp/set_resp_cookie4", Config), - {_, <<"mycookie=myvalue; Version=1; Path=/resp/set_resp_cookie4">>} + {_, <<"mycookie=myvalue; Path=/resp/set_resp_cookie4">>} = lists:keyfind(<<"set-cookie">>, 1, Headers2), %% Multiple cookies. {200, Headers3, _} = do_get("/resp/set_resp_cookie3/multiple", Config), [_, _] = [H || H={<<"set-cookie">>, _} <- Headers3], %% Overwrite previously set cookie. {200, Headers4, _} = do_get("/resp/set_resp_cookie3/overwrite", Config), - {_, <<"mycookie=overwrite; Version=1">>} + {_, <<"mycookie=overwrite">>} = lists:keyfind(<<"set-cookie">>, 1, Headers4), ok. @@ -787,6 +852,8 @@ set_resp_header(Config) -> doc("Response using set_resp_header."), {200, Headers, <<"OK">>} = do_get("/resp/set_resp_header", Config), true = lists:keymember(<<"content-type">>, 1, Headers), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/set_resp_header_cookie", Config)), ok. set_resp_headers(Config) -> @@ -794,6 +861,8 @@ set_resp_headers(Config) -> {200, Headers, <<"OK">>} = do_get("/resp/set_resp_headers", Config), true = lists:keymember(<<"content-type">>, 1, Headers), true = lists:keymember(<<"content-encoding">>, 1, Headers), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/set_resp_headers_cookie", Config)), ok. resp_header(Config) -> @@ -855,22 +924,52 @@ delete_resp_header(Config) -> false = lists:keymember(<<"content-type">>, 1, Headers), ok. +%% Data may be lost due to how RESET_STREAM QUIC frame works. +%% Because there is ongoing work for a better way to reset streams +%% (https://www.ietf.org/archive/id/draft-ietf-quic-reliable-stream-reset-03.html) +%% we convert the error to a 500 to keep the tests more explicit +%% at what we expect. +%% @todo When RESET_STREAM_AT gets added we can remove this function. +do_maybe_h3_error2({stream_error, h3_internal_error, _}) -> {500, []}; +do_maybe_h3_error2(Result) -> Result. + +do_maybe_h3_error3({stream_error, h3_internal_error, _}) -> {500, [], <<>>}; +do_maybe_h3_error3(Result) -> Result. + inform2(Config) -> doc("Informational response(s) without headers, followed by the real response."), {102, [], 200, _, _} = do_get_inform("/resp/inform2/102", Config), {102, [], 200, _, _} = do_get_inform("/resp/inform2/binary", Config), - {500, _} = do_get_inform("/resp/inform2/error", Config), + {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform2/error", Config)), {102, [], 200, _, _} = do_get_inform("/resp/inform2/twice", Config), - ok. + %% With HTTP/1.1 and HTTP/2 we will not get an error. + %% With HTTP/3 however the stream will occasionally + %% be reset before Gun receives the response. + case do_get_inform("/resp/inform2/after_reply", Config) of + {200, _} -> + ok; + {stream_error, h3_internal_error, _} -> + ok + end. inform3(Config) -> doc("Informational response(s) with headers, followed by the real response."), Headers = [{<<"ext-header">>, <<"ext-value">>}], {102, Headers, 200, _, _} = do_get_inform("/resp/inform3/102", Config), {102, Headers, 200, _, _} = do_get_inform("/resp/inform3/binary", Config), - {500, _} = do_get_inform("/resp/inform3/error", Config), + {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform3/error", Config)), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _} = do_maybe_h3_error2(do_get_inform("/resp/inform3/set_cookie", Config)), {102, Headers, 200, _, _} = do_get_inform("/resp/inform3/twice", Config), - ok. + %% With HTTP/1.1 and HTTP/2 we will not get an error. + %% With HTTP/3 however the stream will occasionally + %% be reset before Gun receives the response. + case do_get_inform("/resp/inform3/after_reply", Config) of + {200, _} -> + ok; + {stream_error, h3_internal_error, _} -> + ok + end. reply2(Config) -> doc("Response with default headers and no body."), @@ -878,9 +977,8 @@ reply2(Config) -> {201, _, _} = do_get("/resp/reply2/201", Config), {404, _, _} = do_get("/resp/reply2/404", Config), {200, _, _} = do_get("/resp/reply2/binary", Config), - {500, _, _} = do_get("/resp/reply2/error", Config), - %% @todo We want to crash when reply or stream_reply is called twice. - %% How to test this properly? This isn't enough. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply2/error", Config)), + %% @todo How to test this properly? This isn't enough. {200, _, _} = do_get("/resp/reply2/twice", Config), ok. @@ -892,7 +990,9 @@ reply3(Config) -> true = lists:keymember(<<"content-type">>, 1, Headers2), {404, Headers3, _} = do_get("/resp/reply3/404", Config), true = lists:keymember(<<"content-type">>, 1, Headers3), - {500, _, _} = do_get("/resp/reply3/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply3/error", Config)), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply3/set_cookie", Config)), ok. reply4(Config) -> @@ -900,11 +1000,11 @@ reply4(Config) -> {200, _, <<"OK">>} = do_get("/resp/reply4/200", Config), {201, _, <<"OK">>} = do_get("/resp/reply4/201", Config), {404, _, <<"OK">>} = do_get("/resp/reply4/404", Config), - {500, _, _} = do_get("/resp/reply4/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply4/error", Config)), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/reply4/set_cookie", Config)), ok. -%% @todo Crash when stream_reply is called twice. - stream_reply2(Config) -> doc("Response with default headers and streamed body."), Body = <<0:8000000>>, @@ -912,9 +1012,37 @@ stream_reply2(Config) -> {201, _, Body} = do_get("/resp/stream_reply2/201", Config), {404, _, Body} = do_get("/resp/stream_reply2/404", Config), {200, _, Body} = do_get("/resp/stream_reply2/binary", Config), - {500, _, _} = do_get("/resp/stream_reply2/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply2/error", Config)), ok. +stream_reply2_twice(Config) -> + doc("Attempting to stream a response twice results in a crash."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/resp/stream_reply2/twice", + [{<<"accept-encoding">>, <<"gzip">>}]), + {response, nofin, 200, _} = gun:await(ConnPid, Ref, infinity), + Protocol = config(protocol, Config), + Flavor = config(flavor, Config), + case {Protocol, Flavor, gun:await_body(ConnPid, Ref, infinity)} of + %% In HTTP/1.1 we cannot propagate an error at that point. + %% The response will simply not have a body. + {http, vanilla, {ok, <<>>}} -> + ok; + %% When compression was used we do get gzip headers. But + %% we do not have any data in the zlib stream. + {http, compress, {ok, Data}} -> + Z = zlib:open(), + zlib:inflateInit(Z, 31), + 0 = iolist_size(zlib:inflate(Z, Data)), + ok; + %% In HTTP/2 and HTTP/3 the stream gets reset with an appropriate error. + {http2, _, {error, {stream_error, {stream_error, internal_error, _}}}} -> + ok; + {http3, _, {error, {stream_error, {stream_error, h3_internal_error, _}}}} -> + ok + end, + gun:close(ConnPid). + stream_reply3(Config) -> doc("Response with additional headers and streamed body."), Body = <<0:8000000>>, @@ -924,7 +1052,9 @@ stream_reply3(Config) -> true = lists:keymember(<<"content-type">>, 1, Headers2), {404, Headers3, Body} = do_get("/resp/stream_reply3/404", Config), true = lists:keymember(<<"content-type">>, 1, Headers3), - {500, _, _} = do_get("/resp/stream_reply3/error", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply3/error", Config)), + %% The set-cookie header is special. set_resp_cookie must be used. + {500, _, _} = do_maybe_h3_error3(do_get("/resp/stream_reply3/set_cookie", Config)), ok. stream_body_fin0(Config) -> @@ -1008,8 +1138,11 @@ stream_body_content_length_nofin_error(Config) -> end end; http2 -> - %% @todo HTTP2 should have the same content-length checks - ok + %% @todo HTTP/2 should have the same content-length checks. + {skip, "Implement the test for HTTP/2."}; + http3 -> + %% @todo HTTP/3 should have the same content-length checks. + {skip, "Implement the test for HTTP/3."} end. stream_body_concurrent(Config) -> @@ -1104,6 +1237,35 @@ stream_trailers_no_te(Config) -> <<"Hello world!">> = do_decode(RespHeaders, RespBody), gun:close(ConnPid). +stream_trailers_set_cookie(Config) -> + doc("Trying to send set-cookie in trailers should result in a crash."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/resp/stream_trailers/set_cookie", [ + {<<"accept-encoding">>, <<"gzip">>}, + {<<"te">>, <<"trailers">>} + ]), + Protocol = config(protocol, Config), + case gun:await(ConnPid, Ref, infinity) of + {response, nofin, 200, _} when Protocol =:= http -> + %% Trailers are not sent because of the stream error. + {ok, _Body} = gun:await_body(ConnPid, Ref, infinity), + {error, timeout} = gun:await_body(ConnPid, Ref, 1000), + ok; + {response, nofin, 200, _} when Protocol =:= http2 -> + {error, {stream_error, {stream_error, internal_error, _}}} + = gun:await_body(ConnPid, Ref, infinity), + ok; + {response, nofin, 200, _} when Protocol =:= http3 -> + {error, {stream_error, {stream_error, h3_internal_error, _}}} + = gun:await_body(ConnPid, Ref, infinity), + ok; + %% The RST_STREAM arrived before the start of the response. + %% See maybe_h3_error comment for details. + {error, {stream_error, {stream_error, h3_internal_error, _}}} when Protocol =:= http3 -> + ok + end, + gun:close(ConnPid). + do_trailers(Path, Config) -> ConnPid = gun_open(Config), Ref = gun:get(ConnPid, Path, [ @@ -1127,26 +1289,45 @@ do_trailers(Path, Config) -> push(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push", Config); - http2 -> do_push_http2(Config) + http2 -> do_push_http2(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. +push_after_reply(Config) -> + doc("Trying to push a response after the final response results in a crash."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/resp/push/after_reply", []), + %% With HTTP/1.1 and HTTP/2 we will not get an error. + %% With HTTP/3 however the stream will occasionally + %% be reset before Gun receives the response. + case gun:await(ConnPid, Ref, infinity) of + {response, fin, 200, _} -> + ok; + {error, {stream_error, {stream_error, h3_internal_error, _}}} -> + ok + end, + gun:close(ConnPid). + push_method(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push/method", Config); - http2 -> do_push_http2_method(Config) + http2 -> do_push_http2_method(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. push_origin(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push/origin", Config); - http2 -> do_push_http2_origin(Config) + http2 -> do_push_http2_origin(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. push_qs(Config) -> case config(protocol, Config) of http -> do_push_http("/resp/push/qs", Config); - http2 -> do_push_http2_qs(Config) + http2 -> do_push_http2_qs(Config); + http3 -> {skip, "Implement server push for HTTP/3."} end. do_push_http(Path, Config) -> @@ -1154,7 +1335,7 @@ do_push_http(Path, Config) -> ConnPid = gun_open(Config), Ref = gun:get(ConnPid, Path, []), {response, fin, 200, _} = gun:await(ConnPid, Ref, infinity), - ok. + gun:close(ConnPid). do_push_http2(Config) -> doc("Pushed responses."), diff --git a/test/rest_handler_SUITE.erl b/test/rest_handler_SUITE.erl index 1667565..6c1f1c1 100644 --- a/test/rest_handler_SUITE.erl +++ b/test/rest_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -32,7 +32,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Dispatch configuration. @@ -85,7 +85,7 @@ accept_callback_missing(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"content-type">>, <<"text/plain">>} ], <<"Missing!">>), - {response, fin, 500, _} = gun:await(ConnPid, Ref), + {response, fin, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)), ok. accept_callback_patch_false(Config) -> @@ -127,7 +127,7 @@ do_accept_callback_true(Config, Fun) -> ok. charset_in_content_types_provided(Config) -> - doc("When a charset is matched explictly in content_types_provided, " + doc("When a charset is matched explicitly in content_types_provided, " "that charset is used and the charsets_provided callback is ignored."), ConnPid = gun_open(Config), Ref = gun:get(ConnPid, "/charset_in_content_types_provided", [ @@ -472,7 +472,7 @@ delete_resource_missing(Config) -> Ref = gun:delete(ConnPid, "/delete_resource?missing", [ {<<"accept-encoding">>, <<"gzip">>} ]), - {response, _, 500, _} = gun:await(ConnPid, Ref), + {response, _, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)), ok. create_resource_created(Config) -> @@ -571,6 +571,17 @@ generate_etag_missing(Config) -> false = lists:keyfind(<<"etag">>, 1, Headers), ok. +generate_etag_undefined(Config) -> + doc("The etag header must not be sent when " + "the generate_etag callback returns undefined."), + ConnPid = gun_open(Config), + Ref = gun:get(ConnPid, "/generate_etag?undefined", [ + {<<"accept-encoding">>, <<"gzip">>} + ]), + {response, _, 200, Headers} = gun:await(ConnPid, Ref), + false = lists:keyfind(<<"etag">>, 1, Headers), + ok. + generate_etag_binary_strong(Config) -> doc("The etag header must be sent when the generate_etag " "callback returns a strong binary. (RFC7232 2.3)"), @@ -639,10 +650,16 @@ do_generate_etag(Config, Qs, ReqHeaders, Status, Etag) -> {<<"accept-encoding">>, <<"gzip">>} |ReqHeaders ]), - {response, _, Status, RespHeaders} = gun:await(ConnPid, Ref), + {response, _, Status, RespHeaders} = do_maybe_h3_error(gun:await(ConnPid, Ref)), Etag = lists:keyfind(<<"etag">>, 1, RespHeaders), ok. +%% See do_maybe_h3_error2 comment. +do_maybe_h3_error({error, {stream_error, {stream_error, h3_internal_error, _}}}) -> + {response, fin, 500, []}; +do_maybe_h3_error(Result) -> + Result. + if_range_etag_equal(Config) -> doc("When the if-range header matches, a 206 partial content " "response is expected for an otherwise valid range request. (RFC7233 3.2)"), @@ -795,7 +812,7 @@ provide_callback_missing(Config) -> doc("A 500 response must be sent when the ProvideCallback can't be called."), ConnPid = gun_open(Config), Ref = gun:get(ConnPid, "/provide_callback_missing", [{<<"accept-encoding">>, <<"gzip">>}]), - {response, fin, 500, _} = gun:await(ConnPid, Ref), + {response, fin, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)), ok. provide_range_callback(Config) -> @@ -951,7 +968,7 @@ provide_range_callback_missing(Config) -> {<<"accept-encoding">>, <<"gzip">>}, {<<"range">>, <<"bytes=0-">>} ]), - {response, fin, 500, _} = gun:await(ConnPid, Ref), + {response, fin, 500, _} = do_maybe_h3_error(gun:await(ConnPid, Ref)), ok. range_ignore_unknown_unit(Config) -> diff --git a/test/rfc6585_SUITE.erl b/test/rfc6585_SUITE.erl index 1f65f78..17cbb07 100644 --- a/test/rfc6585_SUITE.erl +++ b/test/rfc6585_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -30,7 +30,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ diff --git a/test/rfc7230_SUITE.erl b/test/rfc7230_SUITE.erl index 9846a0f..17d1905 100644 --- a/test/rfc7230_SUITE.erl +++ b/test/rfc7230_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2015-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2015-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -22,6 +22,7 @@ -import(cowboy_test, [raw_open/1]). -import(cowboy_test, [raw_send/2]). -import(cowboy_test, [raw_recv_head/1]). +-import(cowboy_test, [raw_recv_rest/3]). -import(cowboy_test, [raw_recv/3]). suite() -> @@ -63,13 +64,7 @@ do_raw(Config, Data) -> {Headers, Rest2} = cow_http:parse_headers(Rest), case lists:keyfind(<<"content-length">>, 1, Headers) of {_, LengthBin} when LengthBin =/= <<"0">> -> - Length = binary_to_integer(LengthBin), - Body = if - byte_size(Rest2) =:= Length -> Rest2; - true -> - {ok, Body0} = raw_recv(Client, Length - byte_size(Rest2), 5000), - << Rest2/bits, Body0/bits >> - end, + Body = raw_recv_rest(Client, binary_to_integer(LengthBin), Rest2), #{client => Client, version => Version, code => Code, reason => Reason, headers => Headers, body => Body}; _ -> #{client => Client, version => Version, code => Code, reason => Reason, headers => Headers, body => <<>>} @@ -1149,18 +1144,19 @@ reject_invalid_content_length(Config) -> %with a message body too large must be rejected with a 413 status %code and the closing of the connection. (RFC7230 3.3.2) -ignore_content_length_when_transfer_encoding(Config) -> +reject_when_both_content_length_and_transfer_encoding(Config) -> doc("When a message includes both transfer-encoding and content-length " - "headers, the content-length header must be removed before processing " - "the request. (RFC7230 3.3.3)"), - #{code := 200, body := <<"Hello world!">>} = do_raw(Config, [ + "headers, the message may be an attempt at request smuggling. It " + "must be rejected with a 400 status code and the closing of the " + "connection. (RFC7230 3.3.3)"), + #{code := 400, client := Client} = do_raw(Config, [ "POST /echo/read_body HTTP/1.1\r\n" "Host: localhost\r\n" "Transfer-encoding: chunked\r\n" "Content-length: 12\r\n" "\r\n" "6\r\nHello \r\n5\r\nworld\r\n1\r\n!\r\n0\r\n\r\n"]), - ok. + {error, closed} = raw_recv(Client, 0, 1000). %socket_error_while_reading_body(Config) -> %If a socket error occurs while reading the body the server @@ -1512,6 +1508,28 @@ http10_no_connection_header_close(Config) -> {_, <<"close">>} = lists:keyfind(<<"connection">>, 1, RespHeaders), {error, closed} = raw_recv(Client, 0, 1000). +connection_invalid(Config) -> + doc("HTTP/1.1 requests with an invalid Connection header " + "must be rejected with a 400 status code and the closing " + "of the connection. (RFC7230 6.1)"), + #{code := 400, client := Client} = do_raw(Config, [ + "GET / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Connection: jndi{ldap127\r\n" + "\r\n"]), + {error, closed} = raw_recv(Client, 0, 1000). + +http10_connection_invalid(Config) -> + doc("HTTP/1.0 requests with an invalid Connection header " + "must be rejected with a 400 status code and the closing " + "of the connection. (RFC7230 6.1)"), + #{code := 400, client := Client} = do_raw(Config, [ + "GET / HTTP/1.0\r\n" + "Host: localhost\r\n" + "Connection: jndi{ldap127\r\n" + "\r\n"]), + {error, closed} = raw_recv(Client, 0, 1000). + limit_requests_keepalive(Config) -> doc("The maximum number of requests sent using a persistent connection " "must be subject to configuration. The connection must be closed " diff --git a/test/rfc7231_SUITE.erl b/test/rfc7231_SUITE.erl index 6c74391..4475899 100644 --- a/test/rfc7231_SUITE.erl +++ b/test/rfc7231_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -35,7 +35,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ @@ -230,13 +230,15 @@ expect(Config) -> {<<"expect">>, <<"100-continue">>} ]), {inform, 100, _} = gun:await(ConnPid, Ref), - ok. + gun:close(ConnPid). http10_expect(Config) -> case config(protocol, Config) of http -> do_http10_expect(Config); http2 -> + expect(Config); + http3 -> expect(Config) end. @@ -303,6 +305,9 @@ expect_discard_body_close(Config) -> do_expect_discard_body_close(Config); http2 -> doc("There's no reason to close the connection when using HTTP/2, " + "even if a stream body is too large. We just cancel the stream."); + http3 -> + doc("There's no reason to close the connection when using HTTP/3, " "even if a stream body is too large. We just cancel the stream.") end. @@ -424,8 +429,10 @@ http10_status_code_100(Config) -> http -> doc("The 100 Continue status code must not " "be sent to HTTP/1.0 endpoints. (RFC7231 6.2)"), - do_http10_status_code_1xx(100, Config); + do_unsupported_status_code_1xx(100, Config); http2 -> + status_code_100(Config); + http3 -> status_code_100(Config) end. @@ -434,12 +441,16 @@ http10_status_code_101(Config) -> http -> doc("The 101 Switching Protocols status code must not " "be sent to HTTP/1.0 endpoints. (RFC7231 6.2)"), - do_http10_status_code_1xx(101, Config); + do_unsupported_status_code_1xx(101, Config); http2 -> + status_code_101(Config); + http3 -> + %% While 101 is not supported by HTTP/3, there is no + %% wording in RFC9114 that forbids sending it. status_code_101(Config) end. -do_http10_status_code_1xx(StatusCode, Config) -> +do_unsupported_status_code_1xx(StatusCode, Config) -> ConnPid = gun_open(Config, #{http_opts => #{version => 'HTTP/1.0'}}), Ref = gun:get(ConnPid, "/resp/inform2/" ++ integer_to_list(StatusCode), [ {<<"accept-encoding">>, <<"gzip">>} @@ -653,7 +664,9 @@ status_code_408_connection_close(Config) -> http -> do_http11_status_code_408_connection_close(Config); http2 -> - doc("HTTP/2 connections are not closed on 408 responses.") + doc("HTTP/2 connections are not closed on 408 responses."); + http3 -> + doc("HTTP/3 connections are not closed on 408 responses.") end. do_http11_status_code_408_connection_close(Config) -> @@ -744,7 +757,9 @@ status_code_426_upgrade_header(Config) -> http -> do_status_code_426_upgrade_header(Config); http2 -> - doc("HTTP/2 does not support the HTTP/1.1 Upgrade mechanism.") + doc("HTTP/2 does not support the HTTP/1.1 Upgrade mechanism."); + http3 -> + doc("HTTP/3 does not support the HTTP/1.1 Upgrade mechanism.") end. do_status_code_426_upgrade_header(Config) -> diff --git a/test/rfc7538_SUITE.erl b/test/rfc7538_SUITE.erl index 5eb9705..c46d388 100644 --- a/test/rfc7538_SUITE.erl +++ b/test/rfc7538_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -30,7 +30,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ diff --git a/test/rfc7540_SUITE.erl b/test/rfc7540_SUITE.erl index 6d8aa91..f040601 100644 --- a/test/rfc7540_SUITE.erl +++ b/test/rfc7540_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2016-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -12,6 +12,12 @@ %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF %% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +%% Note that Cowboy does not implement the PRIORITY mechanism. +%% Everyone has been moving away from it and it is widely seen +%% as a failure. Setting priorities has been counter productive +%% with regards to performance. Clients have been moving away +%% from the mechanism. + -module(rfc7540_SUITE). -compile(export_all). -compile(nowarn_export_all). @@ -28,9 +34,9 @@ all() -> [{group, clear}, {group, tls}]. groups() -> - Modules = ct_helper:all(?MODULE), - Clear = [M || M <- Modules, lists:sublist(atom_to_list(M), 4) =/= "alpn"] -- [prior_knowledge_reject_tls], - TLS = [M || M <- Modules, lists:sublist(atom_to_list(M), 4) =:= "alpn"] ++ [prior_knowledge_reject_tls], + Tests = ct_helper:all(?MODULE), + Clear = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =/= "alpn"] -- [prior_knowledge_reject_tls], + TLS = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =:= "alpn"] ++ [prior_knowledge_reject_tls], [{clear, [parallel], Clear}, {tls, [parallel], TLS}]. init_per_group(Name = clear, Config) -> @@ -483,14 +489,6 @@ http_upgrade_client_preface_settings_ack_timeout(Config) -> %% important, an OPTIONS request can be used to perform the upgrade to %% HTTP/2, at the cost of an additional round trip. -%% @todo If we ever handle priority, we need to check that the initial -%% HTTP/1.1 request has default priority. The relevant RFC quote is: -%% -%% 3.2 -%% The HTTP/1.1 request that is sent prior to upgrade is assigned a -%% stream identifier of 1 (see Section 5.1.1) with default priority -%% values (Section 5.3.5). - http_upgrade_response(Config) -> doc("A response must be sent to the initial HTTP/1.1 request " "after switching to HTTP/2. The response must use " @@ -589,16 +587,20 @@ http_upgrade_response_half_closed(Config) -> alpn_ignore_h2c(Config) -> doc("An h2c ALPN protocol identifier must be ignored. (RFC7540 3.3)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2c">>, <<"http/1.1">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2c">>, <<"http/1.1">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"http/1.1">>} = ssl:negotiated_protocol(Socket), ok. alpn_server_preface(Config) -> doc("The first frame must be a SETTINGS frame " "for the server connection preface. (RFC7540 3.3, RFC7540 3.5, RFC7540 6.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Receive the server preface. {ok, << _:24, 4:8, 0:40 >>} = ssl:recv(Socket, 9, 1000), @@ -607,8 +609,10 @@ alpn_server_preface(Config) -> alpn_client_preface_timeout(Config) -> doc("Clients negotiating HTTP/2 and not sending a preface in " "a timely manner must be disconnected."), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Receive the server preface. {ok, << Len:24 >>} = ssl:recv(Socket, 3, 1000), @@ -620,8 +624,10 @@ alpn_client_preface_timeout(Config) -> alpn_reject_missing_client_preface(Config) -> doc("Servers must treat an invalid connection preface as a " "connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a SETTINGS frame directly instead of the proper preface. ok = ssl:send(Socket, cow_http2:settings(#{})), @@ -635,8 +641,10 @@ alpn_reject_missing_client_preface(Config) -> alpn_reject_invalid_client_preface(Config) -> doc("Servers must treat an invalid connection preface as a " "connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a slightly incorrect preface. ok = ssl:send(Socket, "PRI * HTTP/2.0\r\n\r\nSM: Value\r\n\r\n"), @@ -650,8 +658,10 @@ alpn_reject_invalid_client_preface(Config) -> alpn_reject_missing_client_preface_settings(Config) -> doc("Servers must treat an invalid connection preface as a " "connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a valid preface sequence except followed by a PING instead of a SETTINGS frame. ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:ping(0)]), @@ -665,8 +675,10 @@ alpn_reject_missing_client_preface_settings(Config) -> alpn_reject_invalid_client_preface_settings(Config) -> doc("Servers must treat an invalid connection preface as a " "connection error of type PROTOCOL_ERROR. (RFC7540 3.3, RFC7540 3.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a valid preface sequence except followed by a badly formed SETTINGS frame. ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", << 0:24, 4:8, 0:9, 1:31 >>]), @@ -679,8 +691,10 @@ alpn_reject_invalid_client_preface_settings(Config) -> alpn_accept_client_preface_empty_settings(Config) -> doc("The SETTINGS frame in the client preface may be empty. (RFC7540 3.3, RFC7540 3.5)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a valid preface sequence except followed by an empty SETTINGS frame. ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(#{})]), @@ -694,8 +708,10 @@ alpn_accept_client_preface_empty_settings(Config) -> alpn_client_preface_settings_ack_timeout(Config) -> doc("Failure to acknowledge the server's SETTINGS frame " "results in a SETTINGS_TIMEOUT connection error. (RFC7540 3.5, RFC7540 6.5.3)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a valid preface. ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(#{})]), @@ -710,8 +726,10 @@ alpn_client_preface_settings_ack_timeout(Config) -> alpn(Config) -> doc("Successful ALPN negotiation. (RFC7540 3.3)"), + TlsOpts = ct_helper:get_certs_from_ets(), {ok, Socket} = ssl:connect("localhost", config(port, Config), - [{alpn_advertised_protocols, [<<"h2">>]}, binary, {active, false}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + binary, {active, false}|TlsOpts]), {ok, <<"h2">>} = ssl:negotiated_protocol(Socket), %% Send a valid preface. %% @todo Use non-empty SETTINGS here. Just because. @@ -735,7 +753,9 @@ alpn(Config) -> prior_knowledge_reject_tls(Config) -> doc("Implementations that support HTTP/2 over TLS must use ALPN. (RFC7540 3.4)"), - {ok, Socket} = ssl:connect("localhost", config(port, Config), [binary, {active, false}]), + TlsOpts = ct_helper:get_certs_from_ets(), + {ok, Socket} = ssl:connect("localhost", config(port, Config), + [binary, {active, false}|TlsOpts]), %% Send a valid preface. ok = ssl:send(Socket, ["PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", cow_http2:settings(#{})]), %% We expect the server to send an HTTP 400 error @@ -1354,7 +1374,8 @@ max_frame_size_allow_exactly_custom(Config0) -> {ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000), {ok, _} = gen_tcp:recv(Socket, Len2, 6000), %% No errors follow due to our sending of a 25000 bytes frame. - {error, timeout} = gen_tcp:recv(Socket, 0, 1000) + {error, timeout} = gen_tcp:recv(Socket, 0, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -1384,7 +1405,8 @@ max_frame_size_reject_larger_than_custom(Config0) -> cow_http2:data(1, fin, <<0:30001/unit:8>>) ]), %% Receive a FRAME_SIZE_ERROR connection error. - {ok, << _:24, 7:8, _:72, 6:32 >>} = gen_tcp:recv(Socket, 17, 6000) + {ok, << _:24, 7:8, _:72, 6:32 >>} = gen_tcp:recv(Socket, 17, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2599,9 +2621,10 @@ settings_header_table_size_server(Config0) -> {ok, << Len1:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000), {ok, RespHeadersBlock1} = gen_tcp:recv(Socket, Len1, 6000), {RespHeaders, _} = cow_hpack:decode(RespHeadersBlock1, DecodeState), - {_, <<"200">>} = lists:keyfind(<<":status">>, 1, RespHeaders) + {_, <<"200">>} = lists:keyfind(<<":status">>, 1, RespHeaders), %% The decoding succeeded on the server, confirming that %% the table size was updated to HeaderTableSize. + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2630,7 +2653,8 @@ settings_max_concurrent_streams(Config0) -> cow_http2:headers(3, fin, ReqHeadersBlock2) ]), %% Receive a REFUSED_STREAM stream error. - {ok, << _:24, 3:8, _:8, 3:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000) + {ok, << _:24, 3:8, _:8, 3:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2654,7 +2678,8 @@ settings_max_concurrent_streams_0(Config0) -> ]), ok = gen_tcp:send(Socket, cow_http2:headers(1, fin, HeadersBlock)), %% Receive a REFUSED_STREAM stream error. - {ok, << _:24, 3:8, _:8, 1:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000) + {ok, << _:24, 3:8, _:8, 1:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2722,7 +2747,8 @@ settings_initial_window_size(Config0) -> {ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000), {ok, _} = gen_tcp:recv(Socket, Len2, 6000), %% No errors follow due to our sending of more than 65535 bytes of data. - {error, timeout} = gen_tcp:recv(Socket, 0, 1000) + {error, timeout} = gen_tcp:recv(Socket, 0, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2765,7 +2791,8 @@ settings_initial_window_size_after_ack(Config0) -> cow_http2:data(1, fin, <<0:32/unit:8>>) ]), %% Receive a FLOW_CONTROL_ERROR stream error. - {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000) + {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2813,7 +2840,8 @@ settings_initial_window_size_before_ack(Config0) -> {ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000), {ok, _} = gen_tcp:recv(Socket, Len2, 6000), %% No errors follow due to our sending of more than 0 bytes of data. - {error, timeout} = gen_tcp:recv(Socket, 0, 1000) + {error, timeout} = gen_tcp:recv(Socket, 0, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -2846,7 +2874,8 @@ settings_max_frame_size(Config0) -> {ok, << Len2:24, 1:8, _:40 >>} = gen_tcp:recv(Socket, 9, 6000), {ok, _} = gen_tcp:recv(Socket, Len2, 6000), %% No errors follow due to our sending of a 25000 bytes frame. - {error, timeout} = gen_tcp:recv(Socket, 0, 1000) + {error, timeout} = gen_tcp:recv(Socket, 0, 1000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -3095,7 +3124,8 @@ data_reject_overflow(Config0) -> cow_http2:data(1, fin, <<0:15000/unit:8>>) ]), %% Receive a FLOW_CONTROL_ERROR connection error. - {ok, << _:24, 7:8, _:72, 3:32 >>} = gen_tcp:recv(Socket, 17, 6000) + {ok, << _:24, 7:8, _:72, 3:32 >>} = gen_tcp:recv(Socket, 17, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -3143,7 +3173,8 @@ data_reject_overflow_stream(Config0) -> cow_http2:data(1, fin, <<0:15000/unit:8>>) ]), %% Receive a FLOW_CONTROL_ERROR stream error. - {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000) + {ok, << _:24, 3:8, _:8, 1:32, 3:32 >>} = gen_tcp:recv(Socket, 13, 6000), + gen_tcp:close(Socket) after cowboy:stop_listener(?FUNCTION_NAME) end. @@ -3862,6 +3893,7 @@ accept_host_header_on_missing_pseudo_header_authority(Config) -> %% When both :authority and host headers are received, the current behavior %% is to favor :authority and ignore the host header. The specification does %% not describe the correct behavior to follow in that case. +%% @todo The HTTP/3 spec says both values must be identical and non-empty. reject_many_pseudo_header_authority(Config) -> doc("A request containing more than one authority component must be rejected " diff --git a/test/rfc8297_SUITE.erl b/test/rfc8297_SUITE.erl index 9ae6180..c6c1c9d 100644 --- a/test/rfc8297_SUITE.erl +++ b/test/rfc8297_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -30,7 +30,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). init_dispatch(_) -> cowboy_router:compile([{"[...]", [ diff --git a/test/rfc8441_SUITE.erl b/test/rfc8441_SUITE.erl index 245658f..3e71667 100644 --- a/test/rfc8441_SUITE.erl +++ b/test/rfc8441_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -126,6 +126,7 @@ reject_handshake_disabled_by_default(Config0) -> % The Extended CONNECT Method. +%% @todo Refer to RFC9110 7.8 about the case insensitive comparison. accept_uppercase_pseudo_header_protocol(Config) -> doc("The :protocol pseudo header is case insensitive. (draft-01 4)"), %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. @@ -172,6 +173,7 @@ reject_many_pseudo_header_protocol(Config) -> ok. reject_unknown_pseudo_header_protocol(Config) -> + %% @todo This probably shouldn't send 400 but 501 instead based on RFC 9220. doc("An extended CONNECT request with an unknown protocol must be rejected " "with a 400 error. (draft-01 4)"), %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. @@ -192,10 +194,11 @@ reject_unknown_pseudo_header_protocol(Config) -> {ok, << Len1:24, 1:8, _:8, 1:32 >>} = gen_tcp:recv(Socket, 9, 1000), {ok, RespHeadersBlock} = gen_tcp:recv(Socket, Len1, 1000), {RespHeaders, _} = cow_hpack:decode(RespHeadersBlock), - {_, <<"400">>} = lists:keyfind(<<":status">>, 1, RespHeaders), + {_, <<"501">>} = lists:keyfind(<<":status">>, 1, RespHeaders), ok. reject_invalid_pseudo_header_protocol(Config) -> + %% @todo This probably shouldn't send 400 but 501 instead based on RFC 9220. doc("An extended CONNECT request with an invalid protocol must be rejected " "with a 400 error. (draft-01 4)"), %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. @@ -216,7 +219,7 @@ reject_invalid_pseudo_header_protocol(Config) -> {ok, << Len1:24, 1:8, _:8, 1:32 >>} = gen_tcp:recv(Socket, 9, 1000), {ok, RespHeadersBlock} = gen_tcp:recv(Socket, Len1, 1000), {RespHeaders, _} = cow_hpack:decode(RespHeadersBlock), - {_, <<"400">>} = lists:keyfind(<<":status">>, 1, RespHeaders), + {_, <<"501">>} = lists:keyfind(<<":status">>, 1, RespHeaders), ok. reject_missing_pseudo_header_scheme(Config) -> @@ -293,7 +296,7 @@ reject_missing_pseudo_header_protocol(Config) -> %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. {ok, Socket, Settings} = do_handshake(Config), #{enable_connect_protocol := true} = Settings, - %% Send an extended CONNECT request without a :scheme pseudo-header. + %% Send an extended CONNECT request without a :protocol pseudo-header. {ReqHeadersBlock, _} = cow_hpack:encode([ {<<":method">>, <<"CONNECT">>}, {<<":scheme">>, <<"http">>}, @@ -317,7 +320,7 @@ reject_connection_header(Config) -> %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. {ok, Socket, Settings} = do_handshake(Config), #{enable_connect_protocol := true} = Settings, - %% Send an extended CONNECT request without a :scheme pseudo-header. + %% Send an extended CONNECT request with a connection header. {ReqHeadersBlock, _} = cow_hpack:encode([ {<<":method">>, <<"CONNECT">>}, {<<":protocol">>, <<"websocket">>}, @@ -339,7 +342,7 @@ reject_upgrade_header(Config) -> %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. {ok, Socket, Settings} = do_handshake(Config), #{enable_connect_protocol := true} = Settings, - %% Send an extended CONNECT request without a :scheme pseudo-header. + %% Send an extended CONNECT request with a upgrade header. {ReqHeadersBlock, _} = cow_hpack:encode([ {<<":method">>, <<"CONNECT">>}, {<<":protocol">>, <<"websocket">>}, diff --git a/test/rfc9114_SUITE.erl b/test/rfc9114_SUITE.erl new file mode 100644 index 0000000..4a36ee1 --- /dev/null +++ b/test/rfc9114_SUITE.erl @@ -0,0 +1,2426 @@ +%% Copyright (c) 2023-2024, Loïc Hoguin <[email protected]> +%% +%% Permission to use, copy, modify, and/or distribute this software for any +%% purpose with or without fee is hereby granted, provided that the above +%% copyright notice and this permission notice appear in all copies. +%% +%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +-module(rfc9114_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). + +-ifdef(COWBOY_QUICER). + +-include_lib("quicer/include/quicer.hrl"). + +all() -> + [{group, h3}]. + +groups() -> + %% @todo Enable parallel tests but for this issues in the + %% QUIC accept loop need to be figured out (can't connect + %% concurrently somehow, no backlog?). + [{h3, [], ct_helper:all(?MODULE)}]. + +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config))} + }, Config). + +end_per_group(Name, _) -> + cowboy_test:stop_group(Name). + +init_routes(_) -> [ + {"localhost", [ + {"/", hello_h, []}, + {"/echo/:key", echo_h, []} + ]} +]. + +%% Starting HTTP/3 for "https" URIs. + +alpn(Config) -> + doc("Successful ALPN negotiation. (RFC9114 3.1)"), + {ok, Conn} = quicer:connect("localhost", config(port, Config), + #{alpn => ["h3"], verify => none}, 5000), + {ok, <<"h3">>} = quicer:negotiated_protocol(Conn), + %% To make sure the connection is fully established we wait + %% to receive the SETTINGS frame on the control stream. + {ok, _ControlRef, _Settings} = do_wait_settings(Conn), + ok. + +alpn_error(Config) -> + doc("Failed ALPN negotiation using the 'h2' token. (RFC9114 3.1)"), + {error, transport_down, #{status := alpn_neg_failure}} + = quicer:connect("localhost", config(port, Config), + #{alpn => ["h2"], verify => none}, 5000), + ok. + +%% @todo 3.2. Connection Establishment +%% After the QUIC connection is established, a SETTINGS frame MUST be sent by each endpoint as the initial frame of their respective HTTP control stream. + +%% @todo 3.3. Connection Reuse +%% Servers are encouraged to maintain open HTTP/3 connections for as long as +%possible but are permitted to terminate idle connections if necessary. When +%either endpoint chooses to close the HTTP/3 connection, the terminating +%endpoint SHOULD first send a GOAWAY frame (Section 5.2) so that both endpoints +%can reliably determine whether previously sent frames have been processed and +%gracefully complete or terminate any necessary remaining tasks. + +%% Frame format. + +req_stream(Config) -> + doc("Complete lifecycle of a request stream. (RFC9114 4.1)"), + {ok, Conn} = quicer:connect("localhost", config(port, Config), + #{alpn => ["h3"], verify => none}, 5000), + %% To make sure the connection is fully established we wait + %% to receive the SETTINGS frame on the control stream. + {ok, ControlRef, _Settings} = do_wait_settings(Conn), + %% Send a request on a request stream. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ], ?QUIC_SEND_FLAG_FIN), + %% Receive the response. + {ok, Data} = do_receive_data(StreamRef), + {HLenEnc, HLenBits} = do_guess_int_encoding(Data), + << + 1, %% HEADERS frame. + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes, + Rest/bits + >> = Data, + {ok, DecodedResponse, _DecData, _DecSt} + = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + #{ + <<":status">> := <<"200">>, + <<"content-length">> := BodyLen + } = maps:from_list(DecodedResponse), + {DLenEnc, DLenBits} = do_guess_int_encoding(Rest), + << + 0, %% DATA frame. + DLenEnc:2, DLen:DLenBits, + Body:DLen/bytes + >> = Rest, + <<"Hello world!">> = Body, + BodyLen = integer_to_binary(byte_size(Body)), + ok = do_wait_peer_send_shutdown(StreamRef), + ok = do_wait_stream_closed(StreamRef). + +%% @todo Same test as above but with content-length unset? + +req_stream_two_requests(Config) -> + doc("Receipt of multiple requests on a single stream must " + "be rejected with an H3_MESSAGE_ERROR stream error. " + "(RFC9114 4.1, RFC9114 4.1.2)"), + {ok, Conn} = quicer:connect("localhost", config(port, Config), + #{alpn => ["h3"], verify => none}, 5000), + %% To make sure the connection is fully established we wait + %% to receive the SETTINGS frame on the control stream. + {ok, ControlRef, _Settings} = do_wait_settings(Conn), + %% Send two requests on a request stream. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest1, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedRequest2, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest1)), + EncodedRequest1, + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest2)), + EncodedRequest2 + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef), + ok. + +headers_then_trailers(Config) -> + doc("Receipt of HEADERS followed by trailer HEADERS must be accepted. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_data_then_trailers(Config) -> + doc("Receipt of HEADERS followed by DATA followed by trailer HEADERS " + "must be accepted. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +data_then_headers(Config) -> + doc("Receipt of DATA before HEADERS must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +headers_then_trailers_then_data(Config) -> + doc("Receipt of DATA after trailer HEADERS must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">> + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +headers_then_data_then_trailers_then_data(Config) -> + doc("Receipt of DATA after trailer HEADERS must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">> + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +headers_then_data_then_trailers_then_trailers(Config) -> + doc("Receipt of DATA after trailer HEADERS must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers1, _EncData2, EncSt1} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, EncodedTrailers2, _EncData3, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt1), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers1)), + EncodedTrailers1, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers2)), + EncodedTrailers2 + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +unknown_then_headers(Config) -> + doc("Receipt of unknown frame followed by HEADERS " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + unknown_then_headers(Config, do_unknown_frame_type(), + rand:bytes(rand:uniform(4096))). + +unknown_then_headers(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes, + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_unknown(Config) -> + doc("Receipt of HEADERS followed by unknown frame " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_unknown(Config, do_unknown_frame_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_unknown(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_data_then_unknown(Config) -> + doc("Receipt of HEADERS followed by DATA followed by unknown frame " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_data_then_unknown(Config, do_unknown_frame_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_data_then_unknown(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_trailers_then_unknown(Config) -> + doc("Receipt of HEADERS followed by trailer HEADERS followed by unknown frame " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_data_then_unknown(Config, do_unknown_frame_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_trailers_then_unknown(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_data_then_unknown_then_trailers(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "unknown frame followed by trailer HEADERS " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_data_then_unknown_then_trailers(Config, + do_unknown_frame_type(), rand:bytes(rand:uniform(4096))). + +headers_then_data_then_unknown_then_trailers(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_data_then_unknown_then_data(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "unknown frame followed by DATA " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_data_then_unknown_then_data(Config, + do_unknown_frame_type(), rand:bytes(rand:uniform(4096))). + +headers_then_data_then_unknown_then_data(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(6), + <<"Hello ">>, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes, + <<0>>, %% DATA frame. + cow_http3:encode_int(7), + <<"server!">> + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +headers_then_data_then_trailers_then_unknown(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "trailer HEADERS followed by unknown frame " + "must be accepted. (RFC9114 4.1, RFC9114 9)"), + headers_then_data_then_trailers_then_unknown(Config, + do_unknown_frame_type(), rand:bytes(rand:uniform(4096))). + +headers_then_data_then_trailers_then_unknown(Config, Type, Bytes) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello server!">>, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers, + cow_http3:encode_int(Type), %% Unknown frame. + cow_http3:encode_int(iolist_size(Bytes)), + Bytes + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +do_unknown_frame_type() -> + Type = rand:uniform(4611686018427387904) - 1, + %% Retry if we get a value that's specified. + case lists:member(Type, [ + 16#0, 16#1, 16#3, 16#4, 16#5, 16#7, 16#d, %% HTTP/3 core frame types. + 16#2, 16#6, 16#8, 16#9 %% HTTP/3 reserved frame types that must be rejected. + ]) of + true -> do_unknown_frame_type(); + false -> Type + end. + +reserved_then_headers(Config) -> + doc("Receipt of reserved frame followed by HEADERS " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + unknown_then_headers(Config, do_reserved_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_reserved(Config) -> + doc("Receipt of HEADERS followed by reserved frame " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_unknown(Config, do_reserved_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_data_then_reserved(Config) -> + doc("Receipt of HEADERS followed by DATA followed by reserved frame " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_data_then_unknown(Config, do_reserved_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_trailers_then_reserved(Config) -> + doc("Receipt of HEADERS followed by trailer HEADERS followed by reserved frame " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_trailers_then_unknown(Config, do_reserved_type(), + rand:bytes(rand:uniform(4096))). + +headers_then_data_then_reserved_then_trailers(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "reserved frame followed by trailer HEADERS " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_data_then_unknown_then_trailers(Config, + do_reserved_type(), rand:bytes(rand:uniform(4096))). + +headers_then_data_then_reserved_then_data(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "reserved frame followed by DATA " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_data_then_unknown_then_data(Config, + do_reserved_type(), rand:bytes(rand:uniform(4096))). + +headers_then_data_then_trailers_then_reserved(Config) -> + doc("Receipt of HEADERS followed by DATA followed by " + "trailer HEADERS followed by reserved frame " + "must be accepted when the reserved frame type is " + "of the format 0x1f * N + 0x21. (RFC9114 4.1, RFC9114 7.2.8)"), + headers_then_data_then_trailers_then_unknown(Config, + do_reserved_type(), rand:bytes(rand:uniform(4096))). + +reject_transfer_encoding_header_with_body(Config) -> + doc("Requests containing a transfer-encoding header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.1, RFC9114 4.1.2, RFC9114 4.2)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, _EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"transfer-encoding">>, <<"chunked">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(24), + <<"13\r\nHello server!\r\n0\r\n\r\n">> + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef), + ok. + +%% 4. Expressing HTTP Semantics in HTTP/3 +%% 4.1. HTTP Message Framing + +%% An HTTP request/response exchange fully consumes a client-initiated +%bidirectional QUIC stream. After sending a request, a client MUST close the +%stream for sending. Unless using the CONNECT method (see Section 4.4), clients +%MUST NOT make stream closure dependent on receiving a response to their +%request. After sending a final response, the server MUST close the stream for +%sending. At this point, the QUIC stream is fully closed. +%% @todo What to do with clients that DON'T close the stream +%% for sending after the request is sent? + +%% If a client-initiated stream terminates without enough of the HTTP message +%to provide a complete response, the server SHOULD abort its response stream +%with the error code H3_REQUEST_INCOMPLETE. +%% @todo difficult!! + +%% When the server does not need to receive the remainder of the request, it +%MAY abort reading the request stream, send a complete response, and cleanly +%close the sending part of the stream. The error code H3_NO_ERROR SHOULD be +%used when requesting that the client stop sending on the request stream. +%% @todo read_body related; h2 has this behavior but there is no corresponding test + +%% 4.1.1. Request Cancellation and Rejection + +%% When possible, it is RECOMMENDED that servers send an HTTP response with an +%appropriate status code rather than cancelling a request it has already begun +%processing. + +%% Implementations SHOULD cancel requests by abruptly terminating any +%directions of a stream that are still open. To do so, an implementation resets +%the sending parts of streams and aborts reading on the receiving parts of +%streams; see Section 2.4 of [QUIC-TRANSPORT]. + +%% When the server cancels a request without performing any application +%processing, the request is considered "rejected". The server SHOULD abort its +%response stream with the error code H3_REQUEST_REJECTED. In this context, +%"processed" means that some data from the stream was passed to some higher +%layer of software that might have taken some action as a result. The client +%can treat requests rejected by the server as though they had never been sent +%at all, thereby allowing them to be retried later. + +%% Servers MUST NOT use the H3_REQUEST_REJECTED error code for requests that +%were partially or fully processed. When a server abandons a response after +%partial processing, it SHOULD abort its response stream with the error code +%H3_REQUEST_CANCELLED. +%% @todo + +%% Client SHOULD use the error code H3_REQUEST_CANCELLED to cancel requests. +%Upon receipt of this error code, a server MAY abruptly terminate the response +%using the error code H3_REQUEST_REJECTED if no processing was performed. +%Clients MUST NOT use the H3_REQUEST_REJECTED error code, except when a server +%has requested closure of the request stream with this error code. +%% @todo + +%4.1.2. Malformed Requests and Responses +%A malformed request or response is one that is an otherwise valid sequence of +%frames but is invalid due to: +% +%the presence of prohibited fields or pseudo-header fields, +%% @todo reject_response_pseudo_headers +%% @todo reject_unknown_pseudo_headers +%% @todo reject_pseudo_headers_in_trailers + +%the absence of mandatory pseudo-header fields, +%invalid values for pseudo-header fields, +%pseudo-header fields after fields, +%% @todo reject_pseudo_headers_after_regular_headers + +%an invalid sequence of HTTP messages, +%the inclusion of invalid characters in field names or values. +% +%A request or response that is defined as having content when it contains a +%Content-Length header field (Section 8.6 of [HTTP]) is malformed if the value +%of the Content-Length header field does not equal the sum of the DATA frame +%lengths received. A response that is defined as never having content, even +%when a Content-Length is present, can have a non-zero Content-Length header +%field even though no content is included in DATA frames. +% +%For malformed requests, a server MAY send an HTTP response indicating the +%error prior to closing or resetting the stream. +%% @todo All the malformed tests + +headers_reject_uppercase_header_name(Config) -> + doc("Requests containing uppercase header names must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"I-AM-GIGANTIC">>, <<"How's the weather up there?">>} + ). + +%% 4.2. HTTP Fields +%% An endpoint MUST NOT generate an HTTP/3 field section containing +%connection-specific fields; any message containing connection-specific fields +%MUST be treated as malformed. + +reject_connection_header(Config) -> + doc("Requests containing a connection header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"connection">>, <<"close">>} + ). + +reject_keep_alive_header(Config) -> + doc("Requests containing a keep-alive header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"keep-alive">>, <<"timeout=5, max=1000">>} + ). + +reject_proxy_authenticate_header(Config) -> + doc("Requests containing a proxy-authenticate header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"proxy-authenticate">>, <<"Basic">>} + ). + +reject_proxy_authorization_header(Config) -> + doc("Requests containing a proxy-authorization header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"proxy-authorization">>, <<"Basic YWxhZGRpbjpvcGVuc2VzYW1l">>} + ). + +reject_transfer_encoding_header(Config) -> + doc("Requests containing a transfer-encoding header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"transfer-encoding">>, <<"chunked">>} + ). + +reject_upgrade_header(Config) -> + doc("Requests containing an upgrade header must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.5, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"upgrade">>, <<"websocket">>} + ). + +accept_te_header_value_trailers(Config) -> + doc("Requests containing a TE header with a value of \"trailers\" " + "must be accepted. (RFC9114 4.2)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>}, + {<<"te">>, <<"trailers">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"content-type">>, <<"text/plain">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +reject_te_header_other_values(Config) -> + doc("Requests containing a TE header with a value other than \"trailers\" must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.2, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<"te">>, <<"trailers, deflate;q=0.5">>} + ). + +%% @todo response_dont_send_header_in_connection +%% @todo response_dont_send_connection_header +%% @todo response_dont_send_keep_alive_header +%% @todo response_dont_send_proxy_connection_header +%% @todo response_dont_send_transfer_encoding_header +%% @todo response_dont_send_upgrade_header + +%% 4.2.1. Field Compression +%% To allow for better compression efficiency, the Cookie header field +%([COOKIES]) MAY be split into separate field lines, each with one or more +%cookie-pairs, before compression. If a decompressed field section contains +%multiple cookie field lines, these MUST be concatenated into a single byte +%string using the two-byte delimiter of "; " (ASCII 0x3b, 0x20) before being +%passed into a context other than HTTP/2 or HTTP/3, such as an HTTP/1.1 +%connection, or a generic HTTP server application. + +%% 4.2.2. Header Size Constraints +%% An HTTP/3 implementation MAY impose a limit on the maximum size of the +%message header it will accept on an individual HTTP message. A server that +%receives a larger header section than it is willing to handle can send an HTTP +%431 (Request Header Fields Too Large) status code ([RFC6585]). The size of a +%field list is calculated based on the uncompressed size of fields, including +%the length of the name and value in bytes plus an overhead of 32 bytes for +%each field. +%% If an implementation wishes to advise its peer of this limit, it can be +%conveyed as a number of bytes in the SETTINGS_MAX_FIELD_SECTION_SIZE +%parameter. + +reject_unknown_pseudo_headers(Config) -> + doc("Requests containing unknown pseudo-headers must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<":upgrade">>, <<"websocket">>} + ). + +reject_response_pseudo_headers(Config) -> + doc("Requests containing response pseudo-headers must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"), + do_reject_malformed_header(Config, + {<<":status">>, <<"200">>} + ). + +reject_pseudo_headers_in_trailers(Config) -> + doc("Requests containing pseudo-headers in trailers must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"trailer">>, <<"x-checksum">>} + ], 0, cow_qpack:init(encoder)), + {ok, EncodedTrailers, _EncData2, _EncSt} = cow_qpack:encode_field_section([ + {<<"x-checksum">>, <<"md5:4cc909a007407f3706399b6496babec3">>}, + {<<":path">>, <<"/">>} + ], 0, EncSt0), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(10000), + <<0:10000/unit:8>>, + <<1>>, %% HEADERS frame for trailers. + cow_http3:encode_int(iolist_size(EncodedTrailers)), + EncodedTrailers + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef), + ok. + +reject_pseudo_headers_after_regular_headers(Config) -> + doc("Requests containing pseudo-headers after regular headers must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<"content-length">>, <<"0">>}, + {<<":path">>, <<"/">>} + ]). + +reject_userinfo(Config) -> + doc("An authority containing a userinfo component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"user@localhost">>}, + {<<":path">>, <<"/">>} + ]). + +%% To ensure that the HTTP/1.1 request line can be reproduced accurately, this +%% pseudo-header field (:authority) MUST be omitted when translating from an +%% HTTP/1.1 request that has a request target in a method-specific form; +%% see Section 7.1 of [HTTP]. + +reject_empty_path(Config) -> + doc("A request containing an empty path component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<>>} + ]). + +reject_missing_pseudo_header_method(Config) -> + doc("A request without a method component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ]). + +reject_many_pseudo_header_method(Config) -> + doc("A request containing more than one method component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ]). + +reject_missing_pseudo_header_scheme(Config) -> + doc("A request without a scheme component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ]). + +reject_many_pseudo_header_scheme(Config) -> + doc("A request containing more than one scheme component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ]). + +reject_missing_pseudo_header_authority(Config) -> + doc("A request without an authority or host component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":path">>, <<"/">>} + ]). + +accept_host_header_on_missing_pseudo_header_authority(Config) -> + doc("A request without an authority but with a host header must be accepted. " + "(RFC9114 4.3.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, _EncSt0} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/">>}, + {<<"host">>, <<"localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +%% @todo +%% If the :scheme pseudo-header field identifies a scheme that has a mandatory +%% authority component (including "http" and "https"), the request MUST contain +%% either an :authority pseudo-header field or a Host header field. +%% - If both fields are present, they MUST NOT be empty. +%% - If both fields are present, they MUST contain the same value. + +reject_many_pseudo_header_authority(Config) -> + doc("A request containing more than one authority component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ]). + +reject_missing_pseudo_header_path(Config) -> + doc("A request without a path component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>} + ]). + +reject_many_pseudo_header_path(Config) -> + doc("A request containing more than one path component must be rejected " + "with an H3_MESSAGE_ERROR stream error. (RFC9114 4.3.1, RFC9114 4.1.2)"), + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<":path">>, <<"/">>} + ]). + +do_reject_malformed_header(Config, Header) -> + do_reject_malformed_headers(Config, [ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + Header + ]). + +do_reject_malformed_headers(Config, Headers) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData1, _EncSt0} + = cow_qpack:encode_field_section(Headers, 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = do_wait_stream_aborted(StreamRef), + ok. + +%% For responses, a single ":status" pseudo-header field is defined that +%% carries the HTTP status code; see Section 15 of [HTTP]. This pseudo-header +%% field MUST be included in all responses; otherwise, the response is malformed +%% (see Section 4.1.2). + +%% @todo Implement CONNECT. (RFC9114 4.4. The CONNECT Method) + +%% @todo Maybe block the sending of 101 responses? (RFC9114 4.5. HTTP Upgrade) - also HTTP/2. + +%% @todo Implement server push (RFC9114 4.6. Server Push) + +%% @todo - need a way to list connections +%% 5.2. Connection Shutdown +%% Endpoints initiate the graceful shutdown of an HTTP/3 connection by sending +%% a GOAWAY frame. The GOAWAY frame contains an identifier that indicates to the +%% receiver the range of requests or pushes that were or might be processed in +%% this connection. The server sends a client-initiated bidirectional stream ID; +%% the client sends a push ID. Requests or pushes with the indicated identifier +%% or greater are rejected (Section 4.1.1) by the sender of the GOAWAY. This +%% identifier MAY be zero if no requests or pushes were processed. + +%% @todo +%% Upon sending a GOAWAY frame, the endpoint SHOULD explicitly cancel (see +%% Sections 4.1.1 and 7.2.3) any requests or pushes that have identifiers greater +%% than or equal to the one indicated, in order to clean up transport state for +%% the affected streams. The endpoint SHOULD continue to do so as more requests +%% or pushes arrive. + +%% @todo +%% Endpoints MUST NOT initiate new requests or promise new pushes on the +%% connection after receipt of a GOAWAY frame from the peer. + +%% @todo +%% Requests on stream IDs less than the stream ID in a GOAWAY frame from the +%% server might have been processed; their status cannot be known until a +%% response is received, the stream is reset individually, another GOAWAY is +%% received with a lower stream ID than that of the request in question, or the +%% connection terminates. + +%% @todo +%% Servers MAY reject individual requests on streams below the indicated ID if +%% these requests were not processed. + +%% @todo +%% If a server receives a GOAWAY frame after having promised pushes with a push +%% ID greater than or equal to the identifier contained in the GOAWAY frame, +%% those pushes will not be accepted. + +%% @todo +%% Servers SHOULD send a GOAWAY frame when the closing of a connection is known +%% in advance, even if the advance notice is small, so that the remote peer can +%% know whether or not a request has been partially processed. + +%% @todo +%% An endpoint MAY send multiple GOAWAY frames indicating different +%% identifiers, but the identifier in each frame MUST NOT be greater than the +%% identifier in any previous frame, since clients might already have retried +%% unprocessed requests on another HTTP connection. Receiving a GOAWAY containing +%% a larger identifier than previously received MUST be treated as a connection +%% error of type H3_ID_ERROR. + +%% @todo +%% An endpoint that is attempting to gracefully shut down a connection can send +%% a GOAWAY frame with a value set to the maximum possible value (2^62-4 for +%% servers, 2^62-1 for clients). + +%% @todo +%% Even when a GOAWAY indicates that a given request or push will not be +%% processed or accepted upon receipt, the underlying transport resources still +%% exist. The endpoint that initiated these requests can cancel them to clean up +%% transport state. + +%% @todo +%% Once all accepted requests and pushes have been processed, the endpoint can +%% permit the connection to become idle, or it MAY initiate an immediate closure +%% of the connection. An endpoint that completes a graceful shutdown SHOULD use +%% the H3_NO_ERROR error code when closing the connection. + +%% @todo +%% If a client has consumed all available bidirectional stream IDs with +%% requests, the server need not send a GOAWAY frame, since the client is unable +%% to make further requests. @todo OK that one's some weird stuff lol + +%% @todo +%% 5.3. Immediate Application Closure +%% Before closing the connection, a GOAWAY frame MAY be sent to allow the +%% client to retry some requests. Including the GOAWAY frame in the same packet +%% as the QUIC CONNECTION_CLOSE frame improves the chances of the frame being +%% received by clients. + +bidi_allow_at_least_a_hundred(Config) -> + doc("Endpoints must allow the peer to create at least " + "one hundred bidirectional streams. (RFC9114 6.1"), + #{conn := Conn} = do_connect(Config), + receive + {quic, streams_available, Conn, #{bidi_streams := NumStreams}} -> + true = NumStreams >= 100, + ok + after 5000 -> + error(timeout) + end. + +unidi_allow_at_least_three(Config) -> + doc("Endpoints must allow the peer to create at least " + "three unidirectional streams. (RFC9114 6.2"), + #{conn := Conn} = do_connect(Config), + %% Confirm that the server advertised support for at least 3 unidi streams. + receive + {quic, streams_available, Conn, #{unidi_streams := NumStreams}} -> + true = NumStreams >= 3, + ok + after 5000 -> + error(timeout) + end, + %% Confirm that we can create the unidi streams. + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(ControlRef, [<<0>>, SettingsBin]), + {ok, EncoderRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(EncoderRef, <<2>>), + {ok, DecoderRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(DecoderRef, <<3>>), + %% Streams shouldn't get closed. + fun Loop() -> + receive + %% We don't care about these messages. + {quic, dgram_state_changed, Conn, _} -> + Loop(); + {quic, peer_needs_streams, Conn, _} -> + Loop(); + %% Any other we do care. + Msg -> + error(Msg) + after 1000 -> + ok + end + end(). + +unidi_create_critical_first(Config) -> + doc("Endpoints should create the HTTP control stream as well as " + "the QPACK encoder and decoder streams first. (RFC9114 6.2"), + %% The control stream is accepted in the do_connect/1 function. + #{conn := Conn} = do_connect(Config, #{peer_unidi_stream_count => 3}), + Unidi1 = do_accept_qpack_stream(Conn), + Unidi2 = do_accept_qpack_stream(Conn), + case {Unidi1, Unidi2} of + {{encoder, _}, {decoder, _}} -> + ok; + {{decoder, _}, {encoder, _}} -> + ok + end. + +do_accept_qpack_stream(Conn) -> + receive + {quic, new_stream, StreamRef, #{flags := Flags}} -> + ok = quicer:setopt(StreamRef, active, true), + true = quicer:is_unidirectional(Flags), + receive {quic, <<Type>>, StreamRef, _} -> + {case Type of + 2 -> encoder; + 3 -> decoder + end, StreamRef} + after 5000 -> + error(timeout) + end + after 5000 -> + error(timeout) + end. + +%% @todo We should also confirm that there's at least 1,024 bytes of +%% flow-control credit for each unidi stream the server creates. (How?) +%% It can be set via stream_recv_window_default in quicer. + +unidi_abort_unknown_type(Config) -> + doc("Receipt of an unknown stream type must be aborted " + "with an H3_STREAM_CREATION_ERROR stream error. (RFC9114 6.2, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + %% Create an unknown unidirectional stream. + {ok, StreamRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(1 + do_reserved_type()), + rand:bytes(rand:uniform(4096)) + ]), + %% The stream should have been aborted. + #{reason := h3_stream_creation_error} = do_wait_stream_aborted(StreamRef), + ok. + +unidi_abort_reserved_type(Config) -> + doc("Receipt of a reserved stream type must be aborted " + "with an H3_STREAM_CREATION_ERROR stream error. " + "(RFC9114 6.2, RFC9114 6.2.3, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + %% Create a reserved unidirectional stream. + {ok, StreamRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(do_reserved_type()), + rand:bytes(rand:uniform(4096)) + ]), + %% The stream should have been aborted. + #{reason := h3_stream_creation_error} = do_wait_stream_aborted(StreamRef), + ok. + +%% As certain stream types can affect connection state, a recipient SHOULD NOT +%% discard data from incoming unidirectional streams prior to reading the stream type. + +%% Implementations MAY send stream types before knowing whether the peer +%supports them. However, stream types that could modify the state or semantics +%of existing protocol components, including QPACK or other extensions, MUST NOT +%be sent until the peer is known to support them. +%% @todo It may make sense for Cowboy to delay the creation of unidi streams +%% a little in order to save resources. We could create them when the +%% client does as well, or something similar. + +%% A receiver MUST tolerate unidirectional streams being closed or reset prior +%% to the reception of the unidirectional stream header. + +%% Each side MUST initiate a single control stream at the beginning of the +%% connection and send its SETTINGS frame as the first frame on this stream. +%% @todo What to do when the client never opens a control stream? +%% @todo Similarly, a stream could be opened but with no data being sent. +%% @todo Similarly, a control stream could be opened with no SETTINGS frame sent. + +control_reject_first_frame_data(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<0>>, %% DATA frame. + cow_http3:encode_int(12), + <<"Hello world!">> + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_first_frame_headers(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_first_frame_cancel_push(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<3>>, %% CANCEL_PUSH frame. + cow_http3:encode_int(1), + cow_http3:encode_int(0) + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_accept_first_frame_settings(Config) -> + doc("The first frame on a control stream " + "must be a SETTINGS frame. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +control_reject_first_frame_push_promise(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<5>>, %% PUSH_PROMISE frame. + cow_http3:encode_int(iolist_size(EncodedHeaders) + 1), + cow_http3:encode_int(0), + EncodedHeaders + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_first_frame_goaway(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<7>>, %% GOAWAY frame. + cow_http3:encode_int(1), + cow_http3:encode_int(0) + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_first_frame_max_push_id(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<13>>, %% MAX_PUSH_ID frame. + cow_http3:encode_int(1), + cow_http3:encode_int(0) + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_first_frame_reserved(Config) -> + doc("The first frame on a control stream must be a SETTINGS frame " + "or the connection must be closed with an H3_MISSING_SETTINGS " + "connection error. (RFC9114 6.2.1, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + Len = rand:uniform(512), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + cow_http3:encode_int(do_reserved_type()), + cow_http3:encode_int(Len), + rand:bytes(Len) + ]), + %% The connection should have been closed. + #{reason := h3_missing_settings} = do_wait_connection_closed(Conn), + ok. + +control_reject_multiple(Config) -> + doc("Endpoints must not create multiple control streams. (RFC9114 6.2.1)"), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + do_critical_reject_multiple(Config, [<<0>>, SettingsBin]). + +do_critical_reject_multiple(Config, HeaderData) -> + #{conn := Conn} = do_connect(Config), + %% Create two critical streams. + {ok, StreamRef1} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef1, HeaderData), + {ok, StreamRef2} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef2, HeaderData), + %% The connection should have been closed. + #{reason := h3_stream_creation_error} = do_wait_connection_closed(Conn), + ok. + +control_local_closed_abort(Config) -> + doc("Endpoints must not close the control stream. (RFC9114 6.2.1)"), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + do_critical_local_closed_abort(Config, [<<0>>, SettingsBin]). + +do_critical_local_closed_abort(Config, HeaderData) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef, HeaderData), + %% Wait a little to make sure the stream data was received before we abort. + timer:sleep(100), + %% Close the critical stream. + quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0), + %% The connection should have been closed. + timer:sleep(1000), + #{reason := h3_closed_critical_stream} = do_wait_connection_closed(Conn), + ok. + +control_local_closed_graceful(Config) -> + doc("Endpoints must not close the control stream. (RFC9114 6.2.1)"), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + do_critical_local_closed_graceful(Config, [<<0>>, SettingsBin]). + +do_critical_local_closed_graceful(Config, HeaderData) -> + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, _} = quicer:send(StreamRef, HeaderData), + %% Close the critical stream. + quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_GRACEFUL, 0), + %% The connection should have been closed. + #{reason := h3_closed_critical_stream} = do_wait_connection_closed(Conn), + ok. + +control_remote_closed_abort(Config) -> + doc("Endpoints must not close the control stream. (RFC9114 6.2.1)"), + #{conn := Conn, control := ControlRef} = do_connect(Config), + %% Close the control stream. + quicer:async_shutdown_stream(ControlRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0), + %% The connection should have been closed. + #{reason := h3_closed_critical_stream} = do_wait_connection_closed(Conn), + ok. + +%% We cannot gracefully shutdown a remote unidi stream; only abort reading. + +%% Because the contents of the control stream are used to manage the behavior +%% of other streams, endpoints SHOULD provide enough flow-control credit to keep +%% the peer's control stream from becoming blocked. + +%% @todo Implement server push (RFC9114 6.2.2 Push Streams) + +data_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/echo/read_body">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello ">> + ]), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, [ + <<"server!">> + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello server!">> + } = do_receive_response(StreamRef), + ok. + +headers_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + Half = iolist_size(EncodedHeaders) div 2, + <<EncodedHeadersPart1:Half/binary, EncodedHeadersPart2/bits>> + = iolist_to_binary(EncodedHeaders), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeadersPart1 + ]), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, [ + EncodedHeadersPart2 + ]), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +%% @todo Implement server push. cancel_push_frame_can_span_multiple_packets(Config) -> + +settings_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + <<SettingsPart1:1/binary, SettingsPart2/bits>> = SettingsBin, + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsPart1 + ]), + timer:sleep(100), + {ok, _} = quicer:send(ControlRef, [ + SettingsPart2 + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +goaway_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<7>>, cow_http3:encode_int(1) %% GOAWAY part 1. + ]), + timer:sleep(100), + {ok, _} = quicer:send(ControlRef, [ + cow_http3:encode_int(0) %% GOAWAY part 2. + ]), + %% The connection should be closed gracefully. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + h3_no_error = cow_http3:code_to_error(Code), + ok; + %% @todo Temporarily also accept this message. I am + %% not sure why it happens but it isn't wrong per se. + {quic, shutdown, Conn, success} -> + ok + after 1000 -> + error(timeout) + end. + +max_push_id_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<13>>, cow_http3:encode_int(1) %% MAX_PUSH_ID part 1. + ]), + timer:sleep(100), + {ok, _} = quicer:send(ControlRef, [ + cow_http3:encode_int(0) %% MAX_PUSH_ID part 2. + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +unknown_frame_can_span_multiple_packets(Config) -> + doc("HTTP/3 frames can span multiple packets. (RFC9114 7)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(do_unknown_frame_type()), + cow_http3:encode_int(16383) + ]), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, rand:bytes(4096)), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, rand:bytes(4096)), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, rand:bytes(4096)), + timer:sleep(100), + {ok, _} = quicer:send(StreamRef, rand:bytes(4095)), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ], ?QUIC_SEND_FLAG_FIN), + #{ + headers := #{<<":status">> := <<"200">>}, + body := <<"Hello world!">> + } = do_receive_response(StreamRef), + ok. + +%% The DATA and SETTINGS frames can be zero-length therefore +%% they cannot be too short. + +headers_frame_too_short(Config) -> + doc("Frames that terminate before the end of identified fields " + "must be rejected with an H3_FRAME_ERROR connection error. " + "(RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(0) + ]), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +%% @todo Implement server push. cancel_push_frame_too_short(Config) -> + +goaway_frame_too_short(Config) -> + doc("Frames that terminate before the end of identified fields " + "must be rejected with an H3_FRAME_ERROR connection error. " + "(RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<7>>, cow_http3:encode_int(0) %% GOAWAY. + ]), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +max_push_id_frame_too_short(Config) -> + doc("Frames that terminate before the end of identified fields " + "must be rejected with an H3_FRAME_ERROR connection error. " + "(RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<13>>, cow_http3:encode_int(0) %% MAX_PUSH_ID. + ]), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +data_frame_truncated(Config) -> + doc("Truncated frames must be rejected with an " + "H3_FRAME_ERROR connection error. (RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/echo/read_body">>}, + {<<"content-length">>, <<"13">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders, + <<0>>, %% DATA frame. + cow_http3:encode_int(13), + <<"Hello ">> + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +headers_frame_truncated(Config) -> + doc("Truncated frames must be rejected with an " + "H3_FRAME_ERROR connection error. (RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)) + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +%% I am not sure how to test truncated CANCEL_PUSH, SETTINGS, GOAWAY +%% or MAX_PUSH_ID frames, as those are sent on the control stream, +%% which we cannot terminate. + +%% The DATA, HEADERS and SETTINGS frames can be of any length +%% therefore they cannot be too long per se, even if unwanted +%% data can be included at the end of the frame's payload. + +%% @todo Implement server push. cancel_push_frame_too_long(Config) -> + +goaway_frame_too_long(Config) -> + doc("Frames that contain additional bytes after the end of identified fields " + "must be rejected with an H3_FRAME_ERROR connection error. " + "(RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<7>>, cow_http3:encode_int(3), %% GOAWAY. + <<0, 1, 2>> + ]), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +max_push_id_frame_too_long(Config) -> + doc("Frames that contain additional bytes after the end of identified fields " + "must be rejected with an H3_FRAME_ERROR connection error. " + "(RFC9114 7.1, RFC9114 10.8)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<13>>, cow_http3:encode_int(9), %% MAX_PUSH_ID. + <<0, 1, 2, 3, 4, 5, 6, 7, 8>> + ]), + %% The connection should have been closed. + #{reason := h3_frame_error} = do_wait_connection_closed(Conn), + ok. + +%% Streams may terminate abruptly in the middle of frames. + +data_frame_rejected_on_control_stream(Config) -> + doc("DATA frames received on the control stream must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.1)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<0>>, %% DATA frame. + cow_http3:encode_int(12), + <<"Hello world!">> + ]), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +headers_frame_rejected_on_control_stream(Config) -> + doc("HEADERS frames received on the control stream must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.2)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ]), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +%% @todo Implement server push. (RFC9114 7.2.3. CANCEL_PUSH) + +settings_twice(Config) -> + doc("Receipt of a second SETTINGS frame on the control stream " + "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.4)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + SettingsBin + ]), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +settings_on_bidi_stream(Config) -> + doc("Receipt of a SETTINGS frame on a bidirectional stream " + "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.4)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + SettingsBin, + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +settings_identifier_twice(Config) -> + doc("Receipt of a duplicate SETTINGS identifier must be rejected " + "with an H3_SETTINGS_ERROR connection error. (RFC9114 7.2.4)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + SettingsPayload = [ + cow_http3:encode_int(6), cow_http3:encode_int(4096), + cow_http3:encode_int(6), cow_http3:encode_int(8192) + ], + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<4>>, %% SETTINGS frame. + cow_http3:encode_int(iolist_size(SettingsPayload)), + SettingsPayload + ]), + %% The connection should have been closed. + #{reason := h3_settings_error} = do_wait_connection_closed(Conn), + ok. + +settings_ignore_unknown_identifier(Config) -> + doc("Unknown SETTINGS identifiers must be ignored (RFC9114 7.2.4, RFC9114 9)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + SettingsPayload = [ + cow_http3:encode_int(999), cow_http3:encode_int(4096) + ], + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<4>>, %% SETTINGS frame. + cow_http3:encode_int(iolist_size(SettingsPayload)), + SettingsPayload + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +settings_ignore_reserved_identifier(Config) -> + doc("Reserved SETTINGS identifiers must be ignored (RFC9114 7.2.4.1)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + SettingsPayload = [ + cow_http3:encode_int(do_reserved_type()), cow_http3:encode_int(4096) + ], + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<4>>, %% SETTINGS frame. + cow_http3:encode_int(iolist_size(SettingsPayload)), + SettingsPayload + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +%% @todo Check that we send a reserved SETTINGS identifier when sending a +%% non-empty SETTINGS frame. (7.2.4.1. Defined SETTINGS Parameters) + +%% @todo Check that setting SETTINGS_MAX_FIELD_SECTION_SIZE works. + +%% It is unclear whether the SETTINGS identifier 0x00 must be rejected or ignored. + +settings_reject_http2_0x02(Config) -> + do_settings_reject_http2(Config, 2, 1). + +settings_reject_http2_0x03(Config) -> + do_settings_reject_http2(Config, 3, 100). + +settings_reject_http2_0x04(Config) -> + do_settings_reject_http2(Config, 4, 128000). + +settings_reject_http2_0x05(Config) -> + do_settings_reject_http2(Config, 5, 1000000). + +do_settings_reject_http2(Config, Identifier, Value) -> + doc("Receipt of an unused HTTP/2 SETTINGS identifier must be rejected " + "with an H3_SETTINGS_ERROR connection error. (RFC9114 7.2.4, RFC9114 11.2.2)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + SettingsPayload = [ + cow_http3:encode_int(Identifier), cow_http3:encode_int(Value) + ], + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + <<4>>, %% SETTINGS frame. + cow_http3:encode_int(iolist_size(SettingsPayload)), + SettingsPayload + ]), + %% The connection should have been closed. + #{reason := h3_settings_error} = do_wait_connection_closed(Conn), + ok. + +%% 7.2.4.2. Initialization +%% An HTTP implementation MUST NOT send frames or requests that would be +%% invalid based on its current understanding of the peer's settings. +%% @todo In the case of SETTINGS_MAX_FIELD_SECTION_SIZE I don't think we have a choice. + +%% All settings begin at an initial value. Each endpoint SHOULD use these +%% initial values to send messages before the peer's SETTINGS frame has arrived, +%% as packets carrying the settings can be lost or delayed. When the SETTINGS +%% frame arrives, any settings are changed to their new values. + +%% Endpoints MUST NOT require any data to be received from the peer prior to +%% sending the SETTINGS frame; settings MUST be sent as soon as the transport is +%% ready to send data. + +%% @todo Implement 0-RTT. (7.2.4.2. Initialization) + +%% @todo Implement server push. (7.2.5. PUSH_PROMISE) + +goaway_on_bidi_stream(Config) -> + doc("Receipt of a GOAWAY frame on a bidirectional stream " + "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.6)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(StreamRef, [ + <<7>>, cow_http3:encode_int(1), cow_http3:encode_int(0) %% GOAWAY. + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +%% @todo Implement server push. (7.2.6 GOAWAY - will have to reject too large push IDs) + +max_push_id_on_bidi_stream(Config) -> + doc("Receipt of a MAX_PUSH_ID frame on a bidirectional stream " + "must be rejected with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.7)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(StreamRef, [ + <<13>>, cow_http3:encode_int(1), cow_http3:encode_int(0) %% MAX_PUSH_ID. + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +%% @todo Implement server push. (7.2.7 MAX_PUSH_ID) + +max_push_id_reject_lower(Config) -> + doc("Receipt of a MAX_PUSH_ID value lower than previously received " + "must be rejected with an H3_ID_ERROR connection error. (RFC9114 7.2.7)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + <<13>>, cow_http3:encode_int(1), cow_http3:encode_int(20), %% MAX_PUSH_ID. + <<13>>, cow_http3:encode_int(1), cow_http3:encode_int(10) %% MAX_PUSH_ID. + ]), + %% The connection should have been closed. + #{reason := h3_id_error} = do_wait_connection_closed(Conn), + ok. + +reserved_on_control_stream(Config) -> + doc("Receipt of a reserved frame type on a control stream " + "must be ignored. (RFC9114 7.2.8)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + Len = rand:uniform(512), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + cow_http3:encode_int(do_reserved_type()), + cow_http3:encode_int(Len), + rand:bytes(Len) + ]), + %% The connection should remain up. + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + error(Reason) + after 1000 -> + ok + end. + +reserved_reject_http2_0x02_control(Config) -> + do_reserved_reject_http2_control(Config, 2). + +reserved_reject_http2_0x06_control(Config) -> + do_reserved_reject_http2_control(Config, 6). + +reserved_reject_http2_0x08_control(Config) -> + do_reserved_reject_http2_control(Config, 8). + +reserved_reject_http2_0x09_control(Config) -> + do_reserved_reject_http2_control(Config, 9). + +do_reserved_reject_http2_control(Config, Type) -> + doc("Receipt of an unused HTTP/2 frame type must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.8, RFC9114 11.2.1)"), + #{conn := Conn} = do_connect(Config), + {ok, ControlRef} = quicer:start_stream(Conn, + #{open_flag => ?QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL}), + {ok, SettingsBin, _HTTP3Machine0} = cow_http3_machine:init(client, #{}), + Len = rand:uniform(512), + {ok, _} = quicer:send(ControlRef, [ + <<0>>, %% CONTROL stream. + SettingsBin, + cow_http3:encode_int(Type), + cow_http3:encode_int(Len), + rand:bytes(Len) + ]), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +reserved_reject_http2_0x02_bidi(Config) -> + do_reserved_reject_http2_bidi(Config, 2). + +reserved_reject_http2_0x06_bidi(Config) -> + do_reserved_reject_http2_bidi(Config, 6). + +reserved_reject_http2_0x08_bidi(Config) -> + do_reserved_reject_http2_bidi(Config, 8). + +reserved_reject_http2_0x09_bidi(Config) -> + do_reserved_reject_http2_bidi(Config, 9). + +do_reserved_reject_http2_bidi(Config, Type) -> + doc("Receipt of an unused HTTP/2 frame type must be rejected " + "with an H3_FRAME_UNEXPECTED connection error. (RFC9114 7.2.8, RFC9114 11.2.1)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedHeaders, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, + {<<":path">>, <<"/">>}, + {<<"content-length">>, <<"0">>} + ], 0, cow_qpack:init(encoder)), + Len = rand:uniform(512), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(Type), + cow_http3:encode_int(Len), + rand:bytes(Len), + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedHeaders)), + EncodedHeaders + ], ?QUIC_SEND_FLAG_FIN), + %% The connection should have been closed. + #{reason := h3_frame_unexpected} = do_wait_connection_closed(Conn), + ok. + +%% An endpoint MAY choose to treat a stream error as a connection error under +%% certain circumstances, closing the entire connection in response to a +%% condition on a single stream. + +%% Because new error codes can be defined without negotiation (see Section 9), +%% use of an error code in an unexpected context or receipt of an unknown error +%% code MUST be treated as equivalent to H3_NO_ERROR. + +%% 8.1. HTTP/3 Error Codes +%% H3_INTERNAL_ERROR (0x0102): An internal error has occurred in the HTTP stack. +%% H3_EXCESSIVE_LOAD (0x0107): The endpoint detected that its peer is +%% exhibiting a behavior that might be generating excessive load. +%% H3_MISSING_SETTINGS (0x010a): No SETTINGS frame was received +%% at the beginning of the control stream. +%% H3_REQUEST_REJECTED (0x010b): A server rejected a request without +%% performing any application processing. +%% H3_REQUEST_CANCELLED (0x010c): The request or its response +%% (including pushed response) is cancelled. +%% H3_REQUEST_INCOMPLETE (0x010d): The client's stream terminated +%% without containing a fully formed request. +%% H3_CONNECT_ERROR (0x010f): The TCP connection established in +%% response to a CONNECT request was reset or abnormally closed. +%% H3_VERSION_FALLBACK (0x0110): The requested operation cannot +%% be served over HTTP/3. The peer should retry over HTTP/1.1. + +%% 9. Extensions to HTTP/3 +%% If a setting is used for extension negotiation, the default value MUST be +%% defined in such a fashion that the extension is disabled if the setting is +%% omitted. + +%% 10. Security Considerations +%% 10.3. Intermediary-Encapsulation Attacks +%% Requests or responses containing invalid field names MUST be treated as malformed. +%% Any request or response that contains a character not permitted in a field +%% value MUST be treated as malformed. + +%% 10.5. Denial-of-Service Considerations +%% Implementations SHOULD track the use of these features and set limits on +%% their use. An endpoint MAY treat activity that is suspicious as a connection +%% error of type H3_EXCESSIVE_LOAD, but false positives will result in disrupting +%% valid connections and requests. + +reject_large_unknown_frame(Config) -> + doc("Large unknown frames may risk denial-of-service " + "and should be rejected. (RFC9114 10.5)"), + #{conn := Conn} = do_connect(Config), + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, _} = quicer:send(StreamRef, [ + cow_http3:encode_int(do_unknown_frame_type()), + cow_http3:encode_int(16385) + ]), + #{reason := h3_excessive_load} = do_wait_connection_closed(Conn), + ok. + +%% 10.5.1. Limits on Field Section Size +%% An endpoint can use the SETTINGS_MAX_FIELD_SECTION_SIZE (Section 4.2.2) +%% setting to advise peers of limits that might apply on the size of field +%% sections. +%% +%% A server that receives a larger field section than it is willing to handle +%% can send an HTTP 431 (Request Header Fields Too Large) status code +%% ([RFC6585]). + +%% 10.6. Use of Compression +%% Implementations communicating on a secure channel MUST NOT compress content +%% that includes both confidential and attacker-controlled data unless separate +%% compression contexts are used for each source of data. Compression MUST NOT be +%% used if the source of data cannot be reliably determined. + +%% 10.9. Early Data +%% The anti-replay mitigations in [HTTP-REPLAY] MUST be applied when using HTTP/3 with 0-RTT. + +%% 10.10. Migration +%% Certain HTTP implementations use the client address for logging or +%% access-control purposes. Since a QUIC client's address might change during a +%% connection (and future versions might support simultaneous use of multiple +%% addresses), such implementations will need to either actively retrieve the +%% client's current address or addresses when they are relevant or explicitly +%% accept that the original address might change. @todo Document this behavior. + +%% Appendix A. Considerations for Transitioning from HTTP/2 +%% A.1. Streams +%% QUIC considers a stream closed when all data has been received and sent data +%% has been acknowledged by the peer. HTTP/2 considers a stream closed when the +%% frame containing the END_STREAM bit has been committed to the transport. As a +%% result, the stream for an equivalent exchange could remain "active" for a +%% longer period of time. HTTP/3 servers might choose to permit a larger number +%% of concurrent client-initiated bidirectional streams to achieve equivalent +%% concurrency to HTTP/2, depending on the expected usage patterns. @todo Document this. + +%% Helper functions. + +%% @todo Maybe have a function in cow_http3. +do_reserved_type() -> + 16#1f * (rand:uniform(148764065110560900) - 1) + 16#21. + +do_connect(Config) -> + do_connect(Config, #{}). + +do_connect(Config, Opts) -> + {ok, Conn} = quicer:connect("localhost", config(port, Config), + Opts#{alpn => ["h3"], verify => none}, 5000), + %% To make sure the connection is fully established we wait + %% to receive the SETTINGS frame on the control stream. + {ok, ControlRef, Settings} = do_wait_settings(Conn), + #{ + conn => Conn, + control => ControlRef, %% This is the peer control stream. + settings => Settings + }. + +do_wait_settings(Conn) -> + receive + {quic, new_stream, StreamRef, #{flags := Flags}} -> + ok = quicer:setopt(StreamRef, active, true), + true = quicer:is_unidirectional(Flags), + receive {quic, << + 0, %% Control stream. + SettingsFrame/bits + >>, StreamRef, _} -> + {ok, {settings, Settings}, <<>>} = cow_http3:parse(SettingsFrame), + {ok, StreamRef, Settings} + after 5000 -> + {error, timeout} + end + after 5000 -> + {error, timeout} + end. + +do_receive_data(StreamRef) -> + receive + {quic, Data, StreamRef, _Flags} when is_binary(Data) -> + {ok, Data} + after 5000 -> + {error, timeout} + end. + +do_guess_int_encoding(Data) -> + SizeWithLen = byte_size(Data) - 1, + if + SizeWithLen < 64 + 1 -> + {0, 6}; + SizeWithLen < 16384 + 2 -> + {1, 14}; + SizeWithLen < 1073741824 + 4 -> + {2, 30}; + SizeWithLen < 4611686018427387904 + 8 -> + {3, 62} + end. + +do_wait_peer_send_shutdown(StreamRef) -> + receive + {quic, peer_send_shutdown, StreamRef, undefined} -> + ok + after 5000 -> + {error, timeout} + end. + +do_wait_stream_aborted(StreamRef) -> + receive + {quic, peer_send_aborted, StreamRef, Code} -> + Reason = cow_http3:code_to_error(Code), + #{reason => Reason}; + {quic, peer_receive_aborted, StreamRef, Code} -> + Reason = cow_http3:code_to_error(Code), + #{reason => Reason} + after 5000 -> + {error, timeout} + end. + +do_wait_stream_closed(StreamRef) -> + receive + {quic, stream_closed, StreamRef, #{error := Error, is_conn_shutdown := false}} -> + 0 = Error, + ok + after 5000 -> + {error, timeout} + end. + +do_receive_response(StreamRef) -> + {ok, Data} = do_receive_data(StreamRef), + {HLenEnc, HLenBits} = do_guess_int_encoding(Data), + << + 1, %% HEADERS frame. + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes, + Rest/bits + >> = Data, + {ok, DecodedResponse, _DecData, _DecSt} + = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + Headers = maps:from_list(DecodedResponse), + #{<<"content-length">> := BodyLen} = Headers, + {DLenEnc, DLenBits} = do_guess_int_encoding(Rest), + Body = case Rest of + <<>> -> + <<>>; + << + 0, %% DATA frame. + DLenEnc:2, DLen:DLenBits, + Body0:DLen/bytes + >> -> + BodyLen = integer_to_binary(byte_size(Body0)), + Body0 + end, + ok = do_wait_peer_send_shutdown(StreamRef), + #{ + headers => Headers, + body => Body + }. + +do_wait_connection_closed(Conn) -> + receive + {quic, shutdown, Conn, {unknown_quic_status, Code}} -> + Reason = cow_http3:code_to_error(Code), + #{reason => Reason} + after 5000 -> + {error, timeout} + end. + +-endif. diff --git a/test/rfc9114_SUITE_data/client.key b/test/rfc9114_SUITE_data/client.key new file mode 100644 index 0000000..9c5e1ce --- /dev/null +++ b/test/rfc9114_SUITE_data/client.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVJakPYfQA1Hr6Gnq +GYmpMfXpxUi2QwDBrZfw8dBcVqKhRANCAAQDHeeAvjwD7p+Mg1F+G9FBNy+7Wcms +HEw4sGMzhUL4wjwsqKHpoiuQg3qUXXK0gamx0l77vFjrUc6X1al4+ZM5 +-----END PRIVATE KEY----- diff --git a/test/rfc9114_SUITE_data/client.pem b/test/rfc9114_SUITE_data/client.pem new file mode 100644 index 0000000..cd9dc8c --- /dev/null +++ b/test/rfc9114_SUITE_data/client.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIUeAPi9oyMIE/KRpsRdukfx2eMuuswCgYIKoZIzj0EAwIw +IDELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9EWUFCMB4XDTIzMDcwNTEwMjIy +MloXDTI0MTExNjEwMjIyMlowMTELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9E +WUFCMQ8wDQYDVQQDDAZjbGllbnQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQD +HeeAvjwD7p+Mg1F+G9FBNy+7WcmsHEw4sGMzhUL4wjwsqKHpoiuQg3qUXXK0gamx +0l77vFjrUc6X1al4+ZM5o2IwYDALBgNVHQ8EBAMCA4gwEQYDVR0RBAowCIIGY2xp +ZW50MB0GA1UdDgQWBBTnhPpO+rSIFAxvkwVjlkKOO2jOeDAfBgNVHSMEGDAWgBSD +Hw8A4XXG3jB1Atrqux7AUsf+KjAKBggqhkjOPQQDAgNIADBFAiEA2qf29EBp2hcL +sEO7MM0ZLm4gnaMdcxtyneF3+c7Lg3cCIBFTVP8xHlhCJyb8ESV7S052VU0bKQFN +ioyoYtcycxuZ +-----END CERTIFICATE----- diff --git a/test/rfc9114_SUITE_data/server.key b/test/rfc9114_SUITE_data/server.key new file mode 100644 index 0000000..45ea890 --- /dev/null +++ b/test/rfc9114_SUITE_data/server.key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgvykUYMOS2gW8XTTh +HgmeJM36NT8GGTNXzzt4sIs0o9ahRANCAATnQOMkKbLFQCZY/cxf8otEJG2tVuG6 +QvLqUdERV2+gzE+4ROGDqbb2Jk1szyz4CfBMB4ZfLA/PdSiO+KrOeOcj +-----END PRIVATE KEY----- diff --git a/test/rfc9114_SUITE_data/server.pem b/test/rfc9114_SUITE_data/server.pem new file mode 100644 index 0000000..43cce8e --- /dev/null +++ b/test/rfc9114_SUITE_data/server.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVugAwIBAgIUeAPi9oyMIE/KRpsRdukfx2eMuuowCgYIKoZIzj0EAwIw +IDELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9EWUFCMB4XDTIzMDcwNTEwMjIy +MloXDTI0MTExNjEwMjIyMlowMTELMAkGA1UEBhMCU0UxETAPBgNVBAoMCE5PQk9E +WUFCMQ8wDQYDVQQDDAZzZXJ2ZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATn +QOMkKbLFQCZY/cxf8otEJG2tVuG6QvLqUdERV2+gzE+4ROGDqbb2Jk1szyz4CfBM +B4ZfLA/PdSiO+KrOeOcjo2IwYDALBgNVHQ8EBAMCA4gwEQYDVR0RBAowCIIGc2Vy +dmVyMB0GA1UdDgQWBBS+Np5J8BtmWU534pm9hqhrG/EQ7zAfBgNVHSMEGDAWgBSD +Hw8A4XXG3jB1Atrqux7AUsf+KjAKBggqhkjOPQQDAgNIADBFAiEApRfjIEJfO1VH +ETgNG3/MzDayYScPocVn4v8U15ygEw8CIFUY3xMZzJ5AmiRe9PhIUgueOKQNMtds +wdF9+097+Ey0 +-----END CERTIFICATE----- diff --git a/test/rfc9204_SUITE.erl b/test/rfc9204_SUITE.erl new file mode 100644 index 0000000..e8defd2 --- /dev/null +++ b/test/rfc9204_SUITE.erl @@ -0,0 +1,357 @@ +%% Copyright (c) 2024, Loïc Hoguin <[email protected]> +%% +%% Permission to use, copy, modify, and/or distribute this software for any +%% purpose with or without fee is hereby granted, provided that the above +%% copyright notice and this permission notice appear in all copies. +%% +%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +-module(rfc9204_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). + +-ifdef(COWBOY_QUICER). + +-include_lib("quicer/include/quicer.hrl"). + +all() -> + [{group, h3}]. + +groups() -> + %% @todo Enable parallel tests but for this issues in the + %% QUIC accept loop need to be figured out (can't connect + %% concurrently somehow, no backlog?). + [{h3, [], ct_helper:all(?MODULE)}]. + +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config))} + }, Config). + +end_per_group(Name, _) -> + cowboy_test:stop_group(Name). + +init_routes(_) -> [ + {"localhost", [ + {"/", hello_h, []} + ]} +]. + +%% Encoder. + +%% 2.1 +%% QPACK preserves the ordering of field lines within +%% each field section. An encoder MUST emit field +%% representations in the order they appear in the +%% input field section. + +%% 2.1.1 +%% If the dynamic table does not contain enough room +%% for a new entry without evicting other entries, +%% and the entries that would be evicted are not evictable, +%% the encoder MUST NOT insert that entry into the dynamic +%% table (including duplicates of existing entries). +%% In order to avoid this, an encoder that uses the +%% dynamic table has to keep track of each dynamic +%% table entry referenced by each field section until +%% those representations are acknowledged by the decoder; +%% see Section 4.4.1. + +%% 2.1.2 +%% The decoder specifies an upper bound on the number +%% of streams that can be blocked using the +%% SETTINGS_QPACK_BLOCKED_STREAMS setting; see Section 5. +%% An encoder MUST limit the number of streams that could +%% become blocked to the value of SETTINGS_QPACK_BLOCKED_STREAMS +%% at all times. If a decoder encounters more blocked streams +%% than it promised to support, it MUST treat this as a +%% connection error of type QPACK_DECOMPRESSION_FAILED. + +%% 2.1.3 +%% To avoid these deadlocks, an encoder SHOULD NOT +%% write an instruction unless sufficient stream and +%% connection flow-control credit is available for +%% the entire instruction. + +%% Decoder. + +%% 2.2 +%% The decoder MUST emit field lines in the order their +%% representations appear in the encoded field section. + +%% 2.2.1 +%% While blocked, encoded field section data SHOULD +%% remain in the blocked stream's flow-control window. + +%% If it encounters a Required Insert Count smaller than +%% expected, it MUST treat this as a connection error of +%% type QPACK_DECOMPRESSION_FAILED; see Section 2.2.3. + +%% If it encounters a Required Insert Count larger than +%% expected, it MAY treat this as a connection error of +%% type QPACK_DECOMPRESSION_FAILED. + +%% After the decoder finishes decoding a field section +%% encoded using representations containing dynamic table +%% references, it MUST emit a Section Acknowledgment +%% instruction (Section 4.4.1). + +%% 2.2.2.2 +%% A decoder with a maximum dynamic table capacity +%% (Section 3.2.3) equal to zero MAY omit sending Stream +%% Cancellations, because the encoder cannot have any +%% dynamic table references. + +%% 2.2.3 +%% If the decoder encounters a reference in a field line +%% representation to a dynamic table entry that has already +%% been evicted or that has an absolute index greater than +%% or equal to the declared Required Insert Count (Section 4.5.1), +%% it MUST treat this as a connection error of type +%% QPACK_DECOMPRESSION_FAILED. + +%% If the decoder encounters a reference in an encoder +%% instruction to a dynamic table entry that has already +%% been evicted, it MUST treat this as a connection error +%% of type QPACK_ENCODER_STREAM_ERROR. + +%% Static table. + +%% 3.1 +%% When the decoder encounters an invalid static table index +%% in a field line representation, it MUST treat this as a +%% connection error of type QPACK_DECOMPRESSION_FAILED. +%% +%% If this index is received on the encoder stream, this +%% MUST be treated as a connection error of type +%% QPACK_ENCODER_STREAM_ERROR. + +%% Dynamic table. + +%% 3.2 +%% The dynamic table can contain duplicate entries +%% (i.e., entries with the same name and same value). +%% Therefore, duplicate entries MUST NOT be treated +%% as an error by the decoder. + +%% 3.2.2 +%% The encoder MUST NOT cause a dynamic table entry to be +%% evicted unless that entry is evictable; see Section 2.1.1. + +%% It is an error if the encoder attempts to add an entry +%% that is larger than the dynamic table capacity; the +%% decoder MUST treat this as a connection error of type +%% QPACK_ENCODER_STREAM_ERROR. + +%% 3.2.3 +%% The encoder MUST NOT set a dynamic table capacity that +%% exceeds this maximum, but it can choose to use a lower +%% dynamic table capacity; see Section 4.3.1. + +%% When the client's 0-RTT value of the SETTING is zero, +%% the server MAY set it to a non-zero value in its SETTINGS +%% frame. If the remembered value is non-zero, the server +%% MUST send the same non-zero value in its SETTINGS frame. +%% If it specifies any other value, or omits +%% SETTINGS_QPACK_MAX_TABLE_CAPACITY from SETTINGS, +%% the encoder must treat this as a connection error of +%% type QPACK_DECODER_STREAM_ERROR. + +%% When the maximum table capacity is zero, the encoder +%% MUST NOT insert entries into the dynamic table and +%% MUST NOT send any encoder instructions on the encoder stream. + +%% Wire format. + +%% 4.1.1 +%% QPACK implementations MUST be able to decode integers +%% up to and including 62 bits long. + +%% Encoder and decoder streams. + +decoder_reject_multiple(Config) -> + doc("Endpoints must not create multiple decoder streams. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_reject_multiple(Config, <<3>>). + +encoder_reject_multiple(Config) -> + doc("Endpoints must not create multiple encoder streams. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_reject_multiple(Config, <<2>>). + +%% 4.2 +%% The sender MUST NOT close either of these streams, +%% and the receiver MUST NOT request that the sender close +%% either of these streams. Closure of either unidirectional +%% stream type MUST be treated as a connection error of type +%% H3_CLOSED_CRITICAL_STREAM. + +decoder_local_closed_abort(Config) -> + doc("Endpoints must not close the decoder stream. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_local_closed_abort(Config, <<3>>). + +decoder_local_closed_graceful(Config) -> + doc("Endpoints must not close the decoder stream. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_local_closed_graceful(Config, <<3>>). + +decoder_remote_closed_abort(Config) -> + doc("Endpoints must not close the decoder stream. (RFC9204 4.2)"), + #{conn := Conn} = rfc9114_SUITE:do_connect(Config, #{peer_unidi_stream_count => 3}), + {ok, #{decoder := StreamRef}} = do_wait_unidi_streams(Conn, #{}), + %% Close the control stream. + quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0), + %% The connection should have been closed. + #{reason := h3_closed_critical_stream} = rfc9114_SUITE:do_wait_connection_closed(Conn), + ok. + +encoder_local_closed_abort(Config) -> + doc("Endpoints must not close the encoder stream. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_local_closed_abort(Config, <<2>>). + +encoder_local_closed_graceful(Config) -> + doc("Endpoints must not close the encoder stream. (RFC9204 4.2)"), + rfc9114_SUITE:do_critical_local_closed_graceful(Config, <<2>>). + +encoder_remote_closed_abort(Config) -> + doc("Endpoints must not close the encoder stream. (RFC9204 4.2)"), + #{conn := Conn} = rfc9114_SUITE:do_connect(Config, #{peer_unidi_stream_count => 3}), + {ok, #{encoder := StreamRef}} = do_wait_unidi_streams(Conn, #{}), + %% Close the control stream. + quicer:async_shutdown_stream(StreamRef, ?QUIC_STREAM_SHUTDOWN_FLAG_ABORT, 0), + %% The connection should have been closed. + #{reason := h3_closed_critical_stream} = rfc9114_SUITE:do_wait_connection_closed(Conn), + ok. + +do_wait_unidi_streams(_, Acc=#{decoder := _, encoder := _}) -> + {ok, Acc}; +do_wait_unidi_streams(Conn, Acc) -> + receive + {quic, new_stream, StreamRef, #{flags := Flags}} -> + ok = quicer:setopt(StreamRef, active, true), + true = quicer:is_unidirectional(Flags), + receive {quic, <<TypeValue>>, StreamRef, _} -> + Type = case TypeValue of + 2 -> encoder; + 3 -> decoder + end, + do_wait_unidi_streams(Conn, Acc#{Type => StreamRef}) + after 5000 -> + {error, timeout} + end + after 5000 -> + {error, timeout} + end. + +%% An endpoint MAY avoid creating an encoder stream if it will +%% not be used (for example, if its encoder does not wish to +%% use the dynamic table or if the maximum size of the dynamic +%% table permitted by the peer is zero). + +%% An endpoint MAY avoid creating a decoder stream if its +%% decoder sets the maximum capacity of the dynamic table to zero. + +%% An endpoint MUST allow its peer to create an encoder stream +%% and a decoder stream even if the connection's settings +%% prevent their use. + +%% Encoder instructions. + +%% 4.3.1 +%% The new capacity MUST be lower than or equal to the limit +%% described in Section 3.2.3. In HTTP/3, this limit is the +%% value of the SETTINGS_QPACK_MAX_TABLE_CAPACITY parameter +%% (Section 5) received from the decoder. The decoder MUST +%% treat a new dynamic table capacity value that exceeds this +%% limit as a connection error of type QPACK_ENCODER_STREAM_ERROR. + +%% Reducing the dynamic table capacity can cause entries to be +%% evicted; see Section 3.2.2. This MUST NOT cause the eviction +%% of entries that are not evictable; see Section 2.1.1. + +%% Decoder instructions. + +%% 4.4.1 +%% If an encoder receives a Section Acknowledgment instruction +%% referring to a stream on which every encoded field section +%% with a non-zero Required Insert Count has already been +%% acknowledged, this MUST be treated as a connection error +%% of type QPACK_DECODER_STREAM_ERROR. + +%% 4.4.3 +%% An encoder that receives an Increment field equal to zero, +%% or one that increases the Known Received Count beyond what +%% the encoder has sent, MUST treat this as a connection error +%% of type QPACK_DECODER_STREAM_ERROR. + +%% Field line representation. + +%% 4.5.1.1 +%% If the decoder encounters a value of EncodedInsertCount that +%% could not have been produced by a conformant encoder, it MUST +%% treat this as a connection error of type QPACK_DECOMPRESSION_FAILED. + +%% 4.5.1.2 +%% The value of Base MUST NOT be negative. Though the protocol +%% might operate correctly with a negative Base using post-Base +%% indexing, it is unnecessary and inefficient. An endpoint MUST +%% treat a field block with a Sign bit of 1 as invalid if the +%% value of Required Insert Count is less than or equal to the +%% value of Delta Base. + +%% 4.5.4 +%% When the 'N' bit is set, the encoded field line MUST always +%% be encoded with a literal representation. In particular, +%% when a peer sends a field line that it received represented +%% as a literal field line with the 'N' bit set, it MUST use a +%% literal representation to forward this field line. This bit +%% is intended for protecting field values that are not to be +%% put at risk by compressing them; see Section 7.1 for more details. + +%% Configuration. + +%% 5 +%% SETTINGS_QPACK_MAX_TABLE_CAPACITY +%% SETTINGS_QPACK_BLOCKED_STREAMS + +%% Security considerations. + +%% 7.1.2 +%% (security if used as a proxy merging many connections into one) +%% An ideal solution segregates access to the dynamic table +%% based on the entity that is constructing the message. +%% Field values that are added to the table are attributed +%% to an entity, and only the entity that created a particular +%% value can extract that value. + +%% 7.1.3 +%% An intermediary MUST NOT re-encode a value that uses a +%% literal representation with the 'N' bit set with another +%% representation that would index it. If QPACK is used for +%% re-encoding, a literal representation with the 'N' bit set +%% MUST be used. If HPACK is used for re-encoding, the +%% never-indexed literal representation (see Section 6.2.3 +%% of [RFC7541]) MUST be used. + +%% 7.4 +%% An implementation has to set a limit for the values it +%% accepts for integers, as well as for the encoded length; +%% see Section 4.1.1. In the same way, it has to set a limit +%% to the length it accepts for string literals; see Section 4.1.2. +%% These limits SHOULD be large enough to process the largest +%% individual field the HTTP implementation can be configured +%% to accept. + +%% If an implementation encounters a value larger than it is +%% able to decode, this MUST be treated as a stream error of +%% type QPACK_DECOMPRESSION_FAILED if on a request stream or +%% a connection error of the appropriate type if on the +%% encoder or decoder stream. + +-endif. diff --git a/test/rfc9220_SUITE.erl b/test/rfc9220_SUITE.erl new file mode 100644 index 0000000..7f447ed --- /dev/null +++ b/test/rfc9220_SUITE.erl @@ -0,0 +1,485 @@ +%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% +%% Permission to use, copy, modify, and/or distribute this software for any +%% purpose with or without fee is hereby granted, provided that the above +%% copyright notice and this permission notice appear in all copies. +%% +%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +-module(rfc9220_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). + +all() -> + [{group, enabled}]. + +groups() -> + Tests = ct_helper:all(?MODULE), + [{enabled, [], Tests}]. %% @todo Enable parallel when all is better. + +init_per_group(Name = enabled, Config) -> + cowboy_test:init_http3(Name, #{ + enable_connect_protocol => true, + env => #{dispatch => cowboy_router:compile(init_routes(Config))} + }, Config). + +end_per_group(Name, _) -> + cowboy_test:stop_group(Name). + +init_routes(_) -> [ + {"localhost", [ + {"/ws", ws_echo, []} + ]} +]. + +% The SETTINGS_ENABLE_CONNECT_PROTOCOL SETTINGS Parameter. + +% The new parameter name is SETTINGS_ENABLE_CONNECT_PROTOCOL. The +% value of the parameter MUST be 0 or 1. + +% Upon receipt of SETTINGS_ENABLE_CONNECT_PROTOCOL with a value of 1 a +% client MAY use the Extended CONNECT definition of this document when +% creating new streams. Receipt of this parameter by a server does not +% have any impact. +%% @todo ignore_client_enable_setting(Config) -> + +reject_handshake_when_disabled(Config0) -> + doc("Extended CONNECT requests MUST be rejected with a " + "H3_MESSAGE_ERROR stream error when enable_connect_protocol=false. " + "(RFC9220, RFC8441 4)"), + Config = cowboy_test:init_http3(disabled, #{ + enable_connect_protocol => false, + env => #{dispatch => cowboy_router:compile(init_routes(Config0))} + }, Config0), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 0. + #{ + conn := Conn, + settings := Settings + } = rfc9114_SUITE:do_connect(Config), + case Settings of + #{enable_connect_protocol := false} -> ok; + _ when map_size(Settings) =:= 0 -> ok + end, + %% Send a CONNECT :protocol request to upgrade the stream to Websocket. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +reject_handshake_disabled_by_default(Config0) -> + doc("Extended CONNECT requests MUST be rejected with a " + "H3_MESSAGE_ERROR stream error when enable_connect_protocol=false. " + "(RFC9220, RFC8441 4)"), + Config = cowboy_test:init_http3(disabled, #{ + env => #{dispatch => cowboy_router:compile(init_routes(Config0))} + }, Config0), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 0. + #{ + conn := Conn, + settings := Settings + } = rfc9114_SUITE:do_connect(Config), + case Settings of + #{enable_connect_protocol := false} -> ok; + _ when map_size(Settings) =:= 0 -> ok + end, + %% Send a CONNECT :protocol request to upgrade the stream to Websocket. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +% The Extended CONNECT Method. + +accept_uppercase_pseudo_header_protocol(Config) -> + doc("The :protocol pseudo header is case insensitive. (RFC9220, RFC8441 4, RFC9110 7.8)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send a CONNECT :protocol request to upgrade the stream to Websocket. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"WEBSOCKET">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% Receive a 200 response. + {ok, Data} = rfc9114_SUITE:do_receive_data(StreamRef), + {HLenEnc, HLenBits} = rfc9114_SUITE:do_guess_int_encoding(Data), + << + 1, %% HEADERS frame. + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes + >> = Data, + {ok, DecodedResponse, _DecData, _DecSt} + = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + #{<<":status">> := <<"200">>} = maps:from_list(DecodedResponse), + ok. + +reject_many_pseudo_header_protocol(Config) -> + doc("An extended CONNECT request containing more than one " + "protocol component must be rejected with a H3_MESSAGE_ERROR " + "stream error. (RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request with more than one :protocol pseudo-header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":protocol">>, <<"mqtt">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +reject_unknown_pseudo_header_protocol(Config) -> + doc("An extended CONNECT request containing more than one " + "protocol component must be rejected with a 501 Not Implemented " + "response. (RFC9220, RFC8441 4)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request with an unknown protocol. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"mqtt">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been rejected with a 501 Not Implemented. + #{headers := #{<<":status">> := <<"501">>}} = rfc9114_SUITE:do_receive_response(StreamRef), + ok. + +reject_invalid_pseudo_header_protocol(Config) -> + doc("An extended CONNECT request with an invalid protocol " + "component must be rejected with a 501 Not Implemented " + "response. (RFC9220, RFC8441 4)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request with an invalid protocol. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket mqtt">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been rejected with a 501 Not Implemented. + #{headers := #{<<":status">> := <<"501">>}} = rfc9114_SUITE:do_receive_response(StreamRef), + ok. + +reject_missing_pseudo_header_scheme(Config) -> + doc("An extended CONNECT request whtout a scheme component " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request without a :scheme pseudo-header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +reject_missing_pseudo_header_path(Config) -> + doc("An extended CONNECT request whtout a path component " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request without a :path pseudo-header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +% On requests bearing the :protocol pseudo-header, the :authority +% pseudo-header field is interpreted according to Section 8.1.2.3 of +% [RFC7540] instead of Section 8.3 of [RFC7540]. In particular the +% server MUST not make a new TCP connection to the host and port +% indicated by the :authority. + +reject_missing_pseudo_header_authority(Config) -> + doc("An extended CONNECT request whtout an authority component " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request without an :authority pseudo-header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +% Using Extended CONNECT To Bootstrap The WebSocket Protocol. + +reject_missing_pseudo_header_protocol(Config) -> + doc("An extended CONNECT request whtout a protocol component " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC9114 4.3.1, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request without a :protocol pseudo-header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +% The scheme of the Target URI [RFC7230] MUST be https for wss schemed +% WebSockets. HTTP/3 does not provide support for ws schemed WebSockets. +% The websocket URI is still used for proxy autoconfiguration. + +reject_connection_header(Config) -> + doc("An extended CONNECT request with a connection header " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC8441 4, RFC9114 4.2, RFC9114 4.5, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request with a connection header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"connection">>, <<"upgrade">>}, + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +reject_upgrade_header(Config) -> + doc("An extended CONNECT request with a upgrade header " + "must be rejected with a H3_MESSAGE_ERROR stream error. " + "(RFC9220, RFC8441 4, RFC9114 4.2, RFC9114 4.5, RFC9114 4.1.2)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send an extended CONNECT request with a upgrade header. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"upgrade">>, <<"websocket">>}, + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% The stream should have been aborted. + #{reason := h3_message_error} = rfc9114_SUITE:do_wait_stream_aborted(StreamRef), + ok. + +% After successfully processing the opening handshake the peers should +% proceed with The WebSocket Protocol [RFC6455] using the HTTP/2 stream +% from the CONNECT transaction as if it were the TCP connection +% referred to in [RFC6455]. The state of the WebSocket connection at +% this point is OPEN as defined by [RFC6455], Section 4.1. +%% @todo I'm guessing we should test for things like RST_STREAM, +%% closing the connection and others? + +% Examples. + +accept_handshake_when_enabled(Config) -> + doc("Confirm the example for Websocket over HTTP/2 works. (RFC9220, RFC8441 5.1)"), + %% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. + #{conn := Conn, settings := Settings} = rfc9114_SUITE:do_connect(Config), + #{enable_connect_protocol := true} = Settings, + %% Send a CONNECT :protocol request to upgrade the stream to Websocket. + {ok, StreamRef} = quicer:start_stream(Conn, #{}), + {ok, EncodedRequest, _EncData, _EncSt} = cow_qpack:encode_field_section([ + {<<":method">>, <<"CONNECT">>}, + {<<":protocol">>, <<"websocket">>}, + {<<":scheme">>, <<"https">>}, + {<<":path">>, <<"/ws">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<"sec-websocket-version">>, <<"13">>}, + {<<"origin">>, <<"http://localhost">>} + ], 0, cow_qpack:init(encoder)), + {ok, _} = quicer:send(StreamRef, [ + <<1>>, %% HEADERS frame. + cow_http3:encode_int(iolist_size(EncodedRequest)), + EncodedRequest + ]), + %% Receive a 200 response. + {ok, Data} = rfc9114_SUITE:do_receive_data(StreamRef), + {HLenEnc, HLenBits} = rfc9114_SUITE:do_guess_int_encoding(Data), + << + 1, %% HEADERS frame. + HLenEnc:2, HLen:HLenBits, + EncodedResponse:HLen/bytes + >> = Data, + {ok, DecodedResponse, _DecData, _DecSt} + = cow_qpack:decode_field_section(EncodedResponse, 0, cow_qpack:init(decoder)), + #{<<":status">> := <<"200">>} = maps:from_list(DecodedResponse), + %% Masked text hello echoed back clear by the server. + Mask = 16#37fa213d, + MaskedHello = ws_SUITE:do_mask(<<"Hello">>, Mask, <<>>), + {ok, _} = quicer:send(StreamRef, cow_http3:data( + <<1:1, 0:3, 1:4, 1:1, 5:7, Mask:32, MaskedHello/binary>>)), + {ok, WsData} = rfc9114_SUITE:do_receive_data(StreamRef), + << + 0, %% DATA frame. + 0:2, 7:6, %% Length (2 bytes header + "Hello"). + 1:1, 0:3, 1:4, 0:1, 5:7, "Hello" %% Websocket frame. + >> = WsData, + ok. + +%% Closing a Websocket stream. + +% The HTTP/3 stream closure is also analogous to the TCP connection +% closure of [RFC6455]. Orderly TCP-level closures are represented +% as a FIN bit on the stream (Section 4.4 of [HTTP/3]). RST exceptions +% are represented with a stream error (Section 8 of [HTTP/3]) of type +% H3_REQUEST_CANCELLED (Section 8.1 of [HTTP/3]). + +%% @todo client close frame with FIN +%% @todo server close frame with FIN +%% @todo client other frame with FIN +%% @todo server other frame with FIN +%% @todo client close connection diff --git a/test/security_SUITE.erl b/test/security_SUITE.erl index f06cec5..944c491 100644 --- a/test/security_SUITE.erl +++ b/test/security_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -33,10 +33,12 @@ groups() -> Tests = [nc_rand, nc_zero], H1Tests = [slowloris, slowloris_chunks], H2CTests = [ + http2_cancel_flood, http2_data_dribble, http2_empty_frame_flooding_data, http2_empty_frame_flooding_headers_continuation, http2_empty_frame_flooding_push_promise, + http2_infinite_continuations, http2_ping_flood, http2_reset_flood, http2_settings_flood, @@ -47,10 +49,12 @@ groups() -> {https, [parallel], Tests ++ H1Tests}, {h2, [parallel], Tests}, {h2c, [parallel], Tests ++ H2CTests}, + {h3, [], Tests}, {http_compress, [parallel], Tests ++ H1Tests}, {https_compress, [parallel], Tests ++ H1Tests}, {h2_compress, [parallel], Tests}, - {h2c_compress, [parallel], Tests ++ H2CTests} + {h2c_compress, [parallel], Tests ++ H2CTests}, + {h3_compress, [], Tests} ]. init_per_suite(Config) -> @@ -64,7 +68,7 @@ init_per_group(Name, Config) -> cowboy_test:init_common_groups(Name, Config, ?MODULE). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Routes. @@ -72,12 +76,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), @@ -179,6 +222,38 @@ http2_empty_frame_flooding_push_promise(Config) -> {ok, <<_:24, 7:8, _:72, 1:32>>} = gen_tcp:recv(Socket, 17, 6000), ok. +http2_infinite_continuations(Config) -> + doc("Confirm that Cowboy rejects CONTINUATION frames when the " + "total size of HEADERS + CONTINUATION(s) exceeds the limit. (VU#421644)"), + {ok, Socket} = rfc7540_SUITE:do_handshake(Config), + %% Send a HEADERS frame followed by a large number + %% of continuation frames. + {HeadersBlock, _} = cow_hpack:encode([ + {<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"http">>}, + {<<":authority">>, <<"localhost">>}, %% @todo Correct port number. + {<<":path">>, <<"/">>} + ]), + HeadersBlockLen = iolist_size(HeadersBlock), + ok = gen_tcp:send(Socket, [ + %% HEADERS frame. + << + HeadersBlockLen:24, 1:8, 0:5, + 0:1, %% END_HEADERS + 0:1, + 1:1, %% END_STREAM + 0:1, + 1:31 %% Stream ID. + >>, + HeadersBlock, + %% CONTINUATION frames. + [<<1024:24, 9:8, 0:8, 0:1, 1:31, 0:1024/unit:8>> + || _ <- lists:seq(1, 100)] + ]), + %% Receive an ENHANCE_YOUR_CALM connection error. + {ok, <<_:24, 7:8, _:72, 11: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)"), diff --git a/test/static_handler_SUITE.erl b/test/static_handler_SUITE.erl index 71a9619..9620f66 100644 --- a/test/static_handler_SUITE.erl +++ b/test/static_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2016-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2016-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -20,6 +20,12 @@ -import(ct_helper, [doc/1]). -import(cowboy_test, [gun_open/1]). +%% Import useful functions from req_SUITE. +%% @todo Maybe move these functions to cowboy_test. +-import(req_SUITE, [do_get/2]). +-import(req_SUITE, [do_get/3]). +-import(req_SUITE, [do_maybe_h3_error3/1]). + %% ct. all() -> @@ -39,16 +45,22 @@ groups() -> {dir, [parallel], DirTests}, {priv_dir, [parallel], DirTests} ], + GroupTestsNoParallel = OtherTests ++ [ + {dir, [], DirTests}, + {priv_dir, [], DirTests} + ], [ {http, [parallel], GroupTests}, {https, [parallel], GroupTests}, {h2, [parallel], GroupTests}, {h2c, [parallel], GroupTests}, + {h3, [], GroupTestsNoParallel}, %% @todo Enable parallel when it works better. {http_compress, [parallel], GroupTests}, {https_compress, [parallel], GroupTests}, {h2_compress, [parallel], GroupTests}, {h2c_compress, [parallel], GroupTests}, - %% No real need to test sendfile disabled against https or h2. + {h3_compress, [], GroupTestsNoParallel}, %% @todo Enable parallel when it works better. + %% No real need to test sendfile disabled against https, h2 or h3. {http_no_sendfile, [parallel], GroupTests}, {h2c_no_sendfile, [parallel], GroupTests} ]. @@ -65,7 +77,7 @@ init_per_suite(Config) -> %% Add a simple Erlang application archive containing one file %% in its priv directory. true = code:add_pathz(filename:join( - [config(data_dir, Config), "static_files_app", "ebin"])), + [config(data_dir, Config), "static_files_app.ez", "static_files_app", "ebin"])), ok = application:load(static_files_app), %% A special folder contains files of 1 character from 1 to 127 %% excluding / and \ as they are always rejected. @@ -116,6 +128,17 @@ init_per_group(Name=h2c_no_sendfile, Config) -> sendfile => false }, [{flavor, vanilla}|Config]), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name=h3, Config) -> + cowboy_test:init_http3(Name, #{ + env => #{dispatch => init_dispatch(Config)}, + middlewares => [?MODULE, cowboy_router, cowboy_handler] + }, [{flavor, vanilla}|Config]); +init_per_group(Name=h3_compress, Config) -> + cowboy_test:init_http3(Name, #{ + env => #{dispatch => init_dispatch(Config)}, + middlewares => [?MODULE, cowboy_router, cowboy_handler], + stream_handlers => [cowboy_compress_h, cowboy_stream_h] + }, [{flavor, vanilla}|Config]); init_per_group(Name, Config) -> Config1 = cowboy_test:init_common_groups(Name, Config, ?MODULE), Opts = ranch:get_protocol_options(Name), @@ -129,7 +152,7 @@ end_per_group(dir, _) -> end_per_group(priv_dir, _) -> ok; end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). %% Large file. @@ -248,25 +271,11 @@ do_mime_custom(Path) -> _ -> {<<"application">>, <<"octet-stream">>, []} end. -do_get(Path, Config) -> - do_get(Path, [], Config). - -do_get(Path, ReqHeaders, Config) -> - ConnPid = gun_open(Config), - Ref = gun:get(ConnPid, Path, [{<<"accept-encoding">>, <<"gzip">>}|ReqHeaders]), - {response, IsFin, Status, RespHeaders} = gun:await(ConnPid, Ref), - {ok, Body} = case IsFin of - nofin -> gun:await_body(ConnPid, Ref); - fin -> {ok, <<>>} - end, - gun:close(ConnPid), - {Status, RespHeaders, Body}. - %% Tests. bad(Config) -> doc("Bad cowboy_static options: not a tuple."), - {500, _, _} = do_get("/bad", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad", Config)), ok. bad_dir_path(Config) -> @@ -276,7 +285,7 @@ bad_dir_path(Config) -> bad_dir_route(Config) -> doc("Bad cowboy_static options: missing [...] in route."), - {500, _, _} = do_get("/bad/dir/route", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/dir/route", Config)), ok. bad_file_in_priv_dir_in_ez_archive(Config) -> @@ -291,27 +300,27 @@ bad_file_path(Config) -> bad_options(Config) -> doc("Bad cowboy_static extra options: not a list."), - {500, _, _} = do_get("/bad/options", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options", Config)), ok. bad_options_charset(Config) -> doc("Bad cowboy_static extra options: invalid charset option."), - {500, _, _} = do_get("/bad/options/charset", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/charset", Config)), ok. bad_options_etag(Config) -> doc("Bad cowboy_static extra options: invalid etag option."), - {500, _, _} = do_get("/bad/options/etag", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/etag", Config)), ok. bad_options_mime(Config) -> doc("Bad cowboy_static extra options: invalid mimetypes option."), - {500, _, _} = do_get("/bad/options/mime", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/options/mime", Config)), ok. bad_priv_dir_app(Config) -> doc("Bad cowboy_static options: wrong application name."), - {500, _, _} = do_get("/bad/priv_dir/app/style.css", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_dir/app/style.css", Config)), ok. bad_priv_dir_in_ez_archive(Config) -> @@ -331,12 +340,12 @@ bad_priv_dir_path(Config) -> bad_priv_dir_route(Config) -> doc("Bad cowboy_static options: missing [...] in route."), - {500, _, _} = do_get("/bad/priv_dir/route", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_dir/route", Config)), ok. bad_priv_file_app(Config) -> doc("Bad cowboy_static options: wrong application name."), - {500, _, _} = do_get("/bad/priv_file/app", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/bad/priv_file/app", Config)), ok. bad_priv_file_in_ez_archive(Config) -> @@ -535,7 +544,7 @@ dir_unknown(Config) -> etag_crash(Config) -> doc("Get a file with a crashing etag function."), - {500, _, _} = do_get("/etag/crash", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/etag/crash", Config)), ok. etag_custom(Config) -> @@ -813,7 +822,7 @@ mime_all_uppercase(Config) -> mime_crash(Config) -> doc("Get a file with a crashing mimetype function."), - {500, _, _} = do_get("/mime/crash/style.css", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/mime/crash/style.css", Config)), ok. mime_custom_cowboy(Config) -> @@ -848,7 +857,7 @@ mime_hardcode_tuple(Config) -> charset_crash(Config) -> doc("Get a file with a crashing charset function."), - {500, _, _} = do_get("/charset/crash/style.css", Config), + {500, _, _} = do_maybe_h3_error3(do_get("/charset/crash/style.css", Config)), ok. charset_custom_cowboy(Config) -> @@ -933,7 +942,8 @@ unicode_basic_error(Config) -> %% # and ? indicate fragment and query components %% and are therefore not part of the path. http -> "\r\s#?"; - http2 -> "#?" + http2 -> "#?"; + http3 -> "#?" end, _ = [case do_get("/char/" ++ [C], Config) of {400, _, _} -> ok; diff --git a/test/stream_handler_SUITE.erl b/test/stream_handler_SUITE.erl index 46a05b2..f8e2200 100644 --- a/test/stream_handler_SUITE.erl +++ b/test/stream_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -31,50 +31,42 @@ groups() -> %% We set this module as a logger in order to silence expected errors. init_per_group(Name = http, Config) -> - cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config); + cowboy_test:init_http(Name, init_plain_opts(), Config); init_per_group(Name = https, Config) -> - cowboy_test:init_https(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config); + cowboy_test:init_https(Name, init_plain_opts(), Config); init_per_group(Name = h2, Config) -> - cowboy_test:init_http2(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config); + cowboy_test:init_http2(Name, init_plain_opts(), Config); init_per_group(Name = h2c, Config) -> - Config1 = cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [stream_handler_h] - }, Config), + Config1 = cowboy_test:init_http(Name, init_plain_opts(), Config), lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3, Config) -> + cowboy_test:init_http3(Name, init_plain_opts(), Config); init_per_group(Name = http_compress, Config) -> - cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config); + cowboy_test:init_http(Name, init_compress_opts(), Config); init_per_group(Name = https_compress, Config) -> - cowboy_test:init_https(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config); + cowboy_test:init_https(Name, init_compress_opts(), Config); init_per_group(Name = h2_compress, Config) -> - cowboy_test:init_http2(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config); + cowboy_test:init_http2(Name, init_compress_opts(), Config); init_per_group(Name = h2c_compress, Config) -> - Config1 = cowboy_test:init_http(Name, #{ - logger => ?MODULE, - stream_handlers => [cowboy_compress_h, stream_handler_h] - }, Config), - lists:keyreplace(protocol, 1, Config1, {protocol, http2}). + Config1 = cowboy_test:init_http(Name, init_compress_opts(), Config), + lists:keyreplace(protocol, 1, Config1, {protocol, http2}); +init_per_group(Name = h3_compress, Config) -> + cowboy_test:init_http3(Name, init_compress_opts(), Config). end_per_group(Name, _) -> - cowboy:stop_listener(Name). + cowboy_test:stop_group(Name). + +init_plain_opts() -> + #{ + logger => ?MODULE, + stream_handlers => [stream_handler_h] + }. + +init_compress_opts() -> + #{ + logger => ?MODULE, + stream_handlers => [cowboy_compress_h, stream_handler_h] + }. %% Logger function silencing the expected crashes. @@ -99,15 +91,20 @@ crash_in_init(Config) -> %% Confirm terminate/3 is NOT called. We have no state to give to it. receive {Self, Pid, terminate, _, _, _} -> error(terminate) after 1000 -> ok end, %% Confirm early_error/5 is called in HTTP/1.1's case. - %% HTTP/2 does not send a response back so there is no early_error call. + %% HTTP/2 and HTTP/3 do not send a response back so there is no early_error call. case config(protocol, Config) of http -> receive {Self, Pid, early_error, _, _, _, _, _} -> ok after 1000 -> error(timeout) end; - http2 -> ok + http2 -> ok; + http3 -> ok end, - %% Receive a 500 error response. - case gun:await(ConnPid, Ref) of - {response, fin, 500, _} -> ok; - {error, {stream_error, {stream_error, internal_error, _}}} -> ok + do_await_internal_error(ConnPid, Ref, Config). + +do_await_internal_error(ConnPid, Ref, Config) -> + Protocol = config(protocol, Config), + case {Protocol, gun:await(ConnPid, Ref)} of + {http, {response, fin, 500, _}} -> ok; + {http2, {error, {stream_error, {stream_error, internal_error, _}}}} -> ok; + {http3, {error, {stream_error, {stream_error, h3_internal_error, _}}}} -> ok end. crash_in_data(Config) -> @@ -126,11 +123,7 @@ crash_in_data(Config) -> gun:data(ConnPid, Ref, fin, <<"Hello!">>), %% Confirm terminate/3 is called, indicating the stream ended. receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% Receive a 500 error response. - case gun:await(ConnPid, Ref) of - {response, fin, 500, _} -> ok; - {error, {stream_error, {stream_error, internal_error, _}}} -> ok - end. + do_await_internal_error(ConnPid, Ref, Config). crash_in_info(Config) -> doc("Confirm an error is sent when a stream handler crashes in info/3."), @@ -144,14 +137,14 @@ crash_in_info(Config) -> %% Confirm init/3 is called. Pid = receive {Self, P, init, _, _, _} -> P after 1000 -> error(timeout) end, %% Send a message to make the stream handler crash. - Pid ! {{Pid, 1}, crash}, + StreamID = case config(protocol, Config) of + http3 -> 0; + _ -> 1 + end, + Pid ! {{Pid, StreamID}, crash}, %% Confirm terminate/3 is called, indicating the stream ended. receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% Receive a 500 error response. - case gun:await(ConnPid, Ref) of - {response, fin, 500, _} -> ok; - {error, {stream_error, {stream_error, internal_error, _}}} -> ok - end. + do_await_internal_error(ConnPid, Ref, Config). crash_in_terminate(Config) -> doc("Confirm the state is correct when a stream handler crashes in terminate/3."), @@ -185,10 +178,12 @@ crash_in_terminate(Config) -> {ok, <<"Hello world!">>} = gun:await_body(ConnPid, Ref2), ok. +%% @todo The callbacks ARE used for HTTP/2 and HTTP/3 CONNECT/TRACE requests. crash_in_early_error(Config) -> case config(protocol, Config) of http -> do_crash_in_early_error(Config); - http2 -> doc("The callback early_error/5 is not currently used for HTTP/2.") + http2 -> doc("The callback early_error/5 is not currently used for HTTP/2."); + http3 -> doc("The callback early_error/5 is not currently used for HTTP/3.") end. do_crash_in_early_error(Config) -> @@ -225,10 +220,12 @@ do_crash_in_early_error(Config) -> {response, fin, 500, _} = gun:await(ConnPid, Ref2), ok. +%% @todo The callbacks ARE used for HTTP/2 and HTTP/3 CONNECT/TRACE requests. crash_in_early_error_fatal(Config) -> case config(protocol, Config) of http -> do_crash_in_early_error_fatal(Config); - http2 -> doc("The callback early_error/5 is not currently used for HTTP/2.") + http2 -> doc("The callback early_error/5 is not currently used for HTTP/2."); + http3 -> doc("The callback early_error/5 is not currently used for HTTP/3.") end. do_crash_in_early_error_fatal(Config) -> @@ -262,7 +259,8 @@ early_error_stream_error_reason(Config) -> %% reason in both protocols. {Method, Headers, Status, Error} = case config(protocol, Config) of http -> {<<"GET">>, [{<<"host">>, <<"host:port">>}], 400, protocol_error}; - http2 -> {<<"TRACE">>, [], 501, no_error} + http2 -> {<<"TRACE">>, [], 501, no_error}; + http3 -> {<<"TRACE">>, [], 501, h3_no_error} end, Ref = gun:request(ConnPid, Method, "/long_polling", [ {<<"accept-encoding">>, <<"gzip">>}, @@ -293,7 +291,7 @@ flow_after_body_fully_read(Config) -> %% Receive a 200 response, sent after the second flow command, %% confirming that the flow command was accepted. {response, _, 200, _} = gun:await(ConnPid, Ref), - ok. + gun:close(ConnPid). set_options_ignore_unknown(Config) -> doc("Confirm that unknown options are ignored when using the set_options commands."), @@ -355,11 +353,20 @@ shutdown_on_socket_close(Config) -> Spawn ! {Self, ready}, %% Close the socket. ok = gun:close(ConnPid), - %% Confirm terminate/3 is called, indicating the stream ended. - receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% Confirm we receive a DOWN message for the child process. - receive {'DOWN', MRef, process, Spawn, shutdown} -> ok after 1000 -> error(timeout) end, - ok. + Protocol = config(protocol, Config), + try + %% Confirm terminate/3 is called, indicating the stream ended. + receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, + %% Confirm we receive a DOWN message for the child process. + receive {'DOWN', MRef, process, Spawn, shutdown} -> ok after 1000 -> error(timeout) end, + ok + catch error:timeout when Protocol =:= http3 -> + %% @todo Figure out why this happens. Could be a timing issue + %% or a legitimate bug. I suspect that the server just + %% doesn't receive the GOAWAY frame from Gun because + %% Gun is too quick to close the connection. + shutdown_on_socket_close(Config) + end. shutdown_timeout_on_stream_stop(Config) -> doc("Confirm supervised processes are killed " @@ -406,33 +413,45 @@ shutdown_timeout_on_socket_close(Config) -> Spawn ! {Self, ready}, %% Close the socket. ok = gun:close(ConnPid), - %% Confirm terminate/3 is called, indicating the stream ended. - receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, - %% We should NOT receive a DOWN message immediately. - receive {'DOWN', MRef, process, Spawn, killed} -> error(killed) after 1500 -> ok end, - %% We should received it now. - receive {'DOWN', MRef, process, Spawn, killed} -> ok after 1000 -> error(timeout) end, - ok. + Protocol = config(protocol, Config), + try + %% Confirm terminate/3 is called, indicating the stream ended. + receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, + %% We should NOT receive a DOWN message immediately. + receive {'DOWN', MRef, process, Spawn, killed} -> error(killed) after 1500 -> ok end, + %% We should receive it now. + receive {'DOWN', MRef, process, Spawn, killed} -> ok after 1000 -> error(timeout) end, + ok + catch error:timeout when Protocol =:= http3 -> + %% @todo Figure out why this happens. Could be a timing issue + %% or a legitimate bug. I suspect that the server just + %% doesn't receive the GOAWAY frame from Gun because + %% Gun is too quick to close the connection. + shutdown_timeout_on_socket_close(Config) + end. switch_protocol_after_headers(Config) -> case config(protocol, Config) of http -> do_switch_protocol_after_response( <<"switch_protocol_after_headers">>, Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. switch_protocol_after_headers_data(Config) -> case config(protocol, Config) of http -> do_switch_protocol_after_response( <<"switch_protocol_after_headers_data">>, Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. switch_protocol_after_response(Config) -> case config(protocol, Config) of http -> do_switch_protocol_after_response( <<"switch_protocol_after_response">>, Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. do_switch_protocol_after_response(TestCase, Config) -> @@ -502,7 +521,12 @@ terminate_on_stop(Config) -> {response, fin, 204, _} = gun:await(ConnPid, Ref), %% Confirm the stream is still alive even though we %% received the response fully, and tell it to stop. - Pid ! {{Pid, 1}, please_stop}, + StreamID = case config(protocol, Config) of + http -> 1; + http2 -> 1; + http3 -> 0 + end, + Pid ! {{Pid, StreamID}, please_stop}, receive {Self, Pid, info, _, please_stop, _} -> ok after 1000 -> error(timeout) end, %% Confirm terminate/3 is called. receive {Self, Pid, terminate, _, _, _} -> ok after 1000 -> error(timeout) end, @@ -511,7 +535,8 @@ terminate_on_stop(Config) -> terminate_on_switch_protocol(Config) -> case config(protocol, Config) of http -> do_terminate_on_switch_protocol(Config); - http2 -> doc("The switch_protocol command is not currently supported for HTTP/2.") + http2 -> doc("The switch_protocol command is not currently supported for HTTP/2."); + http3 -> doc("The switch_protocol command is not currently supported for HTTP/3.") end. do_terminate_on_switch_protocol(Config) -> diff --git a/test/sys_SUITE.erl b/test/sys_SUITE.erl index 175219c..2feb716 100644 --- a/test/sys_SUITE.erl +++ b/test/sys_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -22,7 +22,6 @@ -import(ct_helper, [get_remote_pid_tcp/1]). -import(ct_helper, [get_remote_pid_tls/1]). -import(ct_helper, [is_process_down/1]). --import(cowboy_test, [gun_open/1]). all() -> [{group, sys}]. @@ -109,7 +108,8 @@ bad_system_from_h1(Config) -> bad_system_from_h2(Config) -> doc("h2: Sending a system message with a bad From value results in a process crash."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -176,7 +176,8 @@ bad_system_message_h1(Config) -> bad_system_message_h2(Config) -> doc("h2: Sending a system message with a bad Request value results in an error."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -252,7 +253,8 @@ good_system_message_h1(Config) -> good_system_message_h2(Config) -> doc("h2: System messages are handled properly."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -336,7 +338,8 @@ trap_exit_parent_exit_h2(Config) -> doc("h2: A process trapping exits must stop when receiving " "an 'EXIT' message from its parent."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -408,7 +411,8 @@ trap_exit_other_exit_h2(Config) -> doc("h2: A process trapping exits must ignore " "'EXIT' messages from unknown processes."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), Pid ! {'EXIT', self(), {shutdown, ?MODULE}}, @@ -526,7 +530,8 @@ sys_change_code_h1(Config) -> sys_change_code_h2(Config) -> doc("h2: The sys:change_code/4 function works as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% Suspend the process and try to get a request in. The @@ -609,7 +614,8 @@ sys_get_state_h1(Config) -> sys_get_state_h2(Config) -> doc("h2: The sys:get_state/1 function works as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -653,7 +659,7 @@ sys_get_state_loop(Config) -> timer:sleep(100), SupPid = get_remote_pid_tcp(Socket), [{_, Pid, _, _}] = supervisor:which_children(SupPid), - {Req, Env, long_polling_sys_h, undefined} = sys:get_state(Pid), + {Req, Env, long_polling_sys_h, undefined, infinity} = sys:get_state(Pid), #{pid := _, streamid := _} = Req, #{dispatch := _} = Env, ok. @@ -671,7 +677,8 @@ sys_get_status_h1(Config) -> sys_get_status_h2(Config) -> doc("h2: The sys:get_status/1 function works as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -732,7 +739,8 @@ sys_replace_state_h1(Config) -> sys_replace_state_h2(Config) -> doc("h2: The sys:replace_state/2 function works as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -776,7 +784,7 @@ sys_replace_state_loop(Config) -> timer:sleep(100), SupPid = get_remote_pid_tcp(Socket), [{_, Pid, _, _}] = supervisor:which_children(SupPid), - {Req, Env, long_polling_sys_h, undefined} = sys:replace_state(Pid, fun(S) -> S end), + {Req, Env, long_polling_sys_h, undefined, infinity} = sys:replace_state(Pid, fun(S) -> S end), #{pid := _, streamid := _} = Req, #{dispatch := _} = Env, ok. @@ -801,7 +809,8 @@ sys_suspend_and_resume_h1(Config) -> sys_suspend_and_resume_h2(Config) -> doc("h2: The sys:suspend/1 and sys:resume/1 functions work as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% Suspend the process and try to get a request in. The @@ -880,7 +889,8 @@ sys_terminate_h1(Config) -> sys_terminate_h2(Config) -> doc("h2: The sys:terminate/2,3 function works as expected."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), %% Skip the SETTINGS frame. {ok, <<_,_,_,4,_/bits>>} = ssl:recv(Socket, 0, 1000), timer:sleep(100), @@ -983,7 +993,8 @@ supervisor_count_children_h1(Config) -> supervisor_count_children_h2(Config) -> doc("h2: The function supervisor:count_children/1 must work."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% No request was sent so there's no children. @@ -1055,7 +1066,8 @@ supervisor_delete_child_not_found_h1(Config) -> supervisor_delete_child_not_found_h2(Config) -> doc("h2: The function supervisor:delete_child/2 must return {error, not_found}."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% When no children exist. @@ -1114,7 +1126,8 @@ supervisor_get_childspec_not_found_h1(Config) -> supervisor_get_childspec_not_found_h2(Config) -> doc("h2: The function supervisor:get_childspec/2 must return {error, not_found}."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% When no children exist. @@ -1173,7 +1186,8 @@ supervisor_restart_child_not_found_h1(Config) -> supervisor_restart_child_not_found_h2(Config) -> doc("h2: The function supervisor:restart_child/2 must return {error, not_found}."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% When no children exist. @@ -1227,7 +1241,8 @@ supervisor_start_child_not_found_h1(Config) -> supervisor_start_child_not_found_h2(Config) -> doc("h2: The function supervisor:start_child/2 must return {error, start_child_disabled}."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), {error, start_child_disabled} = supervisor:start_child(Pid, #{ @@ -1281,7 +1296,8 @@ supervisor_terminate_child_not_found_h1(Config) -> supervisor_terminate_child_not_found_h2(Config) -> doc("h2: The function supervisor:terminate_child/2 must return {error, not_found}."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% When no children exist. @@ -1344,7 +1360,8 @@ supervisor_which_children_h1(Config) -> supervisor_which_children_h2(Config) -> doc("h2: The function supervisor:which_children/1 must work."), {ok, Socket} = ssl:connect("localhost", config(tls_port, Config), - [{active, false}, binary, {alpn_advertised_protocols, [<<"h2">>]}]), + [{alpn_advertised_protocols, [<<"h2">>]}, + {active, false}, binary|config(tls_opts, Config)]), do_http2_handshake(Socket), Pid = get_remote_pid_tls(Socket), %% No request was sent so there's no children. diff --git a/test/tracer_SUITE.erl b/test/tracer_SUITE.erl index d5683a0..af1f8f3 100644 --- a/test/tracer_SUITE.erl +++ b/test/tracer_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2017-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -29,13 +29,15 @@ suite() -> %% We initialize trace patterns here. Appropriate would be in %% init_per_suite/1, but this works just as well. all() -> - case code:is_module_native(?MODULE) of - true -> - {skip, "The Cowboy tracer is not compatible with native code."}; - false -> - cowboy_tracer_h:set_trace_patterns(), - cowboy_test:common_all() - end. + %% @todo Implement these tests for HTTP/3. + cowboy_test:common_all() -- [{group, h3}, {group, h3_compress}]. + +init_per_suite(Config) -> + cowboy_tracer_h:set_trace_patterns(), + Config. + +end_per_suite(_) -> + ok. %% We want tests for each group to execute sequentially %% because we need to modify the protocol options. Groups diff --git a/test/ws_SUITE.erl b/test/ws_SUITE.erl index 9abeaca..3b74339 100644 --- a/test/ws_SUITE.erl +++ b/test/ws_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2011-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -68,7 +68,8 @@ init_dispatch() -> {"/ws_timeout_cancel", ws_timeout_cancel, []}, {"/ws_max_frame_size", ws_max_frame_size, []}, {"/ws_deflate_opts", ws_deflate_opts_h, []}, - {"/ws_dont_validate_utf8", ws_dont_validate_utf8_h, []} + {"/ws_dont_validate_utf8", ws_dont_validate_utf8_h, []}, + {"/ws_ping", ws_ping_h, []} ]} ]). @@ -341,6 +342,7 @@ ws_first_frame_with_handshake(Config) -> {ok, <<1:1, 0:3, 1:4, 0:1, 5:7, "Hello">>} = gen_tcp:recv(Socket, 0, 6000), ok. +%% @todo Move these tests to ws_handler_SUITE. ws_init_return_ok(Config) -> doc("Handler does nothing."), {ok, Socket, _} = do_handshake("/ws_init?ok", Config), @@ -471,6 +473,17 @@ ws_max_frame_size_intermediate_fragment_close(Config) -> {error, closed} = gen_tcp:recv(Socket, 0, 6000), ok. +ws_ping(Config) -> + doc("Server initiated pings can receive a pong in response."), + {ok, Socket, _} = do_handshake("/ws_ping", Config), + %% Receive a server-sent ping. + {ok, << 1:1, 0:3, 9:4, 0:1, 0:7 >>} = gen_tcp:recv(Socket, 0, 6000), + %% Send a pong back with a 0 mask. + ok = gen_tcp:send(Socket, << 1:1, 0:3, 10:4, 1:1, 0:7, 0:32 >>), + %% Receive a text frame as a result. + {ok, << 1:1, 0:3, 1:4, 0:1, 4:7, "OK!!" >>} = gen_tcp:recv(Socket, 0, 6000), + ok. + ws_send_close(Config) -> doc("Server-initiated close frame ends the connection."), {ok, Socket, _} = do_handshake("/ws_send_close", Config), diff --git a/test/ws_autobahn_SUITE.erl b/test/ws_autobahn_SUITE.erl index 24d76e8..71e5c81 100644 --- a/test/ws_autobahn_SUITE.erl +++ b/test/ws_autobahn_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2011-2017, Loïc Hoguin <[email protected]> +%% Copyright (c) 2011-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above diff --git a/test/ws_handler_SUITE.erl b/test/ws_handler_SUITE.erl index 435600f..ab1ffc8 100644 --- a/test/ws_handler_SUITE.erl +++ b/test/ws_handler_SUITE.erl @@ -1,4 +1,4 @@ -%% Copyright (c) 2018, Loïc Hoguin <[email protected]> +%% Copyright (c) 2018-2024, Loïc Hoguin <[email protected]> %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above @@ -50,6 +50,7 @@ init_dispatch(Name) -> {"/init", ws_init_commands_h, RunOrHibernate}, {"/handle", ws_handle_commands_h, RunOrHibernate}, {"/info", ws_info_commands_h, RunOrHibernate}, + {"/trap_exit", ws_init_h, RunOrHibernate}, {"/active", ws_active_commands_h, RunOrHibernate}, {"/deflate", ws_deflate_commands_h, RunOrHibernate}, {"/set_options", ws_set_options_commands_h, RunOrHibernate}, @@ -211,6 +212,13 @@ do_many_frames_then_close_frame(Config, Path) -> {ok, close} = receive_ws(ConnPid, StreamRef), gun_down(ConnPid). +websocket_init_trap_exit_false(Config) -> + doc("The trap_exit process flag must be set back to false before " + "the connection is taken over by Websocket."), + {ok, ConnPid, StreamRef} = gun_open_ws(Config, "/trap_exit?reply_trap_exit", []), + {ok, {text, <<"trap_exit: false">>}} = receive_ws(ConnPid, StreamRef), + ok. + websocket_active_false(Config) -> doc("The {active, false} command stops receiving data from the socket. " "The {active, true} command reenables it."), |