%% Copyright (c) 2020-2023, Loïc Hoguin %% %% Permission to use, copy, modify, and/or distribute this software for any %% purpose with or without fee is hereby granted, provided that the above %% copyright notice and this permission notice appear in all copies. %% %% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES %% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF %% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR %% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES %% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF %% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -module(rfc6265bis_SUITE). -compile(export_all). -compile(nowarn_export_all). -import(ct_helper, [config/2]). -import(ct_helper, [doc/1]). %% ct. all() -> [ {group, http}, {group, https}, {group, h2c}, {group, h2} ]. groups() -> CommonTests = ct_helper:all(?MODULE), [ {http, [parallel], CommonTests}, {https, [parallel], CommonTests}, %% Websocket over HTTP/2 is currently not supported. {h2c, [parallel], (CommonTests -- [wpt_secure_ws])}, {h2, [parallel], (CommonTests -- [wpt_secure_ws])} ]. 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), [{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), [{transport, tls}, {protocol, Protocol}|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, []}, {"/informational", cookie_informational_h, []}, {"/ws", ws_cookie_h, []} ]} ]. %% Tests. dont_ignore_informational_set_cookie(Config) -> doc("User agents may accept set-cookie headers " "sent in informational responses. (RFC6265bis 3)"), [{<<"informational">>, <<"1">>}, {<<"final">>, <<"1">>}] = do_informational_set_cookie(Config, false). ignore_informational_set_cookie(Config) -> doc("User agents may ignore set-cookie headers " "sent in informational responses. (RFC6265bis 3)"), [{<<"final">>, <<"1">>}] = do_informational_set_cookie(Config, true). do_informational_set_cookie(Config, Boolean) -> Protocol = config(protocol, Config), {ok, ConnPid} = gun:open("localhost", config(port, Config), #{ transport => config(transport, Config), tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], protocols => [{Protocol, #{cookie_ignore_informational => Boolean}}], cookie_store => gun_cookies_list:init() }), {ok, Protocol} = gun:await_up(ConnPid), StreamRef1 = gun:get(ConnPid, "/informational"), {inform, 103, Headers1} = gun:await(ConnPid, StreamRef1), ct:log("Headers1:~n~p", [Headers1]), {response, fin, 204, Headers2} = gun:await(ConnPid, StreamRef1), ct:log("Headers2:~n~p", [Headers2]), 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]), Res = cow_cookie:parse_cookie(Body2), gun:close(ConnPid), Res. set_cookie_connect_tcp(Config) -> doc("Cookies may also be set in responses going through CONNECT tunnels."), Transport = config(transport, Config), Protocol = config(protocol, Config), {ok, ProxyPid, ProxyPort} = event_SUITE:do_proxy_start(Protocol, tcp), {ok, ConnPid} = gun:open("localhost", ProxyPort, #{ transport => tcp, protocols => [Protocol], cookie_store => gun_cookies_list:init() }), {ok, Protocol} = gun:await_up(ConnPid), tunnel_SUITE:do_handshake_completed(Protocol, ProxyPid), StreamRef1 = gun:connect(ConnPid, #{ host => "localhost", port => config(port, Config), transport => Transport, tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], protocols => [Protocol] }), {response, fin, 200, _} = gun:await(ConnPid, StreamRef1), {up, Protocol} = gun:await(ConnPid, StreamRef1), StreamRef2 = gun:get(ConnPid, "/cookie-set?prefix", #{ <<"please-set-cookie">> => <<"a=b">> }, #{tunnel => StreamRef1}), {response, fin, 204, Headers2} = gun:await(ConnPid, StreamRef2), ct:log("Headers2:~n~p", [Headers2]), StreamRef3 = gun:get(ConnPid, "/cookie-echo", [], #{tunnel => StreamRef1}), {response, nofin, 200, _} = gun:await(ConnPid, StreamRef3), {ok, Body3} = gun:await_body(ConnPid, StreamRef3), ct:log("Body3:~n~p", [Body3]), [{<<"a">>, <<"b">>}] = cow_cookie:parse_cookie(Body3), gun:close(ConnPid). set_cookie_connect_tls(Config) -> doc("Cookies may also be set in responses going through CONNECT tunnels."), Transport = config(transport, Config), Protocol = config(protocol, Config), {ok, ProxyPid, ProxyPort} = event_SUITE:do_proxy_start(Protocol, tls), {ok, ConnPid} = gun:open("localhost", ProxyPort, #{ transport => tls, tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], protocols => [Protocol], cookie_store => gun_cookies_list:init() }), {ok, Protocol} = gun:await_up(ConnPid), tunnel_SUITE:do_handshake_completed(Protocol, ProxyPid), StreamRef1 = gun:connect(ConnPid, #{ host => "localhost", port => config(port, Config), transport => Transport, tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], protocols => [Protocol] }), {response, fin, 200, _} = gun:await(ConnPid, StreamRef1), {up, Protocol} = gun:await(ConnPid, StreamRef1), StreamRef2 = gun:get(ConnPid, "/cookie-set?prefix", #{ <<"please-set-cookie">> => <<"a=b">> }, #{tunnel => StreamRef1}), {response, fin, 204, Headers2} = gun:await(ConnPid, StreamRef2), ct:log("Headers2:~n~p", [Headers2]), StreamRef3 = gun:get(ConnPid, "/cookie-echo", [], #{tunnel => StreamRef1}), {response, nofin, 200, _} = gun:await(ConnPid, StreamRef3), {ok, Body3} = gun:await_body(ConnPid, StreamRef3), ct:log("Body3:~n~p", [Body3]), [{<<"a">>, <<"b">>}] = cow_cookie:parse_cookie(Body3), gun:close(ConnPid). %% Web Platform Tests converted to Erlang. %% %% Tests are not automatically updated, the process is manual. %% Some test data is exported in JSON files in the "test/wpt" directory. %% https://github.com/web-platform-tests/wpt/tree/master/cookies -define(WPT_HOST, "web-platform.test"). %% WPT: browser-only tests %% %% cookie-enabled-noncookie-frame.html %% meta-blocked.html %% navigated-away.html %% prefix/document-cookie.non-secure.html %% prefix/__host.document-cookie.html %% prefix/__host.document-cookie.https.html %% prefix/__secure.document-cookie.html %% prefix/__secure.document-cookie.https.html %% secure/set-from-dom.https.sub.html %% secure/set-from-dom.sub.html %% WPT: attributes/attributes-ctl %% %% attributes/attributes-ctl.sub.html %% %% The original tests use the DOM. We can't do that so %% we use a simple HTTP test instead. The original test %% also includes a string representation of the CTL in %% the cookie name. We don't bother. %% %% The expected value is only used for the \t CTL. %% The original test retains the \t in the value because %% it uses the DOM. The Set-Cookie algorithm requires %% us to drop it. wpt_attributes_ctl_domain(Config) -> doc("Test cookie attribute parsing with control characters: " "in Domain attribute value."), do_wpt_ctl_test(fun(CTL) -> { <<"testdomain">>, <<"testdomain=t; Domain=test", CTL, ".co; Domain=", ?WPT_HOST>>, <<"testdomain=t">> } end, "/cookies/attributes", Config). wpt_attributes_ctl_domain2(Config) -> doc("Test cookie attribute parsing with control characters: " "after Domain attribute value."), do_wpt_ctl_test(fun(CTL) -> { <<"testdomain2">>, <<"testdomain2=t; Domain=", ?WPT_HOST, CTL>>, <<"testdomain2=t">> } end, "/cookies/attributes", Config). wpt_attributes_ctl_path(Config) -> doc("Test cookie attribute parsing with control characters: " "in Path attribute value."), do_wpt_ctl_test(fun(CTL) -> { <<"testpath">>, <<"testpath=t; Path=/te", CTL, "st; Path=/cookies/attributes">>, <<"testpath=t">> } end, "/cookies/attributes", Config). wpt_attributes_ctl_path2(Config) -> doc("Test cookie attribute parsing with control characters: " "after Path attribute value."), do_wpt_ctl_test(fun(CTL) -> { <<"testpath2">>, <<"testpath2=t; Path=/cookies/attributes", CTL>>, <<"testpath2=t">> } end, "/cookies/attributes", Config). wpt_attributes_ctl_max_age(Config) -> doc("Test cookie attribute parsing with control characters: " "in Max-Age attribute value."), do_wpt_ctl_test(fun(CTL) -> { <<"testmaxage">>, <<"testmaxage=t; Max-Age=10", CTL, "00; Max-Age=1000">>, <<"testmaxage=t">> } end, "/cookies/attributes", Config). wpt_attributes_ctl_max_age2(Config) -> doc("Test cookie attribute parsing with control characters: " "after Max-Age attribute value."), do_wpt_ctl_test(fun(CTL) -> { <<"testmaxage2">>, <<"testmaxage2=t; Max-Age=1000", CTL>>, <<"testmaxage2=t">> } end, "/cookies/attributes", Config). wpt_attributes_ctl_expires(Config) -> doc("Test cookie attribute parsing with control characters: " "in Expires attribute value."), do_wpt_ctl_test(fun(CTL) -> { <<"testexpires">>, <<"testexpires=t" "; Expires=Fri, 01 Jan 20", CTL, "38 00:00:00 GMT" "; Expires=Fri, 01 Jan 2038 00:00:00 GMT">>, <<"testexpires=t">> } end, "/cookies/attributes", Config). wpt_attributes_ctl_expires2(Config) -> doc("Test cookie attribute parsing with control characters: " "after Expires attribute value."), do_wpt_ctl_test(fun(CTL) -> { <<"testexpires2">>, <<"testexpires2=t; Expires=Fri, 01 Jan 2038 00:00:00 GMT", CTL>>, <<"testexpires2=t">> } end, "/cookies/attributes", Config). wpt_attributes_ctl_secure(Config) -> doc("Test cookie attribute parsing with control characters: " "in Secure attribute."), do_wpt_ctl_test(fun(CTL) -> { <<"testsecure">>, <<"testsecure=t; Sec", CTL, "ure">>, <<"testsecure=t">> } end, "/cookies/attributes", Config). wpt_attributes_ctl_secure2(Config) -> doc("Test cookie attribute parsing with control characters: " "after Secure attribute."), do_wpt_ctl_test(fun(CTL) -> { <<"testsecure2">>, <<"testsecure2=t; Secure", CTL>>, case config(transport, Config) of tcp -> <<>>; %% Secure causes the cookie to be rejected over TCP. tls -> <<"testsecure2=t">> end } end, "/cookies/attributes", Config). wpt_attributes_ctl_httponly(Config) -> doc("Test cookie attribute parsing with control characters: " "in HttpOnly attribute."), do_wpt_ctl_test(fun(CTL) -> { <<"testhttponly">>, <<"testhttponly=t; Http", CTL, "Only">>, <<"testhttponly=t">> } end, "/cookies/attributes", Config). wpt_attributes_ctl_samesite(Config) -> doc("Test cookie attribute parsing with control characters: " "in SameSite attribute value."), do_wpt_ctl_test(fun(CTL) -> { <<"testsamesite">>, <<"testsamesite=t; SameSite=No", CTL, "ne; SameSite=None">>, <<"testsamesite=t">> } end, "/cookies/attributes", Config). wpt_attributes_ctl_samesite2(Config) -> doc("Test cookie attribute parsing with control characters: " "after SameSite attribute value."), do_wpt_ctl_test(fun(CTL) -> { <<"testsamesite2">>, <<"testsamesite2=t; SameSite=None", CTL>>, <<"testsamesite2=t">> } end, "/cookies/attributes", Config). %% @todo Redirect cookie test. %% attributes/domain.sub.html %% attributes/resources/domain-child.sub.html %% WPT: attributes/expires %% %% attributes/expires.html wpt_attributes_expires(Config) -> doc("Test expires attribute parsing."), do_wpt_json_test("attributes_expires", "/cookies/attributes", Config). %% WPT: attributes/invalid %% %% attributes/invalid.html wpt_attributes_invalid(Config) -> doc("Test invalid attribute parsing."), do_wpt_json_test("attributes_invalid", "/cookies/attributes", Config). %% WPT: attributes/max_age %% %% attributes/max-age.html wpt_attributes_max_age(Config) -> doc("Test max-age attribute parsing."), do_wpt_json_test("attributes_max_age", "/cookies/attributes", Config). %% WPT: attributes/path %% %% attributes/path.html wpt_attributes_path(Config) -> doc("Test cookie path attribute parsing."), do_wpt_json_test("attributes_path", "/cookies/attributes", Config). %% @todo Redirect cookie test. %% attributes/path-redirect.html %% attributes/resources/pathfakeout.html %% attributes/resources/path-redirect-shared.js %% attributes/resources/path.html %% attributes/resources/path.html.headers %% attributes/resources/path/one.html %% attributes/resources/path/three.html %% attributes/resources/path/two.html %% attributes/resources/pathfakeout/one.html %% WPT: attributes/secure %% %% attributes/secure.https.html %% attributes/secure-non-secure.html %% attributes/resources/secure-non-secure-child.html wpt_attributes_secure(Config) -> doc("Test cookie secure attribute parsing."), TestFile = case config(transport, Config) of tcp -> "attributes_secure_non_secure"; tls -> "attributes_secure" end, do_wpt_json_test(TestFile, "/cookies/attributes", Config). %% WPT: domain/domain-attribute-host-with-and-without-leading-period %% %% domain/domain-attribute-host-with-and-without-leading-period.sub.https.html %% domain/domain-attribute-host-with-and-without-leading-period.sub.https.html.sub.headers wpt_domain_with_and_without_leading_period(Config) -> doc("Domain with and without leading period."), #{ same_origin := [{<<"a">>, <<"c">>}], subdomain := [{<<"a">>, <<"c">>}] } = do_wpt_domain_test(Config, "domain_with_and_without_leading_period"), ok. %% WPT: domain/domain-attribute-host-with-leading-period %% %% domain/domain-attribute-host-with-leading-period.sub.https.html %% domain/domain-attribute-host-with-leading-period.sub.https.html.sub.headers wpt_domain_with_leading_period(Config) -> doc("Domain with leading period."), #{ same_origin := [{<<"a">>, <<"b">>}], subdomain := [{<<"a">>, <<"b">>}] } = do_wpt_domain_test(Config, "domain_with_leading_period"), ok. %% @todo WPT: domain/domain-attribute-idn-host %% %% domain/domain-attribute-idn-host.sub.https.html %% domain/support/idn-child.sub.https.html %% domain/support/idn.py %% WPT: domain/domain-attribute-matches-host %% %% domain/domain-attribute-matches-host.sub.https.html %% domain/domain-attribute-matches-host.sub.https.html.sub.headers wpt_domain_matches_host(Config) -> doc("Domain matches host header."), #{ same_origin := [{<<"a">>, <<"b">>}], subdomain := [{<<"a">>, <<"b">>}] } = do_wpt_domain_test(Config, "domain_matches_host"), ok. %% WPT: domain/domain-attribute-missing %% %% domain/domain-attribute-missing.sub.html %% domain/domain-attribute-missing.sub.html.headers wpt_domain_missing(Config) -> doc("Domain attribute missing."), #{ same_origin := [{<<"a">>, <<"b">>}], subdomain := undefined } = do_wpt_domain_test(Config, "domain_missing"), ok. do_wpt_domain_test(Config, TestCase) -> Protocol = config(protocol, Config), {ok, ConnPid} = gun:open("localhost", config(port, Config), #{ transport => config(transport, Config), tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], protocols => [Protocol], cookie_store => gun_cookies_list:init() }), {ok, Protocol} = gun:await_up(ConnPid), StreamRef1 = gun:get(ConnPid, ["/cookie-set?", TestCase], #{<<"host">> => ?WPT_HOST}), {response, fin, 204, Headers1} = gun:await(ConnPid, StreamRef1), ct:log("Headers1:~n~p", [Headers1]), StreamRef2 = gun:get(ConnPid, "/cookie-echo", #{<<"host">> => ?WPT_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." ?WPT_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: encoding/charset %% %% encoding/charset.html wpt_encoding(Config) -> doc("Test UTF-8 and ASCII cookie parsing."), do_wpt_json_test("encoding_charset", "/cookies/encoding", Config). %% WPT: name/name %% %% name/name.html wpt_name(Config) -> doc("Test cookie name parsing."), do_wpt_json_test("name", "/cookies/name", Config). %% WPT: name/name-ctl %% %% name/name-ctl.html %% %% The original tests use the DOM. We can't do that so %% we use a simple HTTP test instead. The original test %% also includes a string representation of the CTL in %% the cookie name. We don't bother. %% %% The expected value is only used for the \t CTL. %% The original test retains the \t in the value because %% it uses the DOM. The Set-Cookie algorithm requires %% us to drop it. wpt_name_ctl(Config) -> doc("Test cookie name parsing with control characters."), do_wpt_ctl_test(fun(CTL) -> { <<"test", CTL, "name">>, <<"test", CTL, "name=", CTL>>, <<"test", CTL, "name=">> } end, "/cookies/name", Config). %% @todo Redirect cookie test. %% ordering/ordering.sub.html %% ordering/resources/ordering-child.sub.html %% WPT: partitioned-cookies (Not implemented; proposal.) %% WPT: path/default %% %% path/default.html 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), tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], 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 %% %% path/match.html 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), tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], 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), tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], 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 %% %% prefix/__host.header.html %% prefix/__host.header.https.html wpt_prefix_host(Config) -> doc("__Host- prefix."), Tests = case config(transport, Config) of tcp -> [ {<<"__Host-foo=bar; Path=/;">>, false}, {<<"__Host-foo=bar; Path=/;domain=" ?WPT_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=" ?WPT_HOST>>, false}, {<<"__Host-foo=bar; Secure; Path=/;Max-Age=10">>, false}, {<<"__Host-foo=bar; Secure; Path=/;HttpOnly">>, false}, {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?WPT_HOST "; ">>, false}, {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?WPT_HOST "; domain=" ?WPT_HOST>>, false}, {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?WPT_HOST "; Max-Age=10">>, false}, {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?WPT_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=" ?WPT_HOST "; ">>, false}, {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?WPT_HOST "; Max-Age=10">>, false}, {<<"__Host-foo=bar; Secure; Path=/; Domain=" ?WPT_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 %% %% prefix/__secure.header.html %% prefix/__secure.header.https.html wpt_prefix_secure(Config) -> doc("__Secure- prefix."), Tests = case config(transport, Config) of tcp -> [ {<<"__Secure-foo=bar; Path=/;">>, false}, {<<"__Secure-foo=bar; Path=/;domain=" ?WPT_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=" ?WPT_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), tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], 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">> => ?WPT_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">> => ?WPT_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/ (Not implemented.) %% WPT: samesite-none-secure/ (Not implemented.) %% WPT: schemeful-same-site/ (Not implemented.) %% WPT: secure/set-from-http.* %% %% secure/set-from-http.sub.html %% secure/set-from-http.sub.html.headers %% secure/set-from-http.https.sub.html %% secure/set-from-http.https.sub.html.headers 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), tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], 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* %% %% secure/set-from-ws.sub.html %% secure/set-from-wss.https.sub.html 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), tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], 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), tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], 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. %% WPT: size/attributes %% %% size/attributes.www.sub.html wpt_size_attributes(Config) -> doc("Test cookie attribute size restrictions."), do_wpt_json_test("size_attributes", "/cookies/size", Config). %% WPT: size/name-and-value %% %% size/name-and-value.html wpt_size_name_and_value(Config) -> doc("Test cookie name/value size restrictions."), do_wpt_json_test("size_name_and_value", "/cookies/size", Config). %% WPT: value/value %% %% value/value.html wpt_value(Config) -> doc("Test cookie value parsing."), Tests = do_load_json("value"), _ = [begin #{ <<"name">> := Name, <<"cookie">> := Cookie, <<"expected">> := Expected } = Test, false = maps:is_key(<<"defaultPath">>, Test), do_wpt_set_test(<<"/cookies/value">>, Name, Cookie, Expected, Config) end || Test <- Tests, %% The original test uses the DOM, we use HTTP, and are %% required to drop the cookie entirely if it contains %% a \n (RFC6265bis 5.4) so we skip this test. maps:get(<<"expected">>, Test) =/= <<"test=13">>], ok. %% WPT: value/value-ctl %% %% value/value-ctl.html %% %% The original tests use the DOM. We can't do that so %% we use a simple HTTP test instead. The original test %% also includes a string representation of the CTL in %% the cookie value. We don't bother. %% %% The expected value is only used for the \t CTL. %% The original test retains the \t in the value because %% it uses the DOM. The Set-Cookie algorithm requires %% us to drop it. wpt_value_ctl(Config) -> doc("Test cookie value parsing with control characters."), do_wpt_ctl_test(fun(CTL) -> { <<"test">>, <<"test=", CTL, "value">>, <<"test=value">> } end, "/cookies/value", Config). %% JSON files are created by taking the Javascript Object %% from the HTML files in the WPT suite, using the browser %% Developer console to convert into JSON: %% Obj = %% JSON.stringify(Obj) %% Then copying the result into the JSON file; removing %% the quoting (first and last character) and if needed %% fixing the escaping in Vim using: %% :%s/\\\\/\\/g %% The host may also need to be replaced to match WPT_HOST. do_load_json(File0) -> File = "../../test/wpt/cookies/" ++ File0 ++ ".json", {ok, Bin} = file:read_file(File), jsx:decode(Bin, [{return_maps, true}]). do_wpt_json_test(TestFile, TestPath, Config) -> Tests = do_load_json(TestFile), _ = [begin #{ <<"name">> := Name, <<"cookie">> := Cookie, <<"expected">> := Expected } = Test, DefaultPath = maps:get(<<"defaultPath">>, Test, true), do_wpt_set_test(TestPath, Name, Cookie, Expected, DefaultPath, Config) end || Test <- Tests], ok. do_wpt_ctl_test(Fun, TestPath, Config) -> %% Control characters are defined by RFC5234 to be %x00-1F / %x7F. %% We exclude \r for HTTP/1.1 because this causes errors %% at the header parsing level. CTLs0 = lists:seq(0, 16#1F) ++ [16#7F], CTLs = case config(protocol, Config) of http -> CTLs0 -- "\r"; http2 -> CTLs0 end, %% All CTLs except \t should cause the cookie to be rejected. _ = [begin {Name, Cookie, Expected} = Fun(CTL), case CTL of $\t -> do_wpt_set_test(TestPath, Name, Cookie, Expected, false, Config); _ -> do_wpt_set_test(TestPath, Name, Cookie, <<>>, false, Config) end end || CTL <- CTLs], ok. %% Equivalent to httpCookieTest. do_wpt_set_test(TestPath, Name, Cookie, Expected, Config) -> do_wpt_set_test(TestPath, Name, Cookie, Expected, true, Config). do_wpt_set_test(TestPath, Name, Cookie, Expected, DefaultPath, Config) -> ct:log("Name: ~s", [Name]), Protocol = config(protocol, Config), {ok, ConnPid} = gun:open("localhost", config(port, Config), #{ transport => config(transport, Config), tls_opts => [{verify, verify_none}, {versions, ['tlsv1.2']}], protocols => [Protocol], cookie_store => gun_cookies_list:init() }), {ok, Protocol} = gun:await_up(ConnPid), StreamRef1 = gun:get(ConnPid, ["/cookie-set?ttb=", cow_qs:urlencode(term_to_binary(Cookie))], #{<<"host">> => ?WPT_HOST}), {response, fin, 204, Headers} = gun:await(ConnPid, StreamRef1), ct:log("Headers:~n~p", [Headers]), #{cookie_store := Store} = gun:info(ConnPid), ct:log("Store:~n~p", [Store]), Result1 = case DefaultPath of true -> %% We do another request to get the cookie. StreamRef2 = gun:get(ConnPid, "/cookie-echo", #{<<"host">> => ?WPT_HOST}), {response, nofin, 200, _} = gun:await(ConnPid, StreamRef2), {ok, Body2} = gun:await_body(ConnPid, StreamRef2), case Body2 of <<"UNDEF">> -> <<>>; _ -> Body2 end; false -> %% We call this function to get a request header representation %% of a cookie, similar to what document.cookie returns. case gun_cookies:add_cookie_header( case config(transport, Config) of tcp -> <<"http">>; tls -> <<"https">> end, <>, TestPath, [], Store) of {[{<<"cookie">>, Result0}], _} -> Result0; {[], _} -> <<>> end end, Result = unicode:characters_to_binary(Result1), ct:log("Expected:~n~p~nResult:~n~p", [Expected, Result]), {Name, Cookie, Expected} = {Name, Cookie, Result}, gun:close(ConnPid).