aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLoïc Hoguin <[email protected]>2020-03-11 19:45:16 +0100
committerLoïc Hoguin <[email protected]>2020-03-12 18:08:16 +0100
commit87d0bfff926892d2dc0a55a3dc45d8c5f8a682f3 (patch)
tree5b381d3d2c2f691699baeddad78c781026d237b7
parent04790d3a281a42fbd65c9a44fe88f437cfe025f3 (diff)
downloadgun-87d0bfff926892d2dc0a55a3dc45d8c5f8a682f3.tar.gz
gun-87d0bfff926892d2dc0a55a3dc45d8c5f8a682f3.tar.bz2
gun-87d0bfff926892d2dc0a55a3dc45d8c5f8a682f3.zip
Make Gun use the cookie store when configured to
-rw-r--r--doc/src/manual/gun.info.asciidoc2
-rw-r--r--src/gun.erl113
-rw-r--r--src/gun_cookies.erl69
-rw-r--r--src/gun_cookies_list.erl8
-rw-r--r--src/gun_http.erl109
-rw-r--r--src/gun_http2.erl71
-rw-r--r--test/gun_test.erl4
-rw-r--r--test/handlers/cookie_echo_h.erl11
-rw-r--r--test/handlers/cookie_parser_h.erl23
-rw-r--r--test/handlers/cookie_parser_result_h.erl25
-rw-r--r--test/handlers/cookie_set_h.erl36
-rw-r--r--test/handlers/ws_cookie_h.erl24
-rw-r--r--test/rfc6265bis_SUITE.erl499
13 files changed, 895 insertions, 99 deletions
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 <[email protected]>
+%%
+%% 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.