From 87d0bfff926892d2dc0a55a3dc45d8c5f8a682f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hoguin?= Date: Wed, 11 Mar 2020 19:45:16 +0100 Subject: Make Gun use the cookie store when configured to --- doc/src/manual/gun.info.asciidoc | 2 + src/gun.erl | 113 ++++++- src/gun_cookies.erl | 69 +++-- src/gun_cookies_list.erl | 8 + src/gun_http.erl | 109 ++++--- src/gun_http2.erl | 71 +++-- test/gun_test.erl | 4 + test/handlers/cookie_echo_h.erl | 11 + test/handlers/cookie_parser_h.erl | 23 ++ test/handlers/cookie_parser_result_h.erl | 25 ++ test/handlers/cookie_set_h.erl | 36 +++ test/handlers/ws_cookie_h.erl | 24 ++ test/rfc6265bis_SUITE.erl | 499 +++++++++++++++++++++++++++++++ 13 files changed, 895 insertions(+), 99 deletions(-) create mode 100644 test/handlers/cookie_echo_h.erl create mode 100644 test/handlers/cookie_parser_h.erl create mode 100644 test/handlers/cookie_parser_result_h.erl create mode 100644 test/handlers/cookie_set_h.erl create mode 100644 test/handlers/ws_cookie_h.erl create mode 100644 test/rfc6265bis_SUITE.erl diff --git a/doc/src/manual/gun.info.asciidoc b/doc/src/manual/gun.info.asciidoc index edd97f6..8f98942 100644 --- a/doc/src/manual/gun.info.asciidoc +++ b/doc/src/manual/gun.info.asciidoc @@ -6,6 +6,8 @@ gun:info - Obtain information about the connection == Description +// @todo Document the cookie_store key when documenting cookies. + [source,erlang] ---- info(ConnPid) -> Info diff --git a/src/gun.erl b/src/gun.erl index b08057f..7e468f3 100644 --- a/src/gun.erl +++ b/src/gun.erl @@ -65,6 +65,10 @@ -export([connect/3]). -export([connect/4]). +%% Cookies. +%% @todo -export([gc_cookies/1]). +%% @todo -export([session_gc_cookies/1]). + %% Awaiting gun messages. -export([await/2]). -export([await/3]). @@ -123,6 +127,8 @@ -type opts() :: #{ connect_timeout => timeout(), + cookie_ignore_informational => boolean(), + cookie_store => gun_cookies:store(), domain_lookup_timeout => timeout(), event_handler => {module(), any()}, http_opts => http_opts(), @@ -252,7 +258,8 @@ protocol :: module(), protocol_state :: any(), event_handler :: module(), - event_handler_state :: any() + event_handler_state :: any(), + cookie_store :: undefined | {module(), any()} }). %% Connection. @@ -301,6 +308,8 @@ check_options([{connect_timeout, infinity}|Opts]) -> check_options(Opts); check_options([{connect_timeout, T}|Opts]) when is_integer(T), T >= 0 -> check_options(Opts); +check_options([{cookie_store, {Mod, _}}|Opts]) when is_atom(Mod) -> + check_options(Opts); check_options([{domain_lookup_timeout, infinity}|Opts]) -> check_options(Opts); check_options([{domain_lookup_timeout, T}|Opts]) when is_integer(T), T >= 0 -> @@ -412,7 +421,8 @@ info(ServerPid) -> origin_scheme=OriginScheme, origin_host=OriginHost, origin_port=OriginPort, - intermediaries=Intermediaries + intermediaries=Intermediaries, + cookie_store=CookieStore }} = sys:get_state(ServerPid), Info0 = #{ owner => Owner, @@ -425,7 +435,8 @@ info(ServerPid) -> origin_host => OriginHost, origin_port => OriginPort, %% Intermediaries are listed in the order data goes through them. - intermediaries => lists:reverse(Intermediaries) + intermediaries => lists:reverse(Intermediaries), + cookie_store => CookieStore }, Info = case Socket of undefined -> @@ -543,6 +554,8 @@ put(ServerPid, Path, Headers, Body, ReqOpts) -> request(ServerPid, <<"PUT">>, Path, Headers, Body, ReqOpts). %% Generic requests interface. +%% +%% @todo Accept a TargetURI map as well as a normal Path. -spec headers(pid(), iodata(), iodata(), req_headers()) -> reference(). headers(ServerPid, Method, Path, Headers) -> @@ -880,11 +893,13 @@ init({Owner, Host, Port, Opts}) -> origin_port => Port, opts => Opts }, EvHandlerState0), + CookieStore = maps:get(cookie_store, Opts, undefined), State = #state{owner=Owner, status={up, OwnerRef}, host=Host, port=Port, origin_scheme=OriginScheme, origin_host=Host, origin_port=Port, opts=Opts, transport=Transport, messages=Transport:messages(), - event_handler=EvHandler, event_handler_state=EvHandlerState}, + event_handler=EvHandler, event_handler_state=EvHandlerState, + cookie_store=CookieStore}, {ok, domain_lookup, State, {next_event, internal, {retries, Retry, not_connected}}}. @@ -1141,18 +1156,23 @@ connected(internal, {connected, Socket, Protocol0}, false -> {next_state, StateName, State} end; %% Public HTTP interface. -connected(cast, {headers, ReplyTo, StreamRef, Method, Path, Headers, InitialFlow}, - State=#state{origin_host=Host, origin_port=Port, +%% +%% @todo It might be better, internally, to pass around a URIMap +%% containing the target URI, instead of separate Host/Port/PathWithQs. +connected(cast, {headers, ReplyTo, StreamRef, Method, Path, Headers0, InitialFlow}, + State0=#state{origin_host=Host, origin_port=Port, protocol=Protocol, protocol_state=ProtoState, event_handler=EvHandler, event_handler_state=EvHandlerState0}) -> + {Headers, State} = add_cookie_header(Path, Headers0, State0), {ProtoState2, EvHandlerState} = Protocol:headers(ProtoState, StreamRef, ReplyTo, Method, Host, Port, Path, Headers, InitialFlow, EvHandler, EvHandlerState0), {keep_state, State#state{protocol_state=ProtoState2, event_handler_state=EvHandlerState}}; -connected(cast, {request, ReplyTo, StreamRef, Method, Path, Headers, Body, InitialFlow}, - State=#state{origin_host=Host, origin_port=Port, +connected(cast, {request, ReplyTo, StreamRef, Method, Path, Headers0, Body, InitialFlow}, + State0=#state{origin_host=Host, origin_port=Port, protocol=Protocol, protocol_state=ProtoState, event_handler=EvHandler, event_handler_state=EvHandlerState0}) -> + {Headers, State} = add_cookie_header(Path, Headers0, State0), {ProtoState2, EvHandlerState} = Protocol:request(ProtoState, StreamRef, ReplyTo, Method, Host, Port, Path, Headers, Body, InitialFlow, EvHandler, EvHandlerState0), @@ -1167,8 +1187,8 @@ connected(cast, {connect, ReplyTo, StreamRef, Destination, Headers, InitialFlow} connected(cast, {ws_upgrade, ReplyTo, StreamRef, Path, Headers}, State=#state{opts=Opts}) -> WsOpts = maps:get(ws_opts, Opts, #{}), connected(cast, {ws_upgrade, ReplyTo, StreamRef, Path, Headers, WsOpts}, State); -connected(cast, {ws_upgrade, ReplyTo, StreamRef, Path, Headers, WsOpts}, - State=#state{origin_host=Host, origin_port=Port, +connected(cast, {ws_upgrade, ReplyTo, StreamRef, Path, Headers0, WsOpts}, + State0=#state{origin_host=Host, origin_port=Port, protocol=Protocol, protocol_state=ProtoState, event_handler=EvHandler, event_handler_state=EvHandlerState0}) when Protocol =:= gun_http -> @@ -1178,6 +1198,7 @@ connected(cast, {ws_upgrade, ReplyTo, StreamRef, Path, Headers, WsOpts}, opts => WsOpts }, EvHandlerState0), %% @todo Can fail if HTTP/1.0. + {Headers, State} = add_cookie_header(Path, Headers0, State0), {ProtoState2, EvHandlerState} = Protocol:ws_upgrade(ProtoState, StreamRef, ReplyTo, Host, Port, Path, Headers, WsOpts, EvHandler, EvHandlerState1), @@ -1195,6 +1216,35 @@ connected(cast, {ws_send, ReplyTo, _}, _) -> connected(Type, Event, State) -> handle_common_connected(Type, Event, ?FUNCTION_NAME, State). +add_cookie_header(_, Headers, State=#state{cookie_store=undefined}) -> + {Headers, State}; +add_cookie_header(PathWithQs, Headers0, State=#state{ + origin_host=OriginHost, transport=Transport, cookie_store=Store0}) -> + Scheme = case Transport of + gun_tls -> <<"https">>; + gun_tls_proxy -> <<"https">>; + gun_tcp -> <<"http">> + end, + #{path := Path} = uri_string:parse(PathWithQs), + URIMap = uri_string:normalize(#{ + scheme => Scheme, + host => case lists:keyfind(<<"host">>, 1, Headers0) of + false -> iolist_to_binary(OriginHost); %% @todo Probably not enough for atoms and such. + {_, HeaderHost} -> iolist_to_binary(HeaderHost) + end, + path => iolist_to_binary(Path) + }, [return_map]), + {ok, Cookies0, Store} = gun_cookies:query(Store0, URIMap), + Headers = case Cookies0 of + [] -> + Headers0; + _ -> + Cookies = [{Name, Value} || #{name := Name, value := Value} <- Cookies0], + %% We put cookies at the end of the headers list as it's the least important header. + Headers0 ++ [{<<"cookie">>, cow_cookie:cookie(Cookies)}] + end, + {Headers, State#state{cookie_store=Store}}. + %% Switch to the graceful connection close state. closing(State=#state{protocol=Protocol, protocol_state=ProtoState, event_handler=EvHandler, event_handler_state=EvHandlerState0}, Reason) -> @@ -1355,6 +1405,41 @@ commands([{active, Active}|Tail], State) when is_boolean(Active) -> commands(Tail, State#state{active=Active}); commands([{state, ProtoState}|Tail], State) -> commands(Tail, State#state{protocol_state=ProtoState}); +%% Don't set cookies when cookie store isn't configured. +commands([{set_cookie, _, _, _, _}|Tail], State=#state{cookie_store=undefined}) -> + commands(Tail, State); +%% Ignore cookies set on informational responses when configured to do so. +%% This includes cookies set to Websocket upgrade responses! +commands([{set_cookie, _, _, Status, _}|Tail], State=#state{opts=#{cookie_ignore_informational := true}}) + when Status >= 100, Status =< 199 -> + commands(Tail, State); +commands([{set_cookie, Authority, PathWithQs, _, Headers}|Tail], State=#state{ + transport=Transport, cookie_store=Store0}) -> + Scheme = case Transport of + gun_tls -> <<"https">>; + gun_tls_proxy -> <<"https">>; + gun_tcp -> <<"http">> + end, + %% @todo Not sure if this is best done here or in the protocol code or elsewhere. + #{host := Host, path := Path} = uri_string:parse([Scheme, <<"://">>, Authority, PathWithQs]), + URIMap = uri_string:normalize(#{ + scheme => Scheme, + host => iolist_to_binary(Host), + path => iolist_to_binary(Path) + }, [return_map]), + SetCookies = [SC || {<<"set-cookie">>, SC} <- Headers], + Store = lists:foldl(fun(SC, Store1) -> + case cow_cookie:parse_set_cookie(SC) of + {ok, N, V, A} -> + case gun_cookies:set_cookie(Store1, URIMap, N, V, A) of + {ok, Store2} -> Store2; + {error, _} -> Store1 + end; + ignore -> + Store1 + end + end, Store0, SetCookies), + commands(Tail, State#state{cookie_store=Store}); %% Order is important: the origin must be changed before %% the transport and/or protocol in order to keep track %% of the intermediaries properly. @@ -1499,7 +1584,13 @@ owner_down(Shutdown = {shutdown, _}, State) -> {stop, Shutdown, State}; owner_down(Reason, State) -> {stop, {shutdown, {owner_down, Reason}}, State}. terminate(Reason, StateName, #state{event_handler=EvHandler, - event_handler_state=EvHandlerState}) -> + event_handler_state=EvHandlerState, cookie_store=Store}) -> + case Store of + undefined -> ok; + %% Optimization: gun_cookies_list isn't a persistent cookie store. + {gun_cookies_list, _} -> ok; + _ -> gun_cookies:session_gc(Store) + end, TerminateEvent = #{ state => StateName, reason => Reason diff --git a/src/gun_cookies.erl b/src/gun_cookies.erl index 9f377a3..d4c5423 100644 --- a/src/gun_cookies.erl +++ b/src/gun_cookies.erl @@ -21,6 +21,10 @@ -export([session_gc/1]). -export([set_cookie/5]). +-ifdef(TEST). +-export([wpt_http_state_test_files/1]). %% Also used in rfc6265bis_SUITE. +-endif. + -type store_state() :: any(). -type store() :: {module(), store_state()}. @@ -42,7 +46,7 @@ }. -export_type([cookie/0]). --callback init() -> store(). +-callback init(any()) -> store(). -callback query(State, uri_string:uri_map()) -> {ok, [{binary(), binary()}], State} @@ -66,6 +70,14 @@ -> {ok, State} | {error, any()} when State::store_state(). +-callback gc(State) + -> {ok, State} + when State::store_state(). + +-callback session_gc(State) + -> {ok, State} + when State::store_state(). + -spec domain_match(binary(), binary()) -> boolean(). domain_match(String, String) -> true; @@ -387,35 +399,36 @@ wpt_domain_missing_test() -> {ok, [], _} = query(Store, URIMap#{host => <<"sub." ?HOST>>}), ok. -%% WPT: http-state/general-tests -%% -%% The WPT http-state test suite is either broken or complicated to setup. -%% The original http-state test suite is a better reference at the time -%% of writing. The server running these tests is at -%% https://github.com/abarth/http-state/blob/master/tools/testserver/testserver.py +%% WPT: http-state/*-tests +wpt_http_state_test_files() -> + wpt_http_state_test_files("test/"). + +wpt_http_state_test_files(TestPath) -> + filelib:wildcard(TestPath ++ "wpt/cookies/*-test") -- [ + TestPath ++ "wpt/cookies/attribute0023-test", %% Doesn't match the spec (path override). + TestPath ++ "wpt/cookies/chromium0009-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/chromium0010-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/chromium0012-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/disabled-chromium0020-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/disabled-chromium0022-test", %% Nonsense. + TestPath ++ "wpt/cookies/mozilla0012-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/mozilla0014-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/mozilla0015-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/mozilla0016-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/mozilla0017-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/name0017-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/name0023-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/name0025-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/name0028-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/name0031-test", %% Doesn't match the spec (name with quotes). + TestPath ++ "wpt/cookies/name0032-test", %% Doesn't match the spec (name with quotes). + TestPath ++ "wpt/cookies/name0033-test", %% Doesn't match the spec (empty names). + TestPath ++ "wpt/cookies/optional-domain0042-test" %% Doesn't match the spec (empty domain override). + ]. + wpt_http_state_test_() -> URIMap0 = #{scheme => <<"http">>, host => <<"home.example.org">>, path => <<"/cookie-parser">>}, - TestFiles = filelib:wildcard("test/wpt/cookies/*-test") -- [ - "test/wpt/cookies/attribute0023-test", %% Doesn't match the spec (path override). - "test/wpt/cookies/chromium0009-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/chromium0010-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/chromium0012-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/disabled-chromium0020-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/disabled-chromium0022-test", %% Nonsense. - "test/wpt/cookies/mozilla0012-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/mozilla0014-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/mozilla0015-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/mozilla0016-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/mozilla0017-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/name0017-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/name0023-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/name0025-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/name0028-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/name0031-test", %% Doesn't match the spec (name with quotes). - "test/wpt/cookies/name0032-test", %% Doesn't match the spec (name with quotes). - "test/wpt/cookies/name0033-test", %% Doesn't match the spec (empty names). - "test/wpt/cookies/optional-domain0042-test" %% Doesn't match the spec (empty domain override). - ], + TestFiles = wpt_http_state_test_files(), [{F, fun() -> {ok, Test} = file:read_file(F), %% We don't want the final empty line. diff --git a/src/gun_cookies_list.erl b/src/gun_cookies_list.erl index ccd2292..e8cf17a 100644 --- a/src/gun_cookies_list.erl +++ b/src/gun_cookies_list.erl @@ -17,6 +17,7 @@ -module(gun_cookies_list). -export([init/0]). +-export([init/1]). -export([query/2]). -export([set_cookie_secure_match/2]). -export([set_cookie_take_exact_match/2]). @@ -30,8 +31,15 @@ %% @todo max_cookies => non_neg_integer() | infinity }. +-type opts() :: #{ +}. + -spec init() -> {?MODULE, state()}. init() -> + init(#{}). + +-spec init(opts()) -> {?MODULE, state()}. +init(_Opts) -> {?MODULE, #{cookies => []}}. -spec query(State, uri_string:uri_map()) diff --git a/src/gun_http.erl b/src/gun_http.erl index 79124c3..401e23a 100644 --- a/src/gun_http.erl +++ b/src/gun_http.erl @@ -56,6 +56,11 @@ reply_to :: pid(), flow :: integer() | infinity, method :: binary(), + + %% Request target URI. + authority :: iodata(), + path :: iodata(), + is_alive :: boolean(), handler_state :: undefined | gun_content_handler:state() }). @@ -70,7 +75,10 @@ streams = [] :: [#stream{}], in = head :: io(), in_state = {0, 0} :: {non_neg_integer(), non_neg_integer()}, - out = head :: io() + out = head :: io(), + + %% We must queue commands when parsing the incoming data. + commands_queue = [] :: [{set_cookie, iodata(), iodata(), cow_http:status(), cow_http:headers()}] }). check_options(Opts) -> @@ -113,12 +121,20 @@ init(_ReplyTo, Socket, Transport, Opts) -> switch_transport(Transport, Socket, State) -> State#http_state{socket=Socket, transport=Transport}. +%% This function is called before returning from handle/4. +handle_ret(CommandOrCommands, #http_state{commands_queue=[]}) -> + CommandOrCommands; +handle_ret(Commands, #http_state{commands_queue=Queue}) when is_list(Commands) -> + lists:reverse(Queue, Commands); +handle_ret(Command, #http_state{commands_queue=Queue}) -> + lists:reverse([Command|Queue]). + %% Stop looping when we got no more data. handle(<<>>, State, _, EvHandlerState) -> - {{state, State}, EvHandlerState}; + {handle_ret({state, State}, State), EvHandlerState}; %% Close when server responds and we don't have any open streams. -handle(_, #http_state{streams=[]}, _, EvHandlerState) -> - {close, EvHandlerState}; +handle(_, State=#http_state{streams=[]}, _, EvHandlerState) -> + {handle_ret(close, State), EvHandlerState}; %% Wait for the full response headers before trying to parse them. handle(Data, State=#http_state{in=head, buffer=Buffer, streams=[#stream{ref=StreamRef, reply_to=ReplyTo}|_]}, EvHandler, EvHandlerState0) -> @@ -135,12 +151,12 @@ handle(Data, State=#http_state{in=head, buffer=Buffer, end, Data2 = << Buffer/binary, Data/binary >>, case binary:match(Data2, <<"\r\n\r\n">>) of - nomatch -> {{state, State#http_state{buffer=Data2}}, EvHandlerState}; + nomatch -> {handle_ret({state, State#http_state{buffer=Data2}}, State), EvHandlerState}; {_, _} -> handle_head(Data2, State#http_state{buffer= <<>>}, EvHandler, EvHandlerState) end; %% Everything sent to the socket until it closes is part of the response body. handle(Data, State=#http_state{in=body_close}, _, EvHandlerState) -> - {send_data(Data, State, nofin), EvHandlerState}; + {handle_ret(send_data(Data, State, nofin), State), EvHandlerState}; %% Chunked transfer-encoding may contain both data and trailers. handle(Data, State=#http_state{in=body_chunked, in_state=InState, buffer=Buffer, streams=[#stream{ref=StreamRef, reply_to=ReplyTo}|_], @@ -148,15 +164,18 @@ handle(Data, State=#http_state{in=body_chunked, in_state=InState, Buffer2 = << Buffer/binary, Data/binary >>, case cow_http_te:stream_chunked(Buffer2, InState) of more -> - {{state, State#http_state{buffer=Buffer2}}, EvHandlerState0}; + {handle_ret({state, State#http_state{buffer=Buffer2}}, State), EvHandlerState0}; {more, Data2, InState2} -> - {send_data(Data2, State#http_state{buffer= <<>>, in_state=InState2}, nofin), EvHandlerState0}; + {handle_ret(send_data(Data2, State#http_state{buffer= <<>>, in_state=InState2}, nofin), State), + EvHandlerState0}; {more, Data2, Length, InState2} when is_integer(Length) -> %% @todo See if we can recv faster than one message at a time. - {send_data(Data2, State#http_state{buffer= <<>>, in_state=InState2}, nofin), EvHandlerState0}; + {handle_ret(send_data(Data2, State#http_state{buffer= <<>>, in_state=InState2}, nofin), State), + EvHandlerState0}; {more, Data2, Rest, InState2} -> %% @todo See if we can recv faster than one message at a time. - {send_data(Data2, State#http_state{buffer=Rest, in_state=InState2}, nofin), EvHandlerState0}; + {handle_ret(send_data(Data2, State#http_state{buffer=Rest, in_state=InState2}, nofin), State), + EvHandlerState0}; {done, HasTrailers, Rest} -> %% @todo response_end should be called AFTER send_data {IsFin, EvHandlerState} = case HasTrailers of @@ -178,7 +197,7 @@ handle(Data, State=#http_state{in=body_chunked, in_state=InState, {no_trailers, keepalive} -> handle(Rest, end_stream(State1#http_state{buffer= <<>>}), EvHandler, EvHandlerState); {no_trailers, close} -> - {[{state, end_stream(State1)}, close], EvHandlerState} + {handle_ret([{state, end_stream(State1)}, close], State1), EvHandlerState} end; {done, Data2, HasTrailers, Rest} -> %% @todo response_end should be called AFTER send_data @@ -200,7 +219,7 @@ handle(Data, State=#http_state{in=body_chunked, in_state=InState, {no_trailers, keepalive} -> handle(Rest, end_stream(State1#http_state{buffer= <<>>}), EvHandler, EvHandlerState); {no_trailers, close} -> - {[{state, end_stream(State1)}, close], EvHandlerState} + {handle_ret([{state, end_stream(State1)}, close], State1), EvHandlerState} end end; handle(Data, State=#http_state{in=body_trailer, buffer=Buffer, connection=Conn, @@ -208,7 +227,7 @@ handle(Data, State=#http_state{in=body_trailer, buffer=Buffer, connection=Conn, Data2 = << Buffer/binary, Data/binary >>, case binary:match(Data2, <<"\r\n\r\n">>) of nomatch -> - {{state, State#http_state{buffer=Data2}}, EvHandlerState0}; + {handle_ret({state, State#http_state{buffer=Data2}}, State), EvHandlerState0}; {_, _} -> {Trailers, Rest} = cow_http:parse_headers(Data2), %% @todo We probably want to pass this to gun_content_handler? @@ -223,7 +242,7 @@ handle(Data, State=#http_state{in=body_trailer, buffer=Buffer, connection=Conn, keepalive -> handle(Rest, end_stream(State#http_state{buffer= <<>>}), EvHandler, EvHandlerState); close -> - {[{state, end_stream(State)}, close], EvHandlerState} + {handle_ret([{state, end_stream(State)}, close], State), EvHandlerState} end end; %% We know the length of the rest of the body. @@ -234,7 +253,8 @@ handle(Data, State=#http_state{in={body, Length}, connection=Conn, if %% More data coming. DataSize < Length -> - {send_data(Data, State#http_state{in={body, Length - DataSize}}, nofin), EvHandlerState0}; + {handle_ret(send_data(Data, State#http_state{in={body, Length - DataSize}}, nofin), State), + EvHandlerState0}; %% Stream finished, no rest. DataSize =:= Length -> %% We ignore the active command because the stream ended. @@ -244,8 +264,10 @@ handle(Data, State=#http_state{in={body, Length}, connection=Conn, reply_to => ReplyTo }, EvHandlerState0), case Conn of - keepalive -> {[{state, end_stream(State1)}, {active, true}], EvHandlerState}; - close -> {[{state, end_stream(State1)}, close], EvHandlerState} + keepalive -> + {handle_ret([{state, end_stream(State1)}, {active, true}], State1), EvHandlerState}; + close -> + {handle_ret([{state, end_stream(State1)}, close], State1), EvHandlerState} end; %% Stream finished, rest. true -> @@ -258,14 +280,15 @@ handle(Data, State=#http_state{in={body, Length}, connection=Conn, }, EvHandlerState0), case Conn of keepalive -> handle(Rest, end_stream(State1), EvHandler, EvHandlerState); - close -> {[{state, end_stream(State1)}, close], EvHandlerState} + close -> {handle_ret([{state, end_stream(State1)}, close], State1), EvHandlerState} end end. -handle_head(Data, State=#http_state{streams=[#stream{ref=StreamRef}|_]}, - EvHandler, EvHandlerState) -> +handle_head(Data, State0=#http_state{streams=[#stream{ref=StreamRef, authority=Authority, path=Path}|_], + commands_queue=Commands}, EvHandler, EvHandlerState) -> {Version, Status, _, Rest0} = cow_http:parse_status_line(Data), {Headers, Rest} = cow_http:parse_headers(Rest0), + State = State0#http_state{commands_queue=[{set_cookie, Authority, Path, Status, Headers}|Commands]}, case StreamRef of {connect, _, _} when Status >= 200, Status < 300 -> handle_connect(Rest, State, EvHandler, EvHandlerState, Version, Status, Headers); @@ -305,12 +328,16 @@ handle_connect(Rest, State=#http_state{ timeout => maps:get(tls_handshake_timeout, Destination, infinity) }, Protocols = maps:get(protocols, Destination, [http2, http]), - {[{origin, <<"https">>, NewHost, NewPort, connect}, - {tls_handshake, HandshakeEvent, Protocols, ReplyTo}], EvHandlerState1}; + {handle_ret([ + {origin, <<"https">>, NewHost, NewPort, connect}, + {tls_handshake, HandshakeEvent, Protocols, ReplyTo} + ], State), EvHandlerState1}; _ -> [Protocol] = maps:get(protocols, Destination, [http]), - {[{origin, <<"http">>, NewHost, NewPort, connect}, - {switch_protocol, Protocol, ReplyTo}], EvHandlerState1} + {handle_ret([ + {origin, <<"http">>, NewHost, NewPort, connect}, + {switch_protocol, Protocol, ReplyTo} + ], State), EvHandlerState1} end. %% @todo We probably shouldn't send info messages if the stream is not alive. @@ -326,7 +353,7 @@ handle_inform(Rest, State=#http_state{ %% @todo We might want to switch to the HTTP/2 protocol or to the TLS transport as well. case {Version, Status, StreamRef} of {'HTTP/1.1', 101, #websocket{}} -> - {ws_handshake(Rest, State, StreamRef, Headers), EvHandlerState}; + {handle_ret(ws_handshake(Rest, State, StreamRef, Headers), State), EvHandlerState}; %% Any other 101 response results in us switching to the raw protocol. %% @todo We should check that we asked for an upgrade before accepting it. {'HTTP/1.1', 101, _} when is_reference(StreamRef) -> @@ -335,7 +362,7 @@ handle_inform(Rest, State=#http_state{ {_, Upgrade0} = lists:keyfind(<<"upgrade">>, 1, Headers), Upgrade = cow_http_hd:parse_upgrade(Upgrade0), ReplyTo ! {gun_upgrade, self(), StreamRef, Upgrade, Headers}, - {{switch_protocol, raw, ReplyTo}, EvHandlerState0} + {handle_ret({switch_protocol, raw, ReplyTo}, State), EvHandlerState0} catch _:_ -> %% When the Upgrade header is missing or invalid we treat %% the response as any other informational response. @@ -391,7 +418,7 @@ handle_response(Rest, State=#http_state{version=ClientVersion, opts=Opts, connec %% We always reset in_state even if not chunked. if IsFin =:= fin, Conn2 =:= close -> - {close, EvHandlerState}; + {handle_ret(close, State), EvHandlerState}; IsFin =:= fin -> handle(Rest, end_stream(State#http_state{in=In, in_state={0, 0}, connection=Conn2, @@ -501,22 +528,22 @@ keepalive(State, _, EvHandlerState) -> headers(State=#http_state{opts=Opts, out=head}, StreamRef, ReplyTo, Method, Host, Port, Path, Headers, InitialFlow0, EvHandler, EvHandlerState0) -> - {Conn, Out, EvHandlerState} = send_request(State, StreamRef, ReplyTo, + {Authority, Conn, Out, EvHandlerState} = send_request(State, StreamRef, ReplyTo, Method, Host, Port, Path, Headers, undefined, EvHandler, EvHandlerState0, ?FUNCTION_NAME), InitialFlow = initial_flow(InitialFlow0, Opts), - {new_stream(State#http_state{connection=Conn, out=Out}, StreamRef, ReplyTo, Method, InitialFlow), - EvHandlerState}. + {new_stream(State#http_state{connection=Conn, out=Out}, StreamRef, ReplyTo, + Method, Authority, Path, InitialFlow), EvHandlerState}. request(State=#http_state{opts=Opts, out=head}, StreamRef, ReplyTo, Method, Host, Port, Path, Headers, Body, InitialFlow0, EvHandler, EvHandlerState0) -> - {Conn, Out, EvHandlerState} = send_request(State, StreamRef, ReplyTo, + {Authority, Conn, Out, EvHandlerState} = send_request(State, StreamRef, ReplyTo, Method, Host, Port, Path, Headers, Body, EvHandler, EvHandlerState0, ?FUNCTION_NAME), InitialFlow = initial_flow(InitialFlow0, Opts), - {new_stream(State#http_state{connection=Conn, out=Out}, StreamRef, ReplyTo, Method, InitialFlow), - EvHandlerState}. + {new_stream(State#http_state{connection=Conn, out=Out}, StreamRef, ReplyTo, + Method, Authority, Path, InitialFlow), EvHandlerState}. initial_flow(infinity, #{flow := InitialFlow}) -> InitialFlow; initial_flow(InitialFlow, _) -> InitialFlow. @@ -536,6 +563,7 @@ send_request(State=#http_state{socket=Socket, transport=Transport, version=Versi undefined -> request_io_from_headers(Headers2); _ -> head end, + %% @todo Move this inside the case clause. Authority0 = host_header(Transport, Host, Port), {Authority, Headers3} = case lists:keyfind(<<"host">>, 1, Headers2) of false -> {Authority0, [{<<"host">>, Authority0}|Headers2]}; @@ -572,7 +600,7 @@ send_request(State=#http_state{socket=Socket, transport=Transport, version=Versi _ -> EvHandlerState2 end, - {Conn, Out, EvHandlerState}. + {Authority, Conn, Out, EvHandlerState}. host_header(Transport, Host0, Port) -> Host = case Host0 of @@ -681,7 +709,8 @@ connect(State=#http_state{socket=Socket, transport=Transport, opts=Opts, version cow_http:request(<<"CONNECT">>, Authority, Version, Headers) ]), InitialFlow = initial_flow(InitialFlow0, Opts), - new_stream(State, {connect, StreamRef, Destination}, ReplyTo, <<"CONNECT">>, InitialFlow). + new_stream(State, {connect, StreamRef, Destination}, ReplyTo, + <<"CONNECT">>, Authority, <<>>, InitialFlow). %% We can't cancel anything, we can just stop forwarding messages to the owner. cancel(State0, StreamRef, ReplyTo, EvHandler, EvHandlerState0) -> @@ -780,10 +809,12 @@ response_io_from_headers(_, Version, _Status, Headers) -> %% Streams. -new_stream(State=#http_state{streams=Streams}, StreamRef, ReplyTo, Method, InitialFlow) -> +new_stream(State=#http_state{streams=Streams}, StreamRef, ReplyTo, + Method, Authority, Path, InitialFlow) -> State#http_state{streams=Streams ++ [#stream{ref=StreamRef, reply_to=ReplyTo, flow=InitialFlow, - method=iolist_to_binary(Method), is_alive=true}]}. + method=iolist_to_binary(Method), authority=Authority, + path=iolist_to_binary(Path), is_alive=true}]}. is_stream(#http_state{streams=Streams}, StreamRef) -> lists:keymember(StreamRef, #stream.ref, Streams). @@ -830,13 +861,13 @@ ws_upgrade(State=#http_state{out=head}, StreamRef, ReplyTo, {<<"sec-websocket-key">>, Key} |Headers2 ], - {Conn, Out, EvHandlerState} = send_request(State, StreamRef, ReplyTo, + {Authority, Conn, Out, EvHandlerState} = send_request(State, StreamRef, ReplyTo, <<"GET">>, Host, Port, Path, Headers, undefined, EvHandler, EvHandlerState0, ?FUNCTION_NAME), InitialFlow = maps:get(flow, WsOpts, infinity), {new_stream(State#http_state{connection=Conn, out=Out}, #websocket{ref=StreamRef, reply_to=ReplyTo, key=Key, extensions=GunExtensions, opts=WsOpts}, - ReplyTo, <<"GET">>, InitialFlow), EvHandlerState}. + ReplyTo, <<"GET">>, Authority, Path, InitialFlow), EvHandlerState}. ws_handshake(Buffer, State, Ws=#websocket{key=Key}, Headers) -> %% @todo check upgrade, connection diff --git a/src/gun_http2.erl b/src/gun_http2.erl index 76ba75c..9edabaa 100644 --- a/src/gun_http2.erl +++ b/src/gun_http2.erl @@ -46,6 +46,10 @@ %% Flow control. flow :: integer() | infinity, + %% Request target URI. + authority :: iodata(), + path :: iodata(), + %% Content handlers state. handler_state :: undefined | gun_content_handler:state() }). @@ -72,7 +76,10 @@ %% the idea, that's why the main map has the ID as key. Then we also %% have a Ref->ID index for faster lookup when we only have the Ref. streams = #{} :: #{cow_http2:streamid() => #stream{}}, - stream_refs = #{} :: #{reference() => cow_http2:streamid()} + stream_refs = #{} :: #{reference() => cow_http2:streamid()}, + + %% We must queue commands when parsing the incoming data. + commands_queue = [] :: [{set_cookie, iodata(), iodata(), cow_http:status(), cow_http:headers()}] }). check_options(Opts) -> @@ -144,6 +151,14 @@ init(_ReplyTo, Socket, Transport, Opts0) -> switch_transport(Transport, Socket, State) -> State#http2_state{socket=Socket, transport=Transport}. +%% This function is called before returning from handle/4. +handle_ret(CommandOrCommands, #http2_state{commands_queue=[]}) -> + CommandOrCommands; +handle_ret(Commands, #http2_state{commands_queue=Queue}) when is_list(Commands) -> + lists:reverse(Queue, Commands); +handle_ret(Command, #http2_state{commands_queue=Queue}) -> + lists:reverse([Command|Queue]). + handle(Data, State=#http2_state{buffer=Buffer}, EvHandler, EvHandlerState) -> parse(<< Buffer/binary, Data/binary >>, State#http2_state{buffer= <<>>}, EvHandler, EvHandlerState). @@ -154,11 +169,11 @@ parse(Data, State0=#http2_state{status=preface, http2_machine=HTTP2Machine}, case cow_http2:parse(Data, MaxFrameSize) of {ok, Frame, Rest} when element(1, Frame) =:= settings -> case frame(State0#http2_state{status=connected}, Frame, EvHandler, EvHandlerState0) of - Error = {{error, _}, _} -> Error; + {Error={error, _}, EvHandlerState} -> {handle_ret(Error, State0), EvHandlerState}; {State, EvHandlerState} -> parse(Rest, State, EvHandler, EvHandlerState) end; more -> - {{state, State0#http2_state{buffer=Data}}, EvHandlerState0}; + {handle_ret({state, State0#http2_state{buffer=Data}}, State0), EvHandlerState0}; %% Any error in the preface is converted to this specific error %% to make debugging the problem easier (it's the server's fault). _ -> @@ -168,7 +183,8 @@ parse(Data, State0=#http2_state{status=preface, http2_machine=HTTP2Machine}, _ -> 'Invalid connection preface received. (RFC7540 3.5)' end, - {connection_error(State0, {connection_error, protocol_error, Reason}), EvHandlerState0} + {handle_ret(connection_error(State0, {connection_error, protocol_error, Reason}), State0), + EvHandlerState0} end; parse(Data, State0=#http2_state{status=Status, http2_machine=HTTP2Machine, streams=Streams}, EvHandler, EvHandlerState0) -> @@ -176,28 +192,30 @@ parse(Data, State0=#http2_state{status=Status, http2_machine=HTTP2Machine, strea case cow_http2:parse(Data, MaxFrameSize) of {ok, Frame, Rest} -> case frame(State0, Frame, EvHandler, EvHandlerState0) of - Error = {{error, _}, _} -> Error; + {Error={error, _}, EvHandlerState} -> {handle_ret(Error, State0), EvHandlerState}; {State, EvHandlerState} -> parse(Rest, State, EvHandler, EvHandlerState) end; {ignore, Rest} -> case ignored_frame(State0) of - Error = {error, _} -> {Error, EvHandlerState0}; + Error = {error, _} -> {handle_ret(Error, State0), EvHandlerState0}; State -> parse(Rest, State, EvHandler, EvHandlerState0) end; {stream_error, StreamID, Reason, Human, Rest} -> parse(Rest, reset_stream(State0, StreamID, {stream_error, Reason, Human}), EvHandler, EvHandlerState0); Error = {connection_error, _, _} -> - {connection_error(State0, Error), EvHandlerState0}; + {handle_ret(connection_error(State0, Error), State0), EvHandlerState0}; %% If we both received and sent a GOAWAY frame and there are no streams %% currently running, we can close the connection immediately. more when Status =/= connected, Streams =:= #{} -> - {[{state, State0#http2_state{buffer=Data, status=closing}}, close], EvHandlerState0}; + {handle_ret([{state, State0#http2_state{buffer=Data, status=closing}}, close], State0), + EvHandlerState0}; %% Otherwise we enter the closing state. more when Status =:= goaway -> - {[{state, State0#http2_state{buffer=Data, status=closing}}, closing(State0)], EvHandlerState0}; + {handle_ret([{state, State0#http2_state{buffer=Data, status=closing}}, closing(State0)], State0), + EvHandlerState0}; more -> - {{state, State0#http2_state{buffer=Data}}, EvHandlerState0} + {handle_ret({state, State0#http2_state{buffer=Data}}, State0), EvHandlerState0} end. %% Frames received. @@ -308,12 +326,19 @@ data_frame(State0, StreamID, IsFin, Data, EvHandler, EvHandlerState0) -> end, {maybe_delete_stream(State, StreamID, remote, IsFin), EvHandlerState}. -headers_frame(State=#http2_state{content_handlers=Handlers0}, - StreamID, IsFin, Headers, PseudoHeaders, _BodyLen, +headers_frame(State0=#http2_state{content_handlers=Handlers0, commands_queue=Commands}, + StreamID, IsFin, Headers, #{status := Status}, _BodyLen, EvHandler, EvHandlerState0) -> - Stream = #stream{ref=StreamRef, reply_to=ReplyTo} = get_stream_by_id(State, StreamID), - case PseudoHeaders of - #{status := Status} when Status >= 100, Status =< 199 -> + Stream = get_stream_by_id(State0, StreamID), + #stream{ + ref=StreamRef, + reply_to=ReplyTo, + authority=Authority, + path=Path + } = Stream, + State = State0#http2_state{commands_queue=[{set_cookie, Authority, Path, Status, Headers}|Commands]}, + if + Status >= 100, Status =< 199 -> ReplyTo ! {gun_inform, self(), StreamRef, Status, Headers}, EvHandlerState = EvHandler:response_inform(#{ stream_ref => StreamRef, @@ -322,7 +347,7 @@ headers_frame(State=#http2_state{content_handlers=Handlers0}, headers => Headers }, EvHandlerState0), {State, EvHandlerState}; - #{status := Status} -> + true -> ReplyTo ! {gun_response, self(), StreamRef, IsFin, Status, Headers}, EvHandlerState1 = EvHandler:response_headers(#{ stream_ref => StreamRef, @@ -402,7 +427,7 @@ push_promise_frame(State=#http2_state{socket=Socket, transport=Transport, case Status of connected -> NewStream = #stream{id=PromisedStreamID, ref=PromisedStreamRef, - reply_to=ReplyTo, flow=InitialFlow}, + reply_to=ReplyTo, flow=InitialFlow, authority=Authority, path=Path}, {create_stream(State, NewStream), EvHandlerState}; %% We cancel the push_promise immediately when we are shutting down. _ -> @@ -545,12 +570,13 @@ headers(State=#http2_state{socket=Socket, transport=Transport, opts=Opts, {ok, StreamID, HTTP2Machine1} = cow_http2_machine:init_stream( iolist_to_binary(Method), HTTP2Machine0), {ok, PseudoHeaders, Headers} = prepare_headers(State, Method, Host, Port, Path, Headers0), + Authority = maps:get(authority, PseudoHeaders), RequestEvent = #{ stream_ref => StreamRef, reply_to => ReplyTo, function => ?FUNCTION_NAME, method => Method, - authority => maps:get(authority, PseudoHeaders), + authority => Authority, path => Path, headers => Headers }, @@ -560,7 +586,8 @@ headers(State=#http2_state{socket=Socket, transport=Transport, opts=Opts, Transport:send(Socket, cow_http2:headers(StreamID, IsFin, HeaderBlock)), EvHandlerState = EvHandler:request_headers(RequestEvent, EvHandlerState1), InitialFlow = initial_flow(InitialFlow0, Opts), - Stream = #stream{id=StreamID, ref=StreamRef, reply_to=ReplyTo, flow=InitialFlow}, + Stream = #stream{id=StreamID, ref=StreamRef, reply_to=ReplyTo, flow=InitialFlow, + authority=Authority, path=Path}, {create_stream(State#http2_state{http2_machine=HTTP2Machine}, Stream), EvHandlerState}. request(State0=#http2_state{socket=Socket, transport=Transport, opts=Opts, @@ -571,12 +598,13 @@ request(State0=#http2_state{socket=Socket, transport=Transport, opts=Opts, {ok, StreamID, HTTP2Machine1} = cow_http2_machine:init_stream( iolist_to_binary(Method), HTTP2Machine0), {ok, PseudoHeaders, Headers} = prepare_headers(State0, Method, Host, Port, Path, Headers1), + Authority = maps:get(authority, PseudoHeaders), RequestEvent = #{ stream_ref => StreamRef, reply_to => ReplyTo, function => ?FUNCTION_NAME, method => Method, - authority => maps:get(authority, PseudoHeaders), + authority => Authority, path => Path, headers => Headers }, @@ -590,7 +618,8 @@ request(State0=#http2_state{socket=Socket, transport=Transport, opts=Opts, Transport:send(Socket, cow_http2:headers(StreamID, IsFin, HeaderBlock)), EvHandlerState = EvHandler:request_headers(RequestEvent, EvHandlerState1), InitialFlow = initial_flow(InitialFlow0, Opts), - Stream = #stream{id=StreamID, ref=StreamRef, reply_to=ReplyTo, flow=InitialFlow}, + Stream = #stream{id=StreamID, ref=StreamRef, reply_to=ReplyTo, flow=InitialFlow, + authority=Authority, path=Path}, State = create_stream(State0#http2_state{http2_machine=HTTP2Machine}, Stream), case IsFin of fin -> diff --git a/test/gun_test.erl b/test/gun_test.erl index a263335..bb162f4 100644 --- a/test/gun_test.erl +++ b/test/gun_test.erl @@ -22,6 +22,10 @@ %% Cowboy listeners. +init_cowboy_tcp(Ref, ProtoOpts, Config) -> + {ok, _} = cowboy:start_clear(Ref, [{port, 0}], ProtoOpts), + [{ref, Ref}, {port, ranch:get_port(Ref)}|Config]. + init_cowboy_tls(Ref, ProtoOpts, Config) -> Opts = ct_helper:get_certs_from_ets(), {ok, _} = cowboy:start_tls(Ref, Opts ++ [{port, 0}], ProtoOpts), diff --git a/test/handlers/cookie_echo_h.erl b/test/handlers/cookie_echo_h.erl new file mode 100644 index 0000000..28c5dde --- /dev/null +++ b/test/handlers/cookie_echo_h.erl @@ -0,0 +1,11 @@ +%% Feel free to use, reuse and abuse the code in this file. + +-module(cookie_echo_h). + +-export([init/2]). + +init(Req, State) -> + {ok, cowboy_req:reply(200, + #{<<"content-type">> => <<"text/plain">>}, + cowboy_req:header(<<"cookie">>, Req, <<"UNDEF">>), + Req), State}. diff --git a/test/handlers/cookie_parser_h.erl b/test/handlers/cookie_parser_h.erl new file mode 100644 index 0000000..cff5901 --- /dev/null +++ b/test/handlers/cookie_parser_h.erl @@ -0,0 +1,23 @@ +%% Feel free to use, reuse and abuse the code in this file. + +-module(cookie_parser_h). + +-export([init/2]). + +init(Req0=#{qs := Qs}, State) -> + %% Hardcoded path, but I doubt it's going to break anytime soon. + TestFile = iolist_to_binary(["../../test/wpt/cookies/", Qs, "-test"]), + {ok, Test} = file:read_file(TestFile), + %% We don't want the final empty line. + Lines = lists:reverse(tl(lists:reverse(string:split(Test, <<"\n">>, all)))), + Req = lists:foldl(fun + (<<"Set-Cookie: ",SetCookie/bits>>, Req1) -> + %% We do not use set_resp_cookie because we want to preserve ordering. + SetCookieList = cowboy_req:resp_header(<<"set-cookie">>, Req1, []), + cowboy_req:set_resp_header(<<"set-cookie">>, SetCookieList ++ [SetCookie], Req1); + (<<"Set-Cookie:">>, Req1) -> + Req1; + (<<"Location: ",Location/bits>>, Req1) -> + cowboy_req:set_resp_header(<<"location">>, Location, Req1) + end, Req0, Lines), + {ok, cowboy_req:reply(204, Req), State}. diff --git a/test/handlers/cookie_parser_result_h.erl b/test/handlers/cookie_parser_result_h.erl new file mode 100644 index 0000000..a1fa899 --- /dev/null +++ b/test/handlers/cookie_parser_result_h.erl @@ -0,0 +1,25 @@ +%% Feel free to use, reuse and abuse the code in this file. + +-module(cookie_parser_result_h). + +-export([init/2]). + +init(Req=#{qs := Qs}, State) -> + %% Hardcoded path, but I doubt it's going to break anytime soon. + ExpectedFile = iolist_to_binary(["../../test/wpt/cookies/", Qs, "-expected"]), + CookieHd = cowboy_req:header(<<"cookie">>, Req), + case file:read_file(ExpectedFile) of + {ok, Expected} when Expected =:= <<>>; Expected =:= <<"\n">> -> + undefined = CookieHd, + ok; + {ok, <<"Cookie: ",CookiesBin0/bits>>} -> + %% We only care about the first line. + [CookiesBin, <<>>|_] = string:split(CookiesBin0, <<"\n">>, all), + CookiesBin = CookieHd, + ok + end, + %% We echo back the cookie header in order to log it. + {ok, cowboy_req:reply(204, case CookieHd of + undefined -> #{<<"x-no-cookie-received">> => <<"Cookie header missing.">>}; + _ -> #{<<"x-cookie-received">> => CookieHd} + end, Req), State}. diff --git a/test/handlers/cookie_set_h.erl b/test/handlers/cookie_set_h.erl new file mode 100644 index 0000000..29ff351 --- /dev/null +++ b/test/handlers/cookie_set_h.erl @@ -0,0 +1,36 @@ +%% Feel free to use, reuse and abuse the code in this file. + +-module(cookie_set_h). + +-export([init/2]). + +init(Req0, State) -> + SetCookieList = set_cookie_list(Req0), + Req = cowboy_req:set_resp_header(<<"set-cookie">>, SetCookieList, Req0), + {ok, cowboy_req:reply(204, Req), State}. + +-define(HOST, "web-platform.test"). + +set_cookie_list(#{qs := <<"domain_with_and_without_leading_period">>}) -> + [ + <<"a=b; Path=/; Domain=." ?HOST>>, + <<"a=c; Path=/; Domain=" ?HOST>> + ]; +set_cookie_list(#{qs := <<"domain_with_leading_period">>}) -> + [<<"a=b; Path=/; Domain=." ?HOST>>]; +set_cookie_list(#{qs := <<"domain_matches_host">>}) -> + [<<"a=b; Path=/; Domain=" ?HOST>>]; +set_cookie_list(#{qs := <<"domain_missing">>}) -> + [<<"a=b; Path=/;">>]; +set_cookie_list(#{qs := <<"path_default">>}) -> + [<<"cookie-path-default=1">>]; +set_cookie_list(#{qs := <<"path_default_expire">>}) -> + [<<"cookie-path-default=1; Max-Age=0">>]; +set_cookie_list(#{qs := <<"path=",Path/bits>>}) -> + [[<<"a=b; Path=">>, Path]]; +set_cookie_list(Req=#{qs := <<"prefix">>}) -> + [cowboy_req:header(<<"please-set-cookie">>, Req)]; +set_cookie_list(#{qs := <<"secure_http">>}) -> + [<<"secure_from_nonsecure_http=1; Secure; Path=/">>]; +set_cookie_list(#{qs := <<"secure_https">>}) -> + [<<"secure_from_secure_http=1; Secure; Path=/">>]. diff --git a/test/handlers/ws_cookie_h.erl b/test/handlers/ws_cookie_h.erl new file mode 100644 index 0000000..39889b3 --- /dev/null +++ b/test/handlers/ws_cookie_h.erl @@ -0,0 +1,24 @@ +%% Feel free to use, reuse and abuse the code in this file. + +-module(ws_cookie_h). + +-export([init/2]). +-export([websocket_handle/2]). +-export([websocket_info/2]). + +init(Req0, _) -> + Req = cowboy_req:set_resp_header(<<"set-cookie">>, + [<<"ws_cookie=1; Secure; path=/">>], Req0), + {cowboy_websocket, Req, undefined, #{ + compress => true + }}. + +websocket_handle({text, Data}, State) -> + {[{text, Data}], State}; +websocket_handle({binary, Data}, State) -> + {[{binary, Data}], State}; +websocket_handle(_Frame, State) -> + {[], State}. + +websocket_info(_Info, State) -> + {[], State}. diff --git a/test/rfc6265bis_SUITE.erl b/test/rfc6265bis_SUITE.erl new file mode 100644 index 0000000..ce24c40 --- /dev/null +++ b/test/rfc6265bis_SUITE.erl @@ -0,0 +1,499 @@ +%% Copyright (c) 2020, Loïc Hoguin +%% +%% 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(rfc6265bis_SUITE). +-compile(export_all). +-compile(nowarn_export_all). + +-import(ct_helper, [config/2]). +-import(ct_helper, [doc/1]). + +%% ct. + +all() -> + [ + {group, http}, + {group, https}, + {group, h2c}, + {group, h2} + ]. + +groups() -> + CommonTests = ct_helper:all(?MODULE) -- [wpt_http_state], + NumFiles = length(get_test_files()), + NumDisabledTlsFiles = length(get_disabled_tls_test_files()), + [ + {http, [parallel], CommonTests + ++ [{testcase, wpt_http_state, [{repeat, NumFiles}]}]}, + {https, [parallel], CommonTests + ++ [{testcase, wpt_http_state, [{repeat, NumFiles - NumDisabledTlsFiles}]}]}, + %% Websocket over HTTP/2 is currently not supported. + {h2c, [parallel], (CommonTests -- [wpt_secure_ws]) + ++ [{testcase, wpt_http_state, [{repeat, NumFiles}]}]}, + {h2, [parallel], (CommonTests -- [wpt_secure_ws]) + ++ [{testcase, wpt_http_state, [{repeat, NumFiles - NumDisabledTlsFiles}]}]} + ]. + +init_per_group(Ref, Config0) when Ref =:= http; Ref =:= h2c -> + Protocol = case Ref of + http -> http; + h2c -> http2 + end, + Config = gun_test:init_cowboy_tcp(Ref, #{ + env => #{dispatch => cowboy_router:compile(init_routes())} + }, Config0), + init_per_group_common([{transport, tcp}, {protocol, Protocol}|Config]); +init_per_group(Ref, Config0) when Ref =:= https; Ref =:= h2 -> + Protocol = case Ref of + https -> http; + h2 -> http2 + end, + Config = gun_test:init_cowboy_tls(Ref, #{ + env => #{dispatch => cowboy_router:compile(init_routes())} + }, Config0), + init_per_group_common([{transport, tls}, {protocol, Protocol}|Config]). + +init_per_group_common(Config = [{transport, Transport}|_]) -> + GiverPid = spawn(fun() -> do_test_giver_init(Transport) end), + [{test_giver_pid, GiverPid}|Config]. + +end_per_group(Ref, _) -> + cowboy:stop_listener(Ref). + +init_routes() -> [ + {'_', [ + {"/cookie-echo/[...]", cookie_echo_h, []}, + {"/cookie-parser/[...]", cookie_parser_h, []}, + {"/cookie-parser-result/[...]", cookie_parser_result_h, []}, + {"/cookie-set/[...]", cookie_set_h, []}, + {"/cookies/resources/echo-cookie.html", cookie_echo_h, []}, + {"/cookies/resources/set-cookie.html", cookie_set_h, []}, + {<<"/cookies/resources/echo.py">>, cookie_echo_h, []}, + {<<"/cookies/resources/set.py">>, cookie_set_h, []}, + {<<"/ws">>, ws_cookie_h, []} + ]} +]. + +%% Test files. + +get_test_files() -> + %% Hardcoded path, but I doubt it's going to break anytime soon. + gun_cookies:wpt_http_state_test_files("../../test/"). + +get_disabled_tls_test_files() -> + %% These tests include the Secure attribute and are written for + %% clear text. They must therefore be disabled over TLS. + [ + "../../test/wpt/cookies/0010-test", + "../../test/wpt/cookies/attribute0001-test", + "../../test/wpt/cookies/attribute0002-test", + "../../test/wpt/cookies/attribute0004-test", + "../../test/wpt/cookies/attribute0005-test", + "../../test/wpt/cookies/attribute0007-test", + "../../test/wpt/cookies/attribute0008-test", + "../../test/wpt/cookies/attribute0009-test", + "../../test/wpt/cookies/attribute0010-test", + "../../test/wpt/cookies/attribute0011-test", + "../../test/wpt/cookies/attribute0012-test", + "../../test/wpt/cookies/attribute0013-test", + "../../test/wpt/cookies/attribute0025-test", + "../../test/wpt/cookies/attribute0026-test" + ]. + +do_test_giver_init(Transport) -> + TestFiles0 = get_test_files(), + TestFiles = case Transport of + tcp -> TestFiles0; + tls -> TestFiles0 -- get_disabled_tls_test_files() + end, + do_test_giver_loop(TestFiles). + +do_test_giver_loop([]) -> + ok; +do_test_giver_loop([TestFile|Tail]) -> + receive + {request_test_file, FromPid, FromRef} -> + FromPid ! {FromRef, TestFile}, + do_test_giver_loop(Tail) + after 1000 -> + error(timeout) + end. + +do_request_test_file(Config) -> + Ref = make_ref(), + GiverPid = config(test_giver_pid, Config), + GiverPid ! {request_test_file, self(), Ref}, + receive + {Ref, TestFile} -> + TestFile + after 1000 -> + error(timeout) + end. + +%% Tests. + +-define(HOST, "web-platform.test"). + +%% WPT: domain/domain-attribute-host-with-and-without-leading-period +wpt_domain_with_and_without_leading_period(Config) -> + doc("Domain with and without leading period."), + #{ + same_origin := [{<<"a">>, <<"c">>}], + subdomain := [{<<"a">>, <<"c">>}] + } = do_domain_test(Config, "domain_with_and_without_leading_period"), + ok. + +%% WPT: domain/domain-attribute-host-with-leading-period +wpt_domain_with_leading_period(Config) -> + doc("Domain with leading period."), + #{ + same_origin := [{<<"a">>, <<"b">>}], + subdomain := [{<<"a">>, <<"b">>}] + } = do_domain_test(Config, "domain_with_leading_period"), + ok. + +%% WPT: domain/domain-attribute-matches-host +wpt_domain_matches_host(Config) -> + doc("Domain matches host header."), + #{ + same_origin := [{<<"a">>, <<"b">>}], + subdomain := [{<<"a">>, <<"b">>}] + } = do_domain_test(Config, "domain_matches_host"), + ok. + +%% WPT: domain/domain-attribute-missing +wpt_domain_missing(Config) -> + doc("Domain attribute missing."), + #{ + same_origin := [{<<"a">>, <<"b">>}], + subdomain := undefined + } = do_domain_test(Config, "domain_missing"), + ok. + +do_domain_test(Config, TestCase) -> + Protocol = config(protocol, Config), + {ok, ConnPid} = gun:open("localhost", config(port, Config), #{ + transport => config(transport, Config), + protocols => [Protocol], + cookie_store => gun_cookies_list:init() + }), + {ok, Protocol} = gun:await_up(ConnPid), + StreamRef1 = gun:get(ConnPid, ["/cookie-set?", TestCase], #{<<"host">> => ?HOST}), + {response, fin, 204, Headers1} = gun:await(ConnPid, StreamRef1), + ct:log("Headers1:~n~p", [Headers1]), + StreamRef2 = gun:get(ConnPid, "/cookie-echo", #{<<"host">> => ?HOST}), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {ok, Body2} = gun:await_body(ConnPid, StreamRef2), + ct:log("Body2:~n~p", [Body2]), + StreamRef3 = gun:get(ConnPid, "/cookie-echo", #{<<"host">> => "sub." ?HOST}), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef3), + {ok, Body3} = gun:await_body(ConnPid, StreamRef3), + ct:log("Body3:~n~p", [Body3]), + gun:close(ConnPid), + #{ + same_origin => case Body2 of <<"UNDEF">> -> undefined; _ -> cow_cookie:parse_cookie(Body2) end, + subdomain => case Body3 of <<"UNDEF">> -> undefined; _ -> cow_cookie:parse_cookie(Body3) end + }. + +%% WPT: http-state/*-tests +wpt_http_state(Config) -> + TestFile = do_request_test_file(Config), + Test = string:replace(filename:basename(TestFile), "-test", ""), + doc("http-state: " ++ Test), + ct:log("Test file:~n~s", [element(2, file:read_file(TestFile))]), + ct:log("Expected file:~n~s", [element(2, file:read_file(string:replace(TestFile, "-test", "-expected")))]), + Protocol = config(protocol, Config), + {ok, ConnPid} = gun:open("localhost", config(port, Config), #{ + transport => config(transport, Config), + protocols => [Protocol], + cookie_store => gun_cookies_list:init() + }), + {ok, Protocol} = gun:await_up(ConnPid), + StreamRef1 = gun:get(ConnPid, "/cookie-parser?" ++ Test, #{<<"host">> => "home.example.org"}), + {response, fin, 204, Headers1} = gun:await(ConnPid, StreamRef1), + ct:log("Headers1:~n~p", [Headers1]), + {Host, Path} = case lists:keyfind(<<"location">>, 1, Headers1) of + false -> + {"home.example.org", "/cookie-parser-result?" ++ Test}; + {_, Location} -> + case uri_string:parse(Location) of + #{host := Host0, path := Path0, query := Qs0} -> + {Host0, [Path0, $?, Qs0]}; + #{path := Path0, query := Qs0} -> + {"home.example.org", [Path0, $?, Qs0]} + end + end, + StreamRef2 = gun:get(ConnPid, Path, #{<<"host">> => Host}), + %% The validation is done in the handler. An error results in a 4xx or 5xx. + {response, fin, 204, Headers2} = gun:await(ConnPid, StreamRef2), + ct:log("Headers2:~n~p", [Headers2]), + gun:close(ConnPid). + +%% WPT: path/default +wpt_path_default(Config) -> + doc("Cookie set on the default path can be retrieved."), + Protocol = config(protocol, Config), + {ok, ConnPid} = gun:open("localhost", config(port, Config), #{ + transport => config(transport, Config), + protocols => [Protocol], + cookie_store => gun_cookies_list:init() + }), + {ok, Protocol} = gun:await_up(ConnPid), + %% Set and retrieve the cookie. + StreamRef1 = gun:get(ConnPid, "/cookie-set?path_default"), + {response, fin, 204, Headers1} = gun:await(ConnPid, StreamRef1), + ct:log("Headers1:~n~p", [Headers1]), + StreamRef2 = gun:get(ConnPid, "/cookie-echo"), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {ok, Body2} = gun:await_body(ConnPid, StreamRef2), + ct:log("Body2:~n~p", [Body2]), + [{<<"cookie-path-default">>, <<"1">>}] = cow_cookie:parse_cookie(Body2), + %% Expire the cookie. + StreamRef3 = gun:get(ConnPid, "/cookie-set?path_default_expire"), + {response, fin, 204, Headers3} = gun:await(ConnPid, StreamRef3), + ct:log("Headers3:~n~p", [Headers3]), + StreamRef4 = gun:get(ConnPid, "/cookie-echo"), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef4), + {ok, Body4} = gun:await_body(ConnPid, StreamRef4), + ct:log("Body4:~n~p", [Body4]), + <<"UNDEF">> = Body4, + gun:close(ConnPid). + +%% WPT: path/match +wpt_path_match(Config) -> + doc("Cookie path match."), + MatchTests = [ + <<"/">>, + <<"match.html">>, + <<"cookies">>, + <<"/cookies">>, + <<"/cookies/">>, + <<"/cookies/resources/echo-cookie.html">> + ], + NegTests = [ + <<"/cook">>, + <<"/w/">> + ], + Protocol = config(protocol, Config), + _ = [begin + ct:log("Positive test: ~s", [P]), + {ok, ConnPid} = gun:open("localhost", config(port, Config), #{ + transport => config(transport, Config), + protocols => [Protocol], + cookie_store => gun_cookies_list:init() + }), + {ok, Protocol} = gun:await_up(ConnPid), + %% Set and retrieve the cookie. + StreamRef1 = gun:get(ConnPid, ["/cookies/resources/set-cookie.html?path=", P]), + {response, fin, 204, Headers1} = gun:await(ConnPid, StreamRef1), + ct:log("Headers1:~n~p", [Headers1]), + StreamRef2 = gun:get(ConnPid, "/cookies/resources/echo-cookie.html"), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {ok, Body2} = gun:await_body(ConnPid, StreamRef2), + ct:log("Body2:~n~p", [Body2]), + [{<<"a">>, <<"b">>}] = cow_cookie:parse_cookie(Body2), + gun:close(ConnPid) + end || P <- MatchTests], + _ = [begin + ct:log("Negative test: ~s", [P]), + {ok, ConnPid} = gun:open("localhost", config(port, Config), #{ + transport => config(transport, Config), + protocols => [Protocol], + cookie_store => gun_cookies_list:init() + }), + {ok, Protocol} = gun:await_up(ConnPid), + %% Set and retrieve the cookie. + StreamRef1 = gun:get(ConnPid, ["/cookies/resources/set-cookie.html?path=", P]), + {response, fin, 204, Headers1} = gun:await(ConnPid, StreamRef1), + ct:log("Headers1:~n~p", [Headers1]), + StreamRef2 = gun:get(ConnPid, "/cookies/resources/echo-cookie.html"), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {ok, Body2} = gun:await_body(ConnPid, StreamRef2), + ct:log("Body2:~n~p", [Body2]), + <<"UNDEF">> = Body2, + gun:close(ConnPid) + end || P <- NegTests], + ok. + +%% WPT: prefix/__host.header +wpt_prefix_host(Config) -> + doc("__Host- prefix."), + Tests = case config(transport, Config) of + tcp -> [ + {<<"__Host-foo=bar; Path=/;">>, false}, + {<<"__Host-foo=bar; Path=/;domain=" ?HOST>>, false}, + {<<"__Host-foo=bar; Path=/;Max-Age=10">>, false}, + {<<"__Host-foo=bar; Path=/;HttpOnly">>, false}, + {<<"__Host-foo=bar; Secure; Path=/;">>, false}, + {<<"__Host-foo=bar; Secure; Path=/;domain=" ?HOST>>, false}, + {<<"__Host-foo=bar; Secure; Path=/;Max-Age=10">>, false}, + {<<"__Host-foo=bar; Secure; Path=/;HttpOnly">>, false}, + {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; ">>, false}, + {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; domain=" ?HOST>>, false}, + {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; Max-Age=10">>, false}, + {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; HttpOnly">>, false}, + {<<"__Host-foo=bar; Secure; Path=/cookies/resources/list.py">>, false} + ]; + tls -> [ + {<<"__Host-foo=bar; Path=/;">>, false}, + {<<"__Host-foo=bar; Path=/;Max-Age=10">>, false}, + {<<"__Host-foo=bar; Path=/;HttpOnly">>, false}, + {<<"__Host-foo=bar; Secure; Path=/;">>, true}, + {<<"__Host-foo=bar; Secure; Path=/;Max-Age=10">>, true}, + {<<"__Host-foo=bar; Secure; Path=/;HttpOnly">>, true}, + {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; ">>, false}, + {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; Max-Age=10">>, false}, + {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; HttpOnly">>, false}, + {<<"__Host-foo=bar; Secure; Path=/cookies/resources/list.py">>, false} + ] + end, + _ = [do_wpt_prefix_common(Config, TestCase, Expected, <<"__Host-foo">>) + || {TestCase, Expected} <- Tests], + ok. + +%% WPT: prefix/__secure.header +wpt_prefix_secure(Config) -> + doc("__Secure- prefix."), + Tests = case config(transport, Config) of + tcp -> [ + {<<"__Secure-foo=bar; Path=/;">>, false}, + {<<"__Secure-foo=bar; Path=/;domain=" ?HOST>>, false}, + {<<"__Secure-foo=bar; Path=/;Max-Age=10">>, false}, + {<<"__Secure-foo=bar; Path=/;HttpOnly">>, false}, + {<<"__Secure-foo=bar; Secure; Path=/;">>, false}, + {<<"__Secure-foo=bar; Secure; Path=/;domain=" ?HOST>>, false}, + {<<"__Secure-foo=bar; Secure; Path=/;Max-Age=10">>, false}, + {<<"__Secure-foo=bar; Secure; Path=/;HttpOnly">>, false} + ]; + tls -> [ + {<<"__Secure-foo=bar; Path=/;">>, false}, + {<<"__Secure-foo=bar; Path=/;Max-Age=10">>, false}, + {<<"__Secure-foo=bar; Path=/;HttpOnly">>, false}, + {<<"__Secure-foo=bar; Secure; Path=/;">>, true}, + {<<"__Secure-foo=bar; Secure; Path=/;Max-Age=10">>, true}, + {<<"__Secure-foo=bar; Secure; Path=/;HttpOnly">>, true} + %% Missing two SameSite cases from prefix/__secure.header.https. (Not implemented.) + ] + end, + _ = [do_wpt_prefix_common(Config, TestCase, Expected, <<"__Secure-foo">>) + || {TestCase, Expected} <- Tests], + ok. + +do_wpt_prefix_common(Config, TestCase, Expected, Name) -> + Protocol = config(protocol, Config), + ct:log("Test case: ~s~nCookie must be set? ~s", [TestCase, Expected]), + {ok, ConnPid} = gun:open("localhost", config(port, Config), #{ + transport => config(transport, Config), + protocols => [Protocol], + cookie_store => gun_cookies_list:init() + }), + {ok, Protocol} = gun:await_up(ConnPid), + %% Set and retrieve the cookie. + StreamRef1 = gun:get(ConnPid, "/cookies/resources/set.py?prefix", #{ + <<"host">> => ?HOST, + <<"please-set-cookie">> => TestCase + }), + {response, fin, 204, Headers1} = gun:await(ConnPid, StreamRef1), + ct:log("Headers1:~n~p", [Headers1]), + StreamRef2 = gun:get(ConnPid, "/cookies/resources/echo.py", #{ + <<"host">> => ?HOST + }), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {ok, Body2} = gun:await_body(ConnPid, StreamRef2), + ct:log("Body2:~n~p", [Body2]), + case Expected of + true -> + [{Name, _}] = cow_cookie:parse_cookie(Body2), + ok; + false -> + <<"UNDEF">> = Body2, + ok + end, + gun:close(ConnPid). + +%% WPT: samesite-none-secure/ (Not implemented.) +%% WPT: samesite/ (Not implemented.) + +wpt_secure(Config) -> + doc("Secure attribute."), + case config(transport, Config) of + tcp -> + undefined = do_wpt_secure_common(Config, <<"secure_http">>), + ok; + tls -> + [{<<"secure_from_secure_http">>, <<"1">>}] = do_wpt_secure_common(Config, <<"secure_https">>), + ok + end. + +do_wpt_secure_common(Config, TestCase) -> + Protocol = config(protocol, Config), + {ok, ConnPid} = gun:open("localhost", config(port, Config), #{ + transport => config(transport, Config), + protocols => [Protocol], + cookie_store => gun_cookies_list:init() + }), + {ok, Protocol} = gun:await_up(ConnPid), + StreamRef1 = gun:get(ConnPid, ["/cookie-set?", TestCase]), + {response, fin, 204, Headers1} = gun:await(ConnPid, StreamRef1), + ct:log("Headers1:~n~p", [Headers1]), + StreamRef2 = gun:get(ConnPid, "/cookie-echo"), + {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), + {ok, Body2} = gun:await_body(ConnPid, StreamRef2), + ct:log("Body2:~n~p", [Body2]), + gun:close(ConnPid), + case Body2 of + <<"UNDEF">> -> undefined; + _ -> cow_cookie:parse_cookie(Body2) + end. + +%% WPT: secure/set-from-ws* +wpt_secure_ws(Config) -> + doc("Secure attribute in Websocket upgrade response."), + case config(transport, Config) of + tcp -> + undefined = do_wpt_secure_ws_common(Config), + ok; + tls -> + [{<<"ws_cookie">>, <<"1">>}] = do_wpt_secure_ws_common(Config), + ok + end. + +do_wpt_secure_ws_common(Config) -> + Protocol = config(protocol, Config), + {ok, ConnPid1} = gun:open("localhost", config(port, Config), #{ + transport => config(transport, Config), + protocols => [Protocol], + cookie_store => gun_cookies_list:init() + }), + {ok, Protocol} = gun:await_up(ConnPid1), + StreamRef1 = gun:ws_upgrade(ConnPid1, "/ws"), + {upgrade, [<<"websocket">>], Headers1} = gun:await(ConnPid1, StreamRef1), + ct:log("Headers1:~n~p", [Headers1]), + %% We must extract the cookie store because it is tied to the connection. + #{cookie_store := CookieStore} = gun:info(ConnPid1), + gun:close(ConnPid1), + {ok, ConnPid2} = gun:open("localhost", config(port, Config), #{ + transport => config(transport, Config), + protocols => [Protocol], + cookie_store => CookieStore + }), + StreamRef2 = gun:get(ConnPid2, "/cookie-echo"), + {response, nofin, 200, _} = gun:await(ConnPid2, StreamRef2), + {ok, Body2} = gun:await_body(ConnPid2, StreamRef2), + ct:log("Body2:~n~p", [Body2]), + gun:close(ConnPid2), + case Body2 of + <<"UNDEF">> -> undefined; + _ -> cow_cookie:parse_cookie(Body2) + end. -- cgit v1.2.3