0
Fork 0
mirror of https://github.com/ninenines/cowboy.git synced 2025-07-14 12:20:24 +00:00

Provide better control over which HTTP protocols are enabled

Over cleartext TCP the `protocols` option lists the enabled
protocols. The default is to allow both HTTP/1.1 and HTTP/2.

Over TLS the default protocol to use when ALPN is not used
can now be configured via the `alpn_default_protocol` option.

Performing an HTTP/1.1 upgrade to HTTP/2 over TLS is now
rejected with an error as connecting to HTTP/2 over TLS
requires the use of ALPN (or that HTTP/2 be the default
when connecting over TLS).
This commit is contained in:
Loïc Hoguin 2025-02-10 15:26:00 +01:00
parent 971684788d
commit 053e233c56
No known key found for this signature in database
GPG key ID: 8A9DF795F6FED764
8 changed files with 160 additions and 28 deletions

View file

@ -18,6 +18,7 @@ as a Ranch protocol.
---- ----
opts() :: #{ opts() :: #{
active_n => pos_integer(), active_n => pos_integer(),
alpn_default_protocol => http | http2,
chunked => boolean(), chunked => boolean(),
connection_type => worker | supervisor, connection_type => worker | supervisor,
dynamic_buffer => false | {pos_integer(), pos_integer()}, dynamic_buffer => false | {pos_integer(), pos_integer()},
@ -36,6 +37,7 @@ opts() :: #{
max_method_length => non_neg_integer(), max_method_length => non_neg_integer(),
max_request_line_length => non_neg_integer(), max_request_line_length => non_neg_integer(),
max_skip_body_length => non_neg_integer(), max_skip_body_length => non_neg_integer(),
protocols => [http | http2],
proxy_header => boolean(), proxy_header => boolean(),
request_timeout => timeout(), request_timeout => timeout(),
reset_idle_timeout_on_send => boolean(), 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 packets from the port driver at the expense of potentially
higher memory being used. 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):: chunked (true)::
Whether chunked transfer-encoding is enabled for HTTP/1.1 connections. 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. 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. 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):: proxy_header (false)::
Whether incoming connections have a PROXY protocol header. The Whether incoming connections have a PROXY protocol header. The

View file

@ -18,6 +18,7 @@ as a Ranch protocol.
---- ----
opts() :: #{ opts() :: #{
active_n => pos_integer(), active_n => pos_integer(),
alpn_default_protocol => http | http2,
connection_type => worker | supervisor, connection_type => worker | supervisor,
connection_window_margin_size => 0..16#7fffffff, connection_window_margin_size => 0..16#7fffffff,
connection_window_update_threshold => 0..16#7fffffff, connection_window_update_threshold => 0..16#7fffffff,
@ -46,6 +47,7 @@ opts() :: #{
max_stream_buffer_size => non_neg_integer(), max_stream_buffer_size => non_neg_integer(),
max_stream_window_size => 0..16#7fffffff, max_stream_window_size => 0..16#7fffffff,
preface_timeout => timeout(), preface_timeout => timeout(),
protocols => [http | http2],
proxy_header => boolean(), proxy_header => boolean(),
reset_idle_timeout_on_send => boolean(), reset_idle_timeout_on_send => boolean(),
sendfile => 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 packets from the port driver at the expense of potentially
higher memory being used. 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):: connection_type (supervisor)::
Whether the connection process also acts as a 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. 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):: proxy_header (false)::
Whether incoming connections have a PROXY protocol header. The Whether incoming connections have a PROXY protocol header. The

View file

@ -36,10 +36,6 @@ connection_process(Parent, Ref, Transport, Opts) ->
ProxyInfo = get_proxy_info(Ref, Opts), ProxyInfo = get_proxy_info(Ref, Opts),
{ok, Socket} = ranch:handshake(Ref), {ok, Socket} = ranch:handshake(Ref),
%% Use cowboy_http2 directly only when 'http' is missing. %% 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 Protocol = case maps:get(protocols, Opts, [http2, http]) of
[http2] -> cowboy_http2; [http2] -> cowboy_http2;
[_|_] -> cowboy_http [_|_] -> cowboy_http

View file

@ -25,6 +25,7 @@
-type opts() :: #{ -type opts() :: #{
active_n => pos_integer(), active_n => pos_integer(),
alpn_default_protocol => http | http2,
chunked => boolean(), chunked => boolean(),
compress_buffering => boolean(), compress_buffering => boolean(),
compress_threshold => non_neg_integer(), compress_threshold => non_neg_integer(),
@ -52,6 +53,7 @@
metrics_req_filter => fun((cowboy_req:req()) -> map()), metrics_req_filter => fun((cowboy_req:req()) -> map()),
metrics_resp_headers_filter => fun((cowboy:http_headers()) -> cowboy:http_headers()), metrics_resp_headers_filter => fun((cowboy:http_headers()) -> cowboy:http_headers()),
middlewares => [module()], middlewares => [module()],
protocols => [http | http2],
proxy_header => boolean(), proxy_header => boolean(),
request_timeout => timeout(), request_timeout => timeout(),
reset_idle_timeout_on_send => boolean(), 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)'}); 'The TRACE method is currently not implemented. (RFC7231 4.3.8)'});
%% Accept direct HTTP/2 only at the beginning of the connection. %% Accept direct HTTP/2 only at the beginning of the connection.
<< "PRI * HTTP/2.0\r\n", _/bits >> when InStreamID =:= 1 -> << "PRI * HTTP/2.0\r\n", _/bits >> when InStreamID =:= 1 ->
%% @todo Might be worth throwing to get a clean stacktrace. case lists:member(http2, maps:get(protocols, Opts, [http2, http])) of
true ->
http2_upgrade(State, Buffer); 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, <<>>, parse_method(Buffer, State, <<>>,
maps:get(max_method_length, Opts, 32)) maps:get(max_method_length, Opts, 32))
@ -800,7 +807,7 @@ default_port(_) -> 80.
%% End of request parsing. %% End of request parsing.
request(Buffer, State0=#state{ref=Ref, transport=Transport, peer=Peer, sock=Sock, cert=Cert, 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}}, PS=#ps_header{method=Method, path=Path, qs=Qs, version=Version}},
Headers, Host, Port) -> Headers, Host, Port) ->
Scheme = case Transport:secure() of Scheme = case Transport:secure() of
@ -864,7 +871,7 @@ request(Buffer, State0=#state{ref=Ref, transport=Transport, peer=Peer, sock=Sock
undefined -> Req0; undefined -> Req0;
_ -> Req0#{proxy_header => ProxyHeader} _ -> Req0#{proxy_header => ProxyHeader}
end, end,
case is_http2_upgrade(Headers, Version) of case is_http2_upgrade(Headers, Version, Opts) of
false -> false ->
State = case HasBody of State = case HasBody of
true -> true ->
@ -886,12 +893,13 @@ request(Buffer, State0=#state{ref=Ref, transport=Transport, peer=Peer, sock=Sock
%% HTTP/2 upgrade. %% HTTP/2 upgrade.
%% @todo We must not upgrade to h2c over a TLS connection.
is_http2_upgrade(#{<<"connection">> := Conn, <<"upgrade">> := Upgrade, 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), Conns = cow_http_hd:parse_connection(Conn),
case {lists:member(<<"upgrade">>, Conns), lists:member(<<"http2-settings">>, Conns)} of case lists:member(<<"upgrade">>, Conns)
{true, true} -> 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), Protocols = cow_http_hd:parse_upgrade(Upgrade),
case lists:member(<<"h2c">>, Protocols) of case lists:member(<<"h2c">>, Protocols) of
true -> true ->
@ -902,7 +910,7 @@ is_http2_upgrade(#{<<"connection">> := Conn, <<"upgrade">> := Upgrade,
_ -> _ ->
false false
end; end;
is_http2_upgrade(_, _) -> is_http2_upgrade(_, _, _) ->
false. false.
%% Prior knowledge upgrade, without an HTTP/1.1 request. %% Prior knowledge upgrade, without an HTTP/1.1 request.
@ -922,6 +930,8 @@ http2_upgrade(State=#state{parent=Parent, ref=Ref, socket=Socket, transport=Tran
http2_upgrade(State=#state{parent=Parent, ref=Ref, socket=Socket, transport=Transport, http2_upgrade(State=#state{parent=Parent, ref=Ref, socket=Socket, transport=Transport,
proxy_header=ProxyHeader, peer=Peer, sock=Sock, cert=Cert}, proxy_header=ProxyHeader, peer=Peer, sock=Sock, cert=Cert},
Buffer, HTTP2Settings, Req) -> Buffer, HTTP2Settings, Req) ->
case Transport:secure() of
false ->
%% @todo %% @todo
%% However if the client sent a body, we need to read the body in full %% 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. %% and if we can't do that, return a 413 response. Some options are in order.
@ -934,6 +944,10 @@ http2_upgrade(State=#state{parent=Parent, ref=Ref, socket=Socket, transport=Tran
catch _:_ -> catch _:_ ->
error_terminate(400, State, {connection_error, protocol_error, 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)'}) '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. end.
opts_for_upgrade(#state{opts=Opts, dynamic_buffer_size=false}) -> opts_for_upgrade(#state{opts=Opts, dynamic_buffer_size=false}) ->

View file

@ -25,6 +25,7 @@
-type opts() :: #{ -type opts() :: #{
active_n => pos_integer(), active_n => pos_integer(),
alpn_default_protocol => http | http2,
compress_buffering => boolean(), compress_buffering => boolean(),
compress_threshold => non_neg_integer(), compress_threshold => non_neg_integer(),
connection_type => worker | supervisor, connection_type => worker | supervisor,
@ -62,6 +63,7 @@
metrics_resp_headers_filter => fun((cowboy:http_headers()) -> cowboy:http_headers()), metrics_resp_headers_filter => fun((cowboy:http_headers()) -> cowboy:http_headers()),
middlewares => [module()], middlewares => [module()],
preface_timeout => timeout(), preface_timeout => timeout(),
protocols => [http | http2],
proxy_header => boolean(), proxy_header => boolean(),
reset_idle_timeout_on_send => boolean(), reset_idle_timeout_on_send => boolean(),
sendfile => boolean(), sendfile => boolean(),

View file

@ -39,7 +39,11 @@ connection_process(Parent, Ref, Transport, Opts) ->
{ok, <<"h2">>} -> {ok, <<"h2">>} ->
init(Parent, Ref, Socket, Transport, ProxyInfo, Opts, cowboy_http2); init(Parent, Ref, Socket, Transport, ProxyInfo, Opts, cowboy_http2);
_ -> %% http/1.1 or no protocol negotiated. _ -> %% 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. end.
init(Parent, Ref, Socket, Transport, ProxyInfo, Opts, Protocol) -> init(Parent, Ref, Socket, Transport, ProxyInfo, Opts, Protocol) ->

View file

@ -199,6 +199,73 @@ do_chunked_body(ChunkSize0, Data, Acc) ->
do_chunked_body(ChunkSize, Rest, do_chunked_body(ChunkSize, Rest,
[iolist_to_binary(cow_http_te:chunk(Chunk))|Acc]). [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) -> hibernate(Config) ->
doc("Ensure that we can enable hibernation for HTTP/1.1 connections."), doc("Ensure that we can enable hibernation for HTTP/1.1 connections."),
{ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{ {ok, _} = cowboy:start_clear(?FUNCTION_NAME, [{port, 0}], #{

View file

@ -35,8 +35,9 @@ all() -> [{group, clear}, {group, tls}].
groups() -> groups() ->
Tests = ct_helper:all(?MODULE), Tests = ct_helper:all(?MODULE),
Clear = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =/= "alpn"] -- [prior_knowledge_reject_tls], RejectTLS = [http_upgrade_reject_tls, prior_knowledge_reject_tls],
TLS = [T || T <- Tests, lists:sublist(atom_to_list(T), 4) =:= "alpn"] ++ [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}]. [{clear, [parallel], Clear}, {tls, [parallel], TLS}].
init_per_group(Name = clear, Config) -> init_per_group(Name = clear, Config) ->
@ -68,6 +69,24 @@ init_routes(_) -> [
%% Starting HTTP/2 for "http" URIs. %% 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) -> http_upgrade_ignore_h2(Config) ->
doc("An h2 token in an Upgrade field must be ignored. (RFC7540 3.2)"), 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}]), {ok, Socket} = gen_tcp:connect("localhost", config(port, Config), [binary, {active, false}]),