aboutsummaryrefslogblamecommitdiffstats
path: root/test/rfc6265bis_SUITE.erl
blob: 0672fecce70449717adfd969eec31ab3fa00d87c (plain) (tree)































































































































                                                                                                     
















































































































































































































































































































































































                                                                                                                      
%% 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)
	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.