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