Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Provide better control over which HTTP protocols are enabled #1668

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions doc/src/manual/cowboy_http.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand All @@ -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(),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions doc/src/manual/cowboy_http2.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 0 additions & 4 deletions src/cowboy_clear.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 35 additions & 21 deletions src/cowboy_http.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand All @@ -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 ->
Expand All @@ -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.
Expand All @@ -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}) ->
Expand Down
2 changes: 2 additions & 0 deletions src/cowboy_http2.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 5 additions & 1 deletion src/cowboy_tls.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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) ->
Expand Down
78 changes: 76 additions & 2 deletions test/http_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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, #{
Expand Down Expand Up @@ -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}], #{
Expand Down
23 changes: 21 additions & 2 deletions test/rfc7540_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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) ->
Expand Down Expand Up @@ -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}]),
Expand Down
Loading