Browse Source

Add options to control h2's SETTINGS_HEADER_TABLE_SIZE

Loïc Hoguin 7 years ago
parent
commit
8f4adf437c

+ 15 - 0
doc/src/manual/cowboy_http2.asciidoc

@@ -21,6 +21,8 @@ opts() :: #{
     enable_connect_protocol => boolean(),
     env                     => cowboy_middleware:env(),
     inactivity_timeout      => timeout(),
+    max_decode_table_size   => non_neg_integer(),
+    max_encode_table_size   => non_neg_integer(),
     middlewares             => [module()],
     preface_timeout         => timeout(),
     shutdown_timeout        => timeout(),
@@ -45,6 +47,7 @@ connection_type (supervisor)::
 enable_connect_protocol (false)::
     Whether to enable the extended CONNECT method to allow
     protocols like Websocket to be used over an HTTP/2 stream.
+    This option is experimental and disabled by default.
 
 env (#{})::
     Middleware environment.
@@ -52,6 +55,16 @@ env (#{})::
 inactivity_timeout (300000)::
     Time in ms with nothing received at all before Cowboy closes the connection.
 
+max_decode_table_size (4096)::
+    Maximum header table size used by the decoder. This is the value advertised
+    to the client. The client can then choose a header table size equal or lower
+    to the advertised value.
+
+max_encode_table_size (4096)::
+    Maximum header table size used by the encoder. The server will compare this
+    value to what the client advertises and choose the smallest one as the
+    encoder's header table size.
+
 middlewares ([cowboy_router, cowboy_handler])::
     Middlewares to run for every request.
 
@@ -66,6 +79,8 @@ stream_handlers ([cowboy_stream_h])::
 
 == Changelog
 
+* *2.4*: Add the options `max_decode_table_size` and `max_encode_table_size`.
+* *2.4*: Add the experimental option `enable_connect_protocol`.
 * *2.0*: Protocol introduced.
 
 == See also

+ 40 - 14
src/cowboy_http2.erl

@@ -27,6 +27,8 @@
 	enable_connect_protocol => boolean(),
 	env => cowboy_middleware:env(),
 	inactivity_timeout => timeout(),
+	max_decode_table_size => non_neg_integer(),
+	max_encode_table_size => non_neg_integer(),
 	middlewares => [module()],
 	preface_timeout => timeout(),
 	shutdown_timeout => timeout(),
@@ -93,7 +95,7 @@
 	%% @todo We need a TimerRef to do SETTINGS_TIMEOUT errors.
 	%% We need to be careful there. It's well possible that we send
 	%% two SETTINGS frames before we receive a SETTINGS ack.
-	next_settings = #{} :: undefined | map(), %% @todo perhaps set to undefined by default
+	next_settings = undefined :: undefined | map(),
 	remote_settings = #{
 		initial_window_size => 65535
 	} :: map(),
@@ -201,9 +203,22 @@ init(Parent, Ref, Socket, Transport, Opts, Peer, Sock, Cert, Buffer, _Settings,
 		_ -> parse(State, Buffer)
 	end.
 
-settings_init(State=#state{next_settings=Settings}, Opts) ->
-	EnableConnectProtocol = maps:get(enable_connect_protocol, Opts, false),
-	State#state{next_settings=Settings#{enable_connect_protocol => EnableConnectProtocol}}.
+settings_init(State, Opts) ->
+	S0 = setting_from_opt(#{}, Opts, max_decode_table_size,
+		header_table_size, 4096),
+	%% @todo max_concurrent_streams + enforce it
+	%% @todo initial_window_size
+	%% @todo max_frame_size
+	%% @todo max_header_list_size
+	Settings = setting_from_opt(S0, Opts, enable_connect_protocol,
+		enable_connect_protocol, false),
+	State#state{next_settings=Settings}.
+
+setting_from_opt(Settings, Opts, OptName, SettingName, Default) ->
+	case maps:get(OptName, Opts, Default) of
+		Default -> Settings;
+		Value -> Settings#{SettingName => Value}
+	end.
 
 preface(#state{socket=Socket, transport=Transport, next_settings=Settings}) ->
 	%% We send next_settings and use defaults until we get a ack.
@@ -408,21 +423,32 @@ frame(State=#state{client_streamid=LastStreamID}, {rst_stream, StreamID, _})
 frame(State, {rst_stream, StreamID, Reason}) ->
 	stream_terminate(State, StreamID, {stream_error, Reason, 'Stream reset requested by client.'});
 %% SETTINGS frame.
-frame(State0=#state{socket=Socket, transport=Transport, remote_settings=Settings0},
-		{settings, Settings}) ->
+frame(State0=#state{socket=Socket, transport=Transport, opts=Opts,
+		remote_settings=Settings0}, {settings, Settings}) ->
 	Transport:send(Socket, cow_http2:settings_ack()),
-	State = State0#state{remote_settings=maps:merge(Settings0, Settings)},
-	case Settings of
-		#{initial_window_size := NewWindowSize} ->
+	State1 = State0#state{remote_settings=maps:merge(Settings0, Settings)},
+	maps:fold(fun
+		(header_table_size, NewSize, State=#state{encode_state=EncodeState0}) ->
+			MaxSize = maps:get(max_encode_table_size, Opts, 4096),
+			EncodeState = cow_hpack:set_max_size(min(NewSize, MaxSize), EncodeState0),
+			State#state{encode_state=EncodeState};
+		(initial_window_size, NewWindowSize, State) ->
 			OldWindowSize = maps:get(initial_window_size, Settings0, 65535),
 			update_stream_windows(State, NewWindowSize - OldWindowSize);
-		_ ->
+		(_, _, State) ->
 			State
-	end;
+	end, State1, Settings);
 %% Ack for a previously sent SETTINGS frame.
-frame(State=#state{local_settings=Local0, next_settings=Next}, settings_ack) ->
-	Local = maps:merge(Local0, Next),
-	State#state{local_settings=Local, next_settings=#{}};
+frame(State0=#state{local_settings=Local0, next_settings=NextSettings}, settings_ack) ->
+	Local = maps:merge(Local0, NextSettings),
+	State1 = State0#state{local_settings=Local, next_settings=#{}},
+	maps:fold(fun
+		(header_table_size, MaxSize, State=#state{decode_state=DecodeState0}) ->
+			DecodeState = cow_hpack:set_max_size(MaxSize, DecodeState0),
+			State#state{decode_state=DecodeState};
+		(_, _, State) ->
+			State
+	end, State1, NextSettings);
 %% Unexpected PUSH_PROMISE frame.
 frame(State, {push_promise, _, _, _, _}) ->
 	terminate(State, {connection_error, protocol_error,

+ 8 - 2
test/draft_h2_websockets_SUITE.erl

@@ -78,7 +78,10 @@ reject_handshake_when_disabled(Config0) ->
 	}, Config0),
 	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 0.
 	{ok, Socket, Settings} = do_handshake(Config),
-	#{enable_connect_protocol := false} = Settings,
+	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.
 	{ReqHeadersBlock, _} = cow_hpack:encode([
 		{<<":method">>, <<"CONNECT">>},
@@ -102,7 +105,10 @@ reject_handshake_disabled_by_default(Config0) ->
 	}, Config0),
 	%% Connect to server and confirm that SETTINGS_ENABLE_CONNECT_PROTOCOL = 0.
 	{ok, Socket, Settings} = do_handshake(Config),
-	#{enable_connect_protocol := false} = Settings,
+	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.
 	{ReqHeadersBlock, _} = cow_hpack:encode([
 		{<<":method">>, <<"CONNECT">>},

+ 84 - 0
test/rfc7540_SUITE.erl

@@ -18,6 +18,7 @@
 
 -import(ct_helper, [config/2]).
 -import(ct_helper, [doc/1]).
+-import(ct_helper, [name/0]).
 -import(cowboy_test, [gun_open/1]).
 -import(cowboy_test, [raw_open/1]).
 -import(cowboy_test, [raw_send/2]).
@@ -2449,6 +2450,89 @@ continuation_with_extension_frame_interleaved_error(Config) ->
 %   (Section 5.4.1) of type PROTOCOL_ERROR.
 
 %% (RFC7540 6.5.2)
+
+settings_header_table_size_client(Config) ->
+	doc("The SETTINGS_HEADER_TABLE_SIZE setting can be used to "
+		"inform the server of the maximum header table size "
+		"used by the client to decode header blocks. (RFC7540 6.5.2)"),
+	HeaderTableSize = 128,
+	%% Do the handhsake.
+	{ok, Socket} = gen_tcp:connect("localhost", config(port, Config), [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(#{header_table_size => HeaderTableSize})]),
+	%% Receive the server preface.
+	{ok, << Len0:24 >>} = gen_tcp:recv(Socket, 3, 1000),
+	{ok, << 4:8, 0:40, _:Len0/binary >>} = gen_tcp:recv(Socket, 6 + Len0, 1000),
+	%% Send the SETTINGS ack.
+	ok = gen_tcp:send(Socket, cow_http2:settings_ack()),
+	%% Receive the SETTINGS ack.
+	{ok, << 0:24, 4:8, 1:8, 0:32 >>} = gen_tcp:recv(Socket, 9, 1000),
+	%% Initialize decoding/encoding states.
+	DecodeState = cow_hpack:set_max_size(HeaderTableSize, cow_hpack:init()),
+	EncodeState = cow_hpack:init(),
+	%% Send a HEADERS frame as a request.
+	{ReqHeadersBlock1, _} = cow_hpack:encode([
+		{<<":method">>, <<"GET">>},
+		{<<":scheme">>, <<"http">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<":path">>, <<"/">>}
+	], EncodeState),
+	ok = gen_tcp:send(Socket, cow_http2:headers(1, fin, ReqHeadersBlock1)),
+	%% Receive a HEADERS frame as a response.
+	{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),
+	%% The decoding succeeded, confirming that the table size is
+	%% lower than or equal to HeaderTableSize.
+	ok.
+
+settings_header_table_size_server(Config0) ->
+	doc("The SETTINGS_HEADER_TABLE_SIZE setting can be used to "
+		"inform the client of the maximum header table size "
+		"used by the server to decode header blocks. (RFC7540 6.5.2)"),
+	HeaderTableSize = 128,
+	%% Create a new listener that allows larger header table sizes.
+	Config = cowboy_test:init_http(name(), #{
+		env => #{dispatch => cowboy_router:compile(init_routes(Config0))},
+		max_decode_table_size => HeaderTableSize
+	}, Config0),
+	%% Do the handhsake.
+	{ok, Socket} = gen_tcp:connect("localhost", config(port, Config), [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(#{header_table_size => HeaderTableSize})]),
+	%% Receive the server preface.
+	{ok, << Len0:24 >>} = gen_tcp:recv(Socket, 3, 1000),
+	{ok, Data = <<_:48, _:Len0/binary>>} = gen_tcp:recv(Socket, 6 + Len0, 1000),
+	%% Confirm the server's SETTINGS_HEADERS_TABLE_SIZE uses HeaderTableSize.
+	{ok, {settings, #{header_table_size := HeaderTableSize}}, <<>>}
+		= cow_http2:parse(<<Len0:24, Data/binary>>),
+	%% Send the SETTINGS ack.
+	ok = gen_tcp:send(Socket, cow_http2:settings_ack()),
+	%% Receive the SETTINGS ack.
+	{ok, << 0:24, 4:8, 1:8, 0:32 >>} = gen_tcp:recv(Socket, 9, 1000),
+	%% Initialize decoding/encoding states.
+	DecodeState = cow_hpack:init(),
+	EncodeState = cow_hpack:set_max_size(HeaderTableSize, cow_hpack:init()),
+	%% Send a HEADERS frame as a request.
+	{ReqHeadersBlock1, _} = cow_hpack:encode([
+		{<<":method">>, <<"GET">>},
+		{<<":scheme">>, <<"http">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<":path">>, <<"/">>}
+	], EncodeState),
+	ok = gen_tcp:send(Socket, cow_http2:headers(1, fin, ReqHeadersBlock1)),
+	%% Receive a HEADERS frame as a response.
+	{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),
+	%% The decoding succeeded on the server, confirming that
+	%% the table size was updated to HeaderTableSize.
+	ok.
+
 %   SETTINGS_ENABLE_PUSH (0x2):  This setting can be used to disable
 %      server push (Section 8.2).  An endpoint MUST NOT send a
 %      PUSH_PROMISE frame if it receives this parameter set to a value of