diff --git a/doc/src/manual/cowboy_http.asciidoc b/doc/src/manual/cowboy_http.asciidoc index aff9f1b8..96a55853 100644 --- a/doc/src/manual/cowboy_http.asciidoc +++ b/doc/src/manual/cowboy_http.asciidoc @@ -18,6 +18,7 @@ as a Ranch protocol. ---- opts() :: #{ active_n => pos_integer(), + alpn_default_protocol => http | http2, chunked => boolean(), connection_type => worker | supervisor, dynamic_buffer => false | {pos_integer(), pos_integer()}, @@ -36,6 +37,7 @@ opts() :: #{ max_method_length => non_neg_integer(), max_request_line_length => non_neg_integer(), max_skip_body_length => non_neg_integer(), + protocols => [http | http2], proxy_header => boolean(), request_timeout => timeout(), reset_idle_timeout_on_send => boolean(), @@ -63,6 +65,12 @@ values reduce the number of times Cowboy need to request more packets from the port driver at the expense of potentially higher memory being used. +alpn_default_protocol (http):: + +Default protocol to use when the client connects over TLS +without ALPN. Can be set to `http2` to disable HTTP/1.1 +entirely. + chunked (true):: Whether chunked transfer-encoding is enabled for HTTP/1.1 connections. @@ -156,6 +164,13 @@ max_skip_body_length (1000000):: Maximum length Cowboy is willing to skip when the user code did not read the body fully. When the remaining length is too large or unknown Cowboy will close the connection. +protocols ([http2, http]):: + +Protocols that may be used when the client connects over +cleartext TCP. The default is to allow both HTTP/1.1 and +HTTP/2. HTTP/1.1 and HTTP/2 can be disabled entirely by +omitting them from the list. + proxy_header (false):: Whether incoming connections have a PROXY protocol header. The diff --git a/doc/src/manual/cowboy_http2.asciidoc b/doc/src/manual/cowboy_http2.asciidoc index 971dcb86..7b34b884 100644 --- a/doc/src/manual/cowboy_http2.asciidoc +++ b/doc/src/manual/cowboy_http2.asciidoc @@ -18,6 +18,7 @@ as a Ranch protocol. ---- opts() :: #{ active_n => pos_integer(), + alpn_default_protocol => http | http2, connection_type => worker | supervisor, connection_window_margin_size => 0..16#7fffffff, connection_window_update_threshold => 0..16#7fffffff, @@ -46,6 +47,7 @@ opts() :: #{ max_stream_buffer_size => non_neg_integer(), max_stream_window_size => 0..16#7fffffff, preface_timeout => timeout(), + protocols => [http | http2], proxy_header => boolean(), reset_idle_timeout_on_send => boolean(), sendfile => boolean(), @@ -76,6 +78,12 @@ values reduce the number of times Cowboy need to request more packets from the port driver at the expense of potentially higher memory being used. +alpn_default_protocol (http):: + +Default protocol to use when the client connects over TLS +without ALPN. Can be set to `http2` to disable HTTP/1.1 +entirely. + connection_type (supervisor):: Whether the connection process also acts as a supervisor. @@ -259,6 +267,13 @@ preface_timeout (5000):: Time in ms Cowboy is willing to wait for the connection preface. +protocols ([http2, http]):: + +Protocols that may be used when the client connects over +cleartext TCP. The default is to allow both HTTP/1.1 and +HTTP/2. HTTP/1.1 and HTTP/2 can be disabled entirely by +omitting them from the list. + proxy_header (false):: Whether incoming connections have a PROXY protocol header. The diff --git a/src/cowboy_clear.erl b/src/cowboy_clear.erl index eaeab745..3fe8db53 100644 --- a/src/cowboy_clear.erl +++ b/src/cowboy_clear.erl @@ -36,10 +36,6 @@ connection_process(Parent, Ref, Transport, Opts) -> ProxyInfo = get_proxy_info(Ref, Opts), {ok, Socket} = ranch:handshake(Ref), %% Use cowboy_http2 directly only when 'http' is missing. - %% Otherwise switch to cowboy_http2 from cowboy_http. - %% - %% @todo Extend this option to cowboy_tls and allow disabling - %% the switch to cowboy_http2 in cowboy_http. Also document it. Protocol = case maps:get(protocols, Opts, [http2, http]) of [http2] -> cowboy_http2; [_|_] -> cowboy_http diff --git a/src/cowboy_http.erl b/src/cowboy_http.erl index 476c6944..9438c722 100644 --- a/src/cowboy_http.erl +++ b/src/cowboy_http.erl @@ -25,6 +25,7 @@ -type opts() :: #{ active_n => pos_integer(), + alpn_default_protocol => http | http2, chunked => boolean(), compress_buffering => boolean(), compress_threshold => non_neg_integer(), @@ -52,6 +53,7 @@ metrics_req_filter => fun((cowboy_req:req()) -> map()), metrics_resp_headers_filter => fun((cowboy:http_headers()) -> cowboy:http_headers()), middlewares => [module()], + protocols => [http | http2], proxy_header => boolean(), request_timeout => timeout(), reset_idle_timeout_on_send => boolean(), @@ -511,8 +513,13 @@ parse_request(Buffer, State=#state{opts=Opts, in_streamid=InStreamID}, EmptyLine 'The TRACE method is currently not implemented. (RFC7231 4.3.8)'}); %% Accept direct HTTP/2 only at the beginning of the connection. << "PRI * HTTP/2.0\r\n", _/bits >> when InStreamID =:= 1 -> - %% @todo Might be worth throwing to get a clean stacktrace. - http2_upgrade(State, Buffer); + case lists:member(http2, maps:get(protocols, Opts, [http2, http])) of + true -> + http2_upgrade(State, Buffer); + false -> + error_terminate(501, State, {connection_error, no_error, + 'Prior knowledge upgrade to HTTP/2 is disabled by configuration.'}) + end; _ -> parse_method(Buffer, State, <<>>, maps:get(max_method_length, Opts, 32)) @@ -800,7 +807,7 @@ default_port(_) -> 80. %% End of request parsing. request(Buffer, State0=#state{ref=Ref, transport=Transport, peer=Peer, sock=Sock, cert=Cert, - proxy_header=ProxyHeader, in_streamid=StreamID, in_state= + opts=Opts, proxy_header=ProxyHeader, in_streamid=StreamID, in_state= PS=#ps_header{method=Method, path=Path, qs=Qs, version=Version}}, Headers, Host, Port) -> Scheme = case Transport:secure() of @@ -864,7 +871,7 @@ request(Buffer, State0=#state{ref=Ref, transport=Transport, peer=Peer, sock=Sock undefined -> Req0; _ -> Req0#{proxy_header => ProxyHeader} end, - case is_http2_upgrade(Headers, Version) of + case is_http2_upgrade(Headers, Version, Opts) of false -> State = case HasBody of true -> @@ -886,12 +893,13 @@ request(Buffer, State0=#state{ref=Ref, transport=Transport, peer=Peer, sock=Sock %% HTTP/2 upgrade. -%% @todo We must not upgrade to h2c over a TLS connection. is_http2_upgrade(#{<<"connection">> := Conn, <<"upgrade">> := Upgrade, - <<"http2-settings">> := HTTP2Settings}, 'HTTP/1.1') -> + <<"http2-settings">> := HTTP2Settings}, 'HTTP/1.1', Opts) -> Conns = cow_http_hd:parse_connection(Conn), - case {lists:member(<<"upgrade">>, Conns), lists:member(<<"http2-settings">>, Conns)} of - {true, true} -> + case lists:member(<<"upgrade">>, Conns) + andalso lists:member(<<"http2-settings">>, Conns) + andalso lists:member(http2, maps:get(protocols, Opts, [http2, http])) of + true -> Protocols = cow_http_hd:parse_upgrade(Upgrade), case lists:member(<<"h2c">>, Protocols) of true -> @@ -902,7 +910,7 @@ is_http2_upgrade(#{<<"connection">> := Conn, <<"upgrade">> := Upgrade, _ -> false end; -is_http2_upgrade(_, _) -> +is_http2_upgrade(_, _, _) -> false. %% Prior knowledge upgrade, without an HTTP/1.1 request. @@ -922,18 +930,24 @@ http2_upgrade(State=#state{parent=Parent, ref=Ref, socket=Socket, transport=Tran http2_upgrade(State=#state{parent=Parent, ref=Ref, socket=Socket, transport=Transport, proxy_header=ProxyHeader, peer=Peer, sock=Sock, cert=Cert}, Buffer, HTTP2Settings, Req) -> - %% @todo - %% However if the client sent a body, we need to read the body in full - %% and if we can't do that, return a 413 response. Some options are in order. - %% Always half-closed stream coming from this side. - try cow_http_hd:parse_http2_settings(HTTP2Settings) of - Settings -> - _ = cancel_timeout(State), - cowboy_http2:init(Parent, Ref, Socket, Transport, ProxyHeader, - opts_for_upgrade(State), Peer, Sock, Cert, Buffer, Settings, Req) - catch _:_ -> - error_terminate(400, State, {connection_error, protocol_error, - 'The HTTP2-Settings header must contain a base64 SETTINGS payload. (RFC7540 3.2, RFC7540 3.2.1)'}) + case Transport:secure() of + false -> + %% @todo + %% However if the client sent a body, we need to read the body in full + %% and if we can't do that, return a 413 response. Some options are in order. + %% Always half-closed stream coming from this side. + try cow_http_hd:parse_http2_settings(HTTP2Settings) of + Settings -> + _ = cancel_timeout(State), + cowboy_http2:init(Parent, Ref, Socket, Transport, ProxyHeader, + opts_for_upgrade(State), Peer, Sock, Cert, Buffer, Settings, Req) + catch _:_ -> + error_terminate(400, State, {connection_error, protocol_error, + 'The HTTP2-Settings header must contain a base64 SETTINGS payload. (RFC7540 3.2, RFC7540 3.2.1)'}) + end; + true -> + error_terminate(400, State, {connection_error, protocol_error, + 'Clients that support HTTP/2 over TLS MUST use ALPN. (RFC7540 3.4)'}) end. opts_for_upgrade(#state{opts=Opts, dynamic_buffer_size=false}) -> diff --git a/src/cowboy_http2.erl b/src/cowboy_http2.erl index 207a9679..0944f91a 100644 --- a/src/cowboy_http2.erl +++ b/src/cowboy_http2.erl @@ -25,6 +25,7 @@ -type opts() :: #{ active_n => pos_integer(), + alpn_default_protocol => http | http2, compress_buffering => boolean(), compress_threshold => non_neg_integer(), connection_type => worker | supervisor, @@ -62,6 +63,7 @@ metrics_resp_headers_filter => fun((cowboy:http_headers()) -> cowboy:http_headers()), middlewares => [module()], preface_timeout => timeout(), + protocols => [http | http2], proxy_header => boolean(), reset_idle_timeout_on_send => boolean(), sendfile => boolean(), diff --git a/src/cowboy_tls.erl b/src/cowboy_tls.erl index 60ab2ed5..51b22300 100644 --- a/src/cowboy_tls.erl +++ b/src/cowboy_tls.erl @@ -39,7 +39,11 @@ connection_process(Parent, Ref, Transport, Opts) -> {ok, <<"h2">>} -> init(Parent, Ref, Socket, Transport, ProxyInfo, Opts, cowboy_http2); _ -> %% http/1.1 or no protocol negotiated. - init(Parent, Ref, Socket, Transport, ProxyInfo, Opts, cowboy_http) + Protocol = case maps:get(alpn_default_protocol, Opts, http) of + http -> cowboy_http; + http2 -> cowboy_http2 + end, + init(Parent, Ref, Socket, Transport, ProxyInfo, Opts, Protocol) end. init(Parent, Ref, Socket, Transport, ProxyInfo, Opts, Protocol) -> diff --git a/test/http_SUITE.erl b/test/http_SUITE.erl index f8ba530b..0ef2d2c5 100644 --- a/test/http_SUITE.erl +++ b/test/http_SUITE.erl @@ -28,9 +28,16 @@ -import(cowboy_test, [raw_recv/3]). -import(cowboy_test, [raw_expect_recv/2]). -all() -> [{group, clear}]. +all() -> + [{group, clear_no_parallel}, {group, clear}]. -groups() -> [{clear, [parallel], ct_helper:all(?MODULE)}]. +groups() -> + [ + %% cowboy:stop_listener can be slow when called many times + %% in parallel so we must run this test separately from the others. + {clear_no_parallel, [], [graceful_shutdown_listener]}, + {clear, [parallel], ct_helper:all(?MODULE) -- [graceful_shutdown_listener]} + ]. init_per_group(Name, Config) -> cowboy_test:init_http(Name, #{ @@ -199,6 +206,73 @@ do_chunked_body(ChunkSize0, Data, Acc) -> do_chunked_body(ChunkSize, Rest, [iolist_to_binary(cow_http_te:chunk(Chunk))|Acc]). +disable_http1_tls(Config) -> + doc("Ensure that we can disable HTTP/1.1 over TLS (force HTTP/2)."), + TlsOpts = ct_helper:get_certs_from_ets(), + {ok, _} = cowboy:start_tls(?FUNCTION_NAME, TlsOpts ++ [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + alpn_default_protocol => http2 + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + {ok, Socket} = ssl:connect("localhost", Port, + [binary, {active, false}|TlsOpts]), + %% ALPN was not negotiated but we're still over HTTP/2. + {error, protocol_not_negotiated} = 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(#{})]), + %% Receive the server preface. + {ok, << Len:24 >>} = ssl:recv(Socket, 3, 1000), + {ok, << 4:8, 0:40, _:Len/binary >>} = ssl:recv(Socket, 6 + Len, 1000), + ok + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +disable_http2_prior_knowledge(Config) -> + doc("Ensure that we can disable prior knowledge HTTP/2 upgrade."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + protocols => [http] + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + {ok, Socket} = gen_tcp:connect("localhost", Port, [binary, {active, false}]), + %% Send a valid preface. + ok = gen_tcp:send(Socket, [ + "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", + cow_http2:settings(#{})]), + {ok, <<"HTTP/1.1 501">>} = gen_tcp:recv(Socket, 12, 1000), + ok + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + +disable_http2_upgrade(Config) -> + doc("Ensure that we can disable HTTP/1.1 upgrade to HTTP/2."), + {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ + env => #{dispatch => init_dispatch(Config)}, + protocols => [http] + }), + Port = ranch:get_port(?FUNCTION_NAME), + try + {ok, Socket} = gen_tcp:connect("localhost", Port, [binary, {active, false}]), + %% Send a valid preface. + ok = gen_tcp:send(Socket, [ + "GET / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Connection: Upgrade, HTTP2-Settings\r\n" + "Upgrade: h2c\r\n" + "HTTP2-Settings: ", base64:encode(cow_http2:settings_payload(#{})), "\r\n", + "\r\n"]), + {ok, <<"HTTP/1.1 200">>} = gen_tcp:recv(Socket, 12, 1000), + ok + after + cowboy:stop_listener(?FUNCTION_NAME) + end. + hibernate(Config) -> doc("Ensure that we can enable hibernation for HTTP/1.1 connections."), {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ diff --git a/test/rfc7540_SUITE.erl b/test/rfc7540_SUITE.erl index f0406014..a72e14b5 100644 --- a/test/rfc7540_SUITE.erl +++ b/test/rfc7540_SUITE.erl @@ -35,8 +35,9 @@ all() -> [{group, clear}, {group, tls}]. groups() -> 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], + RejectTLS = [http_upgrade_reject_tls, prior_knowledge_reject_tls], + Clear = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =/= "alpn"] -- RejectTLS, + TLS = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =:= "alpn"] ++ RejectTLS, [{clear, [parallel], Clear}, {tls, [parallel], TLS}]. init_per_group(Name = clear, Config) -> @@ -68,6 +69,24 @@ init_routes(_) -> [ %% Starting HTTP/2 for "http" URIs. +http_upgrade_reject_tls(Config) -> + doc("Implementations that support HTTP/2 over TLS must use ALPN. (RFC7540 3.4)"), + 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, [ + "GET / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Connection: Upgrade, HTTP2-Settings\r\n" + "Upgrade: h2c\r\n" + "HTTP2-Settings: ", base64:encode(cow_http2:settings_payload(#{})), "\r\n", + "\r\n"]), + %% We expect the server to send an HTTP 400 error + %% when trying to use HTTP/2 without going through ALPN negotiation. + {ok, <<"HTTP/1.1 400">>} = ssl:recv(Socket, 12, 1000), + ok. + http_upgrade_ignore_h2(Config) -> doc("An h2 token in an Upgrade field must be ignored. (RFC7540 3.2)"), {ok, Socket} = gen_tcp:connect("localhost", config(port, Config), [binary, {active, false}]),