aboutsummaryrefslogblamecommitdiffstats
path: root/src/gun_cookies.erl
blob: 8f6dd8d2b4833271ab04f6d22f24baedafb387a2 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
                                                             












                                                                           

                     
                               





                          
                               
 



                                                                         











                                                
                                                      
                                
                               

                                 
                                                  


                         
                                 

                                            
                                              








                                                   
                                              



                               

                                   




                                       







                                  
























                                                                                                            

















                                                                                 


                                     





































                                                                           
                                  











                                                                                           


                                             
 
                                          
                                                                                            
                                                            

                                                                                  
                                                             
                                                                                    























                                                         








                                                         



                                                              
                                              
                           

                                     
                    
                                                          

























                                                                              
                                                                                  


























                                                                                         
                                                                            
                                                               



























                                                                          

                                                                   
                                                         
                                                                
                                                                              





                                                                             

                                                             














                                                                                       






























                                                                                                     
             


                                                                                                     

                                                                                     


                                                              

                                                  

























                                                                                                       





                                                                            









































                                                                                              






                                                                                                        

                                                                                                                

          

                                                                                                        
                                                

































                                                                                                                        


                                                                                                        


                                





                                                                                             
                                                              

                                                                                             


                                                                                                           
                                                                                     








                                                                                                





















                                                                 





                                                                                                     





















































                                                                                                           
                                                                                                   




















                                                                                            

                                                





















                                                                                                        
                                                        
       
%% Copyright (c) 2020-2023, 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(gun_cookies).

-export([add_cookie_header/5]).
-export([domain_match/2]).
-export([gc/1]).
-export([path_match/2]).
-export([query/2]).
-export([session_gc/1]).
-export([set_cookie/5]).
-export([set_cookie_header/7]).

-ifdef(TEST).
-export([wpt_http_state_test_files/1]). %% Also used in rfc6265bis_SUITE.
-endif.

-type store_state() :: any().

-type store() :: {module(), store_state()}.
-export_type([store/0]).

-type cookie() :: #{
	name := binary(),
	value := binary(),
	domain := binary(),
	path := binary(),
	creation_time := calendar:datetime(),
	last_access_time := calendar:datetime(),
	expiry_time := calendar:datetime() | infinity,
	persistent := boolean(),
	host_only := boolean(),
	secure_only := boolean(),
	http_only := boolean(),
	same_site := default | none | strict | lax
}.
-export_type([cookie/0]).

-callback init(any()) -> store().

-callback query(State, uri_string:uri_map())
	-> {ok, [gun_cookies:cookie()], State}
	when State::store_state().

-callback set_cookie_secure_match(store_state(), #{
	name := binary(),
%	secure_only := true,
	domain := binary(),
	path := binary()
}) -> match | nomatch.

-callback set_cookie_get_exact_match(State, #{
	name := binary(),
	domain := binary(),
	host_only := boolean(),
	path := binary()
}) -> {ok, cookie(), State} | error
	when State::store_state().

-callback store(State, cookie())
	-> {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 add_cookie_header(binary(), iodata(), iodata(), Headers, Store)
	-> {Headers, Store} when Headers :: [{binary(), iodata()}], Store :: undefined | store().
add_cookie_header(_, _, _, Headers, Store=undefined) ->
	{Headers, Store};
add_cookie_header(Scheme, Authority, PathWithQs, Headers0, Store0) ->
	#{
		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]),
	{ok, Cookies0, Store} = 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, Store}.

-spec domain_match(binary(), binary()) -> boolean().
domain_match(String, String) ->
	true;
domain_match(String, DomainString) ->
	SkipLen = byte_size(String) - byte_size(DomainString) - 1,
	case String of
		<<_:SkipLen/unit:8, $., DomainString/binary>> ->
			case inet:parse_strict_address(binary_to_list(String)) of
				{ok, _} ->
					false;
				{error, einval} ->
					true
			end;
		_ ->
			false
	end.

-spec gc(Store) -> {ok, Store} when Store::store().
gc({Mod, State0}) ->
	{ok, State} = Mod:gc(State0),
	{ok, {Mod, State}}.

-spec path_match(binary(), binary()) -> boolean().
path_match(Path, Path) ->
	true;
path_match(ReqPath, CookiePath) ->
	Len = byte_size(CookiePath),
	CookieLast = binary:last(CookiePath),
	case ReqPath of
		<<CookiePath:Len/binary, _/bits>> when CookieLast =:= $/ ->
			true;
		<<CookiePath:Len/binary, $/, _/bits>> ->
			true;
		_ ->
			false
	end.

-ifdef(TEST).
path_match_test_() ->
	Tests = [
		{<<"/">>, <<"/">>, true},
		{<<"/path/to/resource">>, <<"/path/to/resource">>, true},
		{<<"/path/">>, <<"/path/">>, true},
		{<<"/path/to/resource">>, <<"/path/">>, true},
		{<<"/path/to/resource">>, <<"/path">>, true},
		{<<"/path/to/resource">>, <<"/path/to/">>, true},
		{<<"/path/to/resource">>, <<"/path/to">>, true},
		{<<"/path/to/resource">>, <<"/pa">>, false},
		{<<"/path/to/resource">>, <<"/pat">>, false},
		{<<"/path/to/resource">>, <<"/path/to/r">>, false},
		{<<"/abc">>, <<"/def">>, false}
	],
	[{iolist_to_binary(io_lib:format("(~p,~p)", [PA, PB])),
		fun() -> Res = path_match(PA, PB) end}
	|| {PA, PB, Res} <- Tests].
-endif.

%% @todo The given URI must be normalized.
-spec query(Store, uri_string:uri_map())
	-> {ok, [cookie()], Store}
	when Store::store().
query({Mod, State0}, URI) ->
	{ok, Cookies0, State} = Mod:query(State0, URI),
	Cookies = lists:sort(fun
		(#{path := P, creation_time := CTA}, #{path := P, creation_time := CTB}) ->
			CTA =< CTB;
		(#{path := PA}, #{path := PB}) ->
			PA > PB
	end, Cookies0),
	{ok, Cookies, {Mod, State}}.

-spec session_gc(Store) -> {ok, Store} when Store::store().
session_gc({Mod, State0}) ->
	{ok, State} = Mod:session_gc(State0),
	{ok, {Mod, State}}.

%% @todo The given URI must be normalized.
-spec set_cookie(Store, uri_string:uri_map(), binary(), binary(), cow_cookie:cookie_attrs())
	-> {ok, Store} | {error, any()} when Store::store().
set_cookie(_, _, Name, Value, _) when byte_size(Name) + byte_size(Value) > 4096 ->
	{error, larger_than_4096_bytes};
set_cookie(Store, URI=#{host := Host}, Name, Value, Attrs) ->
	%% This is where we would add a feature to block cookies (like a blacklist).
	CurrentTime = erlang:universaltime(),
	Cookie0 = #{
		name => Name,
		value => Value,
		creation_time => CurrentTime,
		last_access_time => CurrentTime
	},
	Cookie = case Attrs of
		#{max_age := ExpiryTime} ->
			Cookie0#{
				persistent => true,
				expiry_time => ExpiryTime
			};
		#{expires := ExpiryTime} ->
			Cookie0#{
				persistent => true,
				expiry_time => ExpiryTime
			};
		_ ->
			Cookie0#{
				persistent => false,
				expiry_time => infinity
			}
	end,
	Domain0 = maps:get(domain, Attrs, <<>>),
	Domain = case gun_public_suffix:match(Domain0) of
		false ->
			Domain0;
		true when Host =:= Domain0 ->
			<<>>;
		true ->
			{error, domain_is_public_suffix}
	end,
	case Domain of
		<<>> ->
			set_cookie(Store, URI, Attrs, Cookie#{
				host_only => true,
				domain => Host
			});
		Error = {error, _} ->
			Error;
		_ ->
			case domain_match(Host, Domain) of
				true ->
					set_cookie(Store, URI, Attrs, Cookie#{
						host_only => false,
						domain => Domain
					});
				false ->
					{error, domain_match_failed}
			end
	end.

set_cookie(Store, URI, Attrs, Cookie0) ->
	Cookie1 = case Attrs of
		#{path := Path} ->
			Cookie0#{path => Path};
		_ ->
			Cookie0#{path => default_path(URI)}
	end,
	SecureOnly = maps:get(secure, Attrs, false),
	case {SecureOnly, maps:get(scheme, URI)} of
		{true, <<"http">>} ->
			{error, secure_scheme_only};
		_ ->
			Cookie = Cookie1#{
				secure_only => SecureOnly,
				http_only => maps:get(http_only, Attrs, false)
			},
			%% This is where we would drop cookies from non-HTTP APIs.
			set_cookie1(Store, URI, Attrs, Cookie)
	end.

default_path(#{path := Path}) ->
	case string:split(Path, <<"/">>, trailing) of
		[_] -> <<"/">>;
		[<<>>, _] -> <<"/">>;
		[DefaultPath, _] -> DefaultPath
	end;
default_path(_) ->
	<<"/">>.

set_cookie1(Store, URI=#{scheme := <<"http">>}, Attrs, Cookie=#{secure_only := false}) ->
	Match = maps:with([name, domain, path], Cookie),
	case set_cookie_secure_match(Store, Match) of
		match ->
			{error, secure_cookie_matches};
		nomatch ->
			set_cookie2(Store, URI, Attrs, Cookie)
	end;
set_cookie1(Store, URI, Attrs, Cookie) ->
	set_cookie2(Store, URI, Attrs, Cookie).

set_cookie_secure_match({Mod, State}, Match) ->
	Mod:set_cookie_secure_match(State, Match).

set_cookie2(Store, _URI, Attrs, Cookie0) ->
	Cookie = Cookie0#{same_site => maps:get(same_site, Attrs, default)},
	%% This is where we would perform the same-site checks.
	%%
	%% It seems that an option would need to be added to Gun
	%% in order to define the "site for cookies" value. It is
	%% not the same as the site identified by the URI. Although
	%% I do wonder if in the case of server push we may consider
	%% the requested URI to be the "site for cookies", at least
	%% by default.
	%%
	%% The URI argument will be used if/when the above gets
	%% implemented.
	set_cookie3(Store, Attrs, Cookie).

set_cookie3(Store, Attrs, Cookie=#{name := Name,
		host_only := HostOnly, secure_only := SecureOnly}) ->
	Path = maps:get(path, Attrs, undefined),
	case Name of
		<<"__Secure-",_/bits>> when not SecureOnly ->
			{error, name_prefix_secure_requires_secure_only};
		<<"__Host-",_/bits>> when not SecureOnly ->
			{error, name_prefix_host_requires_secure_only};
		<<"__Host-",_/bits>> when not HostOnly ->
			{error, name_prefix_host_requires_host_only};
		<<"__Host-",_/bits>> when Path =/= <<"/">> ->
			{error, name_prefix_host_requires_top_level_path};
		_ ->
			set_cookie_store(Store, Cookie)
	end.

set_cookie_store(Store0, Cookie) ->
	Match = maps:with([name, domain, host_only, path], Cookie),
	case set_cookie_get_exact_match(Store0, Match) of
		{ok, #{creation_time := CreationTime}, Store} ->
			%% This is where we would reject a new non-HTTP cookie
			%% if the OldCookie has http_only set to true.
			store(Store, Cookie#{creation_time => CreationTime});
		error ->
			store(Store0, Cookie)
	end.

set_cookie_get_exact_match({Mod, State0}, Match) ->
	case Mod:set_cookie_get_exact_match(State0, Match) of
		{ok, Cookie, State} ->
			{ok, Cookie, {Mod, State}};
		Error ->
			Error
	end.

store({Mod, State0}, Cookie) ->
	case Mod:store(State0, Cookie) of
		{ok, State} ->
			{ok, {Mod, State}};
		%% @todo Is this return value useful? Can't it just return {ok, State}?
		Error ->
			Error
	end.

-spec set_cookie_header(binary(), iodata(), iodata(), cow_http:status(),
	Headers, Store, #{cookie_ignore_informational := boolean()})
	-> {Headers, Store} when Headers :: [{binary(), iodata()}], Store :: undefined | store().
%% Don't set cookies when cookie store isn't configured.
set_cookie_header(_, _, _, _, _, Store=undefined, _) ->
	Store;
%% Ignore cookies set on informational responses when configured to do so.
%% This includes cookies set to Websocket upgrade responses!
set_cookie_header(_, _, _, Status, _, Store, #{cookie_ignore_informational := true})
		when Status >= 100, Status =< 199 ->
	Store;
set_cookie_header(Scheme, Authority, PathWithQs, _, Headers, Store0, _) ->
	#{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],
	lists:foldl(fun(SC, Store1) ->
		case cow_cookie:parse_set_cookie(SC) of
			{ok, N, V, A} ->
				case set_cookie(Store1, URIMap, N, V, A) of
					{ok, Store} -> Store;
					{error, _} -> Store1
				end;
			ignore ->
				Store1
		end
	end, Store0, SetCookies).

-ifdef(TEST).
gc_test() ->
	URIMap = #{scheme => <<"http">>, host => <<"example.org">>, path => <<"/path/to/resource">>},
	Store0 = gun_cookies_list:init(),
	%% Add a cookie that expires in 2 seconds. GC. Cookie can be retrieved.
	{ok, N0, V0, A0} = cow_cookie:parse_set_cookie(<<"a=b; Path=/; Max-Age=2">>),
	{ok, Store1} = set_cookie(Store0, URIMap, N0, V0, A0),
	{ok, Store2} = gc(Store1),
	{ok, [_], _} = query(Store2, URIMap),
	%% Wait 3 seconds. GC. Cookie was removed.
	timer:sleep(3000),
	{ok, Store} = gc(Store2),
	{ok, [], _} = query(Store, URIMap),
	ok.

gc_expiry_time_infinity_test() ->
	URIMap = #{scheme => <<"http">>, host => <<"example.org">>, path => <<"/path/to/resource">>},
	Store0 = gun_cookies_list:init(),
	%% Add a session cookie. GC. Cookie can be retrieved.
	{ok, N0, V0, A0} = cow_cookie:parse_set_cookie(<<"a=b; Path=/">>),
	{ok, Store1} = set_cookie(Store0, URIMap, N0, V0, A0),
	{ok, Store} = gc(Store1),
	{ok, [_], _} = query(Store, URIMap),
	ok.

session_gc_test() ->
	URIMap = #{scheme => <<"http">>, host => <<"example.org">>, path => <<"/path/to/resource">>},
	Store0 = gun_cookies_list:init(),
	%% Add a persistent and a session cookie. GC session cookies. Only persistent can be retrieved.
	{ok, N0, V0, A0} = cow_cookie:parse_set_cookie(<<"s=s; Path=/">>),
	{ok, Store1} = set_cookie(Store0, URIMap, N0, V0, A0),
	{ok, N1, V1, A1} = cow_cookie:parse_set_cookie(<<"p=p; Path=/; Max-Age=999">>),
	{ok, Store2} = set_cookie(Store1, URIMap, N1, V1, A1),
	{ok, Store} = session_gc(Store2),
	{ok, [#{name := <<"p">>}], _} = query(Store, URIMap),
	ok.

%% Most of the tests for this module are converted from the
%% Web platform test suite. At the time of writing they could
%% be found at https://github.com/web-platform-tests/wpt/tree/master/cookies
%%
%% Some of the tests use files from wpt directly, namely the
%% http-state ones. They are copied to the test/wpt/cookies directory.

-define(HOST, "web-platform.test").

%% WPT: domain/domain-attribute-host-with-and-without-leading-period
%% WPT: domain/domain-attribute-host-with-leading-period
wpt_domain_with_and_without_leading_period_test() ->
	URIMap = #{scheme => <<"http">>, host => <<?HOST>>, path => <<"/path/to/resource">>},
	Store0 = gun_cookies_list:init(),
	%% Add a cookie with a leading period in the domain. Cookie can be retrieved.
	{ok, N0, V0, A0} = cow_cookie:parse_set_cookie(<<"a=b; Path=/; Domain=." ?HOST>>),
	{ok, Store1} = set_cookie(Store0, URIMap, N0, V0, A0),
	{ok, [#{value := <<"b">>}], _} = query(Store1, URIMap),
	{ok, [#{value := <<"b">>}], _} = query(Store1, URIMap#{host => <<"sub." ?HOST>>}),
	%% Add a cookie without a leading period in the domain. Overrides the existing cookie.
	{ok, N1, V1, A1} = cow_cookie:parse_set_cookie(<<"a=c; Path=/; Domain=" ?HOST>>),
	{ok, Store} = set_cookie(Store1, URIMap, N1, V1, A1),
	{ok, [#{value := <<"c">>}], _} = query(Store, URIMap),
	{ok, [#{value := <<"c">>}], _} = query(Store, URIMap#{host => <<"sub." ?HOST>>}),
	ok.

%% WPT: domain/domain-attribute-matches-host
wpt_domain_matches_host_test() ->
	URIMap = #{scheme => <<"http">>, host => <<?HOST>>, path => <<"/path/to/resource">>},
	Store0 = gun_cookies_list:init(),
	%% Add a cookie without a leading period in the domain. Cookie can be retrieved.
	{ok, N1, V1, A1} = cow_cookie:parse_set_cookie(<<"a=c; Path=/; Domain=" ?HOST>>),
	{ok, Store} = set_cookie(Store0, URIMap, N1, V1, A1),
	{ok, [#{value := <<"c">>}], _} = query(Store, URIMap),
	{ok, [#{value := <<"c">>}], _} = query(Store, URIMap#{host => <<"sub." ?HOST>>}),
	ok.

%% WPT: domain/domain-attribute-missing
wpt_domain_missing_test() ->
	URIMap = #{scheme => <<"http">>, host => <<?HOST>>, path => <<"/path/to/resource">>},
	Store0 = gun_cookies_list:init(),
	%% Add a cookie without a domain attribute. Cookie is not sent on subdomains.
	{ok, N1, V1, A1} = cow_cookie:parse_set_cookie(<<"a=c; Path=/">>),
	{ok, Store} = set_cookie(Store0, URIMap, N1, V1, A1),
	{ok, [#{value := <<"c">>}], _} = query(Store, URIMap),
	{ok, [], _} = query(Store, URIMap#{host => <<"sub." ?HOST>>}),
	ok.

%% 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/disabled-chromium0020-test", %% Maximum cookie name of 4096 characters.
		TestPath ++ "wpt/cookies/disabled-chromium0022-test" %% Nonsense.
	].

wpt_http_state_test_() ->
	URIMap0 = #{scheme => <<"http">>, host => <<"home.example.org">>, path => <<"/cookie-parser">>},
	TestFiles = wpt_http_state_test_files(),
	[{F, fun() ->
		{ok, Test} = file:read_file(F),
		%% We don't want the final empty line.
		Lines = lists:reverse(tl(lists:reverse(string:split(Test, <<"\n">>, all)))),
		{Store, URIMap2} = lists:foldl(fun
			(<<"Set-Cookie: ",SetCookie/bits>>, Acc={Store0, URIMap1}) ->
				case cow_cookie:parse_set_cookie(SetCookie) of
					{ok, N, V, A} ->
						%% We use the URIMap that corresponds to the request.
						case set_cookie(Store0, URIMap0, N, V, A) of
							{ok, Store1} -> {Store1, URIMap1};
							{error, _} -> Acc
						end;
					ignore ->
						Acc
				end;
			(<<"Set-Cookie:">>, Acc) ->
				Acc;
			(<<"Location: ",Location/bits>>, {Store0, URIMap1}) ->
				{Store0, maps:merge(URIMap1, uri_string:normalize(Location, [return_map]))}
		end, {gun_cookies_list:init(), URIMap0}, Lines),
		%% We must change the URI if it wasn't already changed by the test.
		URIMap = case URIMap2 of
			URIMap0 -> maps:merge(URIMap0, uri_string:normalize(<<"/cookie-parser-result">>, [return_map]));
			_ -> URIMap2
		end,
		{ok, Cookies, _} = query(Store, URIMap),
		case file:read_file(iolist_to_binary(string:replace(F, <<"-test">>, <<"-expected">>))) of
			{ok, ExpectedFile} when ExpectedFile =:= <<>>; ExpectedFile =:= <<"\n">> ->
				[] = Cookies,
				ok;
			{ok, <<"Cookie: ",CookiesBin0/bits>>} ->
				%% We only care about the first line.
				[CookiesBin, <<>>|_] = string:split(CookiesBin0, <<"\n">>, all),
				CookiesBin = iolist_to_binary(cow_cookie:cookie(
					[{Name, Value} || #{name := Name, value := Value} <- Cookies])),
				ok
		end
	end} || F <- TestFiles].

%% WPT: path/default
wpt_path_default_test() ->
	URIMap = #{scheme => <<"http">>, host => <<?HOST>>, path => <<"/path/to/resource">>},
	Store0 = gun_cookies_list:init(),
	%% Add a cookie without a path attribute.
	{ok, N1, V1, A1} = cow_cookie:parse_set_cookie(<<"cookies-path-default=1">>),
	{ok, Store1} = set_cookie(Store0, URIMap, N1, V1, A1),
	%% Confirm the cookie was stored with the proper default path,
	%% and gets sent for the same path, other resources at the same level or child paths.
	{ok, [#{path := <<"/path/to">>}], _} = query(Store1, URIMap),
	{ok, [#{path := <<"/path/to">>}], _} = query(Store1, URIMap#{path => <<"/path/to/other">>}),
	{ok, [#{path := <<"/path/to">>}], _} = query(Store1, URIMap#{path => <<"/path/to/resource/sub">>}),
	%% Confirm that the cookie cannot be retrieved for parent or unrelated paths.
	{ok, [], _} = query(Store1, URIMap#{path => <<"/path">>}),
	{ok, [], _} = query(Store1, URIMap#{path => <<"/path/toon">>}),
	{ok, [], _} = query(Store1, URIMap#{path => <<"/">>}),
	%% Expire the cookie.
	{ok, N2, V2, A2} = cow_cookie:parse_set_cookie(<<"cookies-path-default=1; Max-Age=0">>),
	{ok, Store} = set_cookie(Store1, URIMap, N2, V2, A2),
	{ok, [], _} = query(Store, URIMap),
	{ok, [], _} = query(Store, URIMap#{path => <<"/path/to/other">>}),
	{ok, [], _} = query(Store, URIMap#{path => <<"/path/to/resource/sub">>}),
	ok.

%% WPT: path/match
wpt_path_match_test_() ->
	URIMap = #{
		scheme => <<"http">>,
		host => <<?HOST>>,
		path => <<"/cookies/resources/echo-cookie.html">>
	},
	MatchTests = [
		<<"/">>,
		<<"match.html">>,
		<<"cookies">>,
		<<"/cookies">>,
		<<"/cookies/">>,
		<<"/cookies/resources/echo-cookie.html">>
	],
	NegTests = [
		<<"/cook">>,
		<<"/w/">>
	],
	[{P, fun() ->
		{ok, N1, V1, A1} = cow_cookie:parse_set_cookie(<<"a=b; Path=",P/binary>>),
		{ok, Store0} = set_cookie(gun_cookies_list:init(), URIMap, N1, V1, A1),
		{ok, [#{name := <<"a">>}], _} = query(Store0, URIMap),
		{ok, N2, V2, A2} = cow_cookie:parse_set_cookie(<<"a=b; Max-Age=0; Path=",P/binary>>),
		{ok, Store} = set_cookie(Store0, URIMap, N2, V2, A2),
		{ok, [], _} = query(Store, URIMap)
	end} || P <- MatchTests]
	++
	[{P, fun() ->
		{ok, N, V, A} = cow_cookie:parse_set_cookie(<<"a=b; Path=",P/binary>>),
		{ok, Store} = set_cookie(gun_cookies_list:init(), URIMap, N, V, A),
		{ok, [], _} = query(Store, URIMap)
	end} || P <- NegTests].

%% WPT: prefix/__host.header
wpt_prefix_host_test_() ->
	Tests = [
		{<<"http">>, <<"__Host-foo=bar; Path=/;">>, false},
		{<<"http">>, <<"__Host-foo=bar; Path=/;domain=" ?HOST>>, false},
		{<<"http">>, <<"__Host-foo=bar; Path=/;Max-Age=10">>, false},
		{<<"http">>, <<"__Host-foo=bar; Path=/;HttpOnly">>, false},
		{<<"http">>, <<"__Host-foo=bar; Secure; Path=/;">>, false},
		{<<"http">>, <<"__Host-foo=bar; Secure; Path=/;domain=" ?HOST>>, false},
		{<<"http">>, <<"__Host-foo=bar; Secure; Path=/;Max-Age=10">>, false},
		{<<"http">>, <<"__Host-foo=bar; Secure; Path=/;HttpOnly">>, false},
		{<<"http">>, <<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; ">>, false},
		{<<"http">>, <<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; domain=" ?HOST>>, false},
		{<<"http">>, <<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; Max-Age=10">>, false},
		{<<"http">>, <<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; HttpOnly">>, false},
		{<<"http">>, <<"__Host-foo=bar; Secure; Path=/cookies/resources/list.py">>, false},
		{<<"https">>, <<"__Host-foo=bar; Path=/;">>, false},
		{<<"https">>, <<"__Host-foo=bar; Path=/;Max-Age=10">>, false},
		{<<"https">>, <<"__Host-foo=bar; Path=/;HttpOnly">>, false},
		{<<"https">>, <<"__Host-foo=bar; Secure; Path=/;">>, true},
		{<<"https">>, <<"__Host-foo=bar; Secure; Path=/;Max-Age=10">>, true},
		{<<"https">>, <<"__Host-foo=bar; Secure; Path=/;HttpOnly">>, true},
		{<<"https">>, <<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; ">>, false},
		{<<"https">>, <<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; Max-Age=10">>, false},
		{<<"https">>, <<"__Host-foo=bar; Secure; Path=/; Domain=" ?HOST "; HttpOnly">>, false},
		{<<"https">>, <<"__Host-foo=bar; Secure; Path=/cookies/resources/list.py">>, false}
	],
	wpt_prefix_common(Tests, <<"__Host-foo">>).

%% WPT: prefix/__secure.header
wpt_prefix_secure_test_() ->
	Tests = [
		{<<"http">>, <<"__Secure-foo=bar; Path=/;">>, false},
		{<<"http">>, <<"__Secure-foo=bar; Path=/;domain=" ?HOST>>, false},
		{<<"http">>, <<"__Secure-foo=bar; Path=/;Max-Age=10">>, false},
		{<<"http">>, <<"__Secure-foo=bar; Path=/;HttpOnly">>, false},
		{<<"http">>, <<"__Secure-foo=bar; Secure; Path=/;">>, false},
		{<<"http">>, <<"__Secure-foo=bar; Secure; Path=/;domain=" ?HOST>>, false},
		{<<"http">>, <<"__Secure-foo=bar; Secure; Path=/;Max-Age=10">>, false},
		{<<"http">>, <<"__Secure-foo=bar; Secure; Path=/;HttpOnly">>, false},
		{<<"https">>, <<"__Secure-foo=bar; Path=/;">>, false},
		{<<"https">>, <<"__Secure-foo=bar; Path=/;Max-Age=10">>, false},
		{<<"https">>, <<"__Secure-foo=bar; Path=/;HttpOnly">>, false},
		{<<"https">>, <<"__Secure-foo=bar; Secure; Path=/;">>, true},
		{<<"https">>, <<"__Secure-foo=bar; Secure; Path=/;Max-Age=10">>, true},
		{<<"https">>, <<"__Secure-foo=bar; Secure; Path=/;HttpOnly">>, true}
		%% Missing two SameSite cases from prefix/__secure.header.https. (Not implemented.)
	],
	wpt_prefix_common(Tests, <<"__Secure-foo">>).

wpt_prefix_common(Tests, Name) ->
	URIMap0 = #{
		host => <<?HOST>>,
		path => <<"/cookies/resources/set.py">>
	},
	[{<<S/binary," ",H/binary>>, fun() ->
		URIMap1 = URIMap0#{scheme => S},
		{ok, N, V, A} = cow_cookie:parse_set_cookie(H),
		case set_cookie(gun_cookies_list:init(), URIMap1, N, V, A) of
			{ok, Store} when Res =:= true ->
				URIMap = URIMap1#{path => <<"/cookies/resources/list.py">>},
				{ok, [#{name := Name}], _} = query(Store, URIMap),
				ok;
			{error, _} ->
				ok
		end
	end} || {S, H, Res} <- Tests].

%% WPT: samesite-none-secure/ (Not implemented.)
%% WPT: samesite/ (Not implemented.)

wpt_secure_https_test() ->
	URIMap = #{
		scheme => <<"https">>,
		host => <<?HOST>>,
		path => <<"/cookies/secure/any.html">>
	},
	{ok, N, V, A} = cow_cookie:parse_set_cookie(<<"secure_from_secure_http=1; Secure; Path=/">>),
	{ok, Store} = set_cookie(gun_cookies_list:init(), URIMap, N, V, A),
	{ok, [#{name := <<"secure_from_secure_http">>}], _} = query(Store, URIMap),
	ok.

wpt_secure_http_test() ->
	URIMap = #{
		scheme => <<"http">>,
		host => <<?HOST>>,
		path => <<"/cookies/secure/any.html">>
	},
	{ok, N, V, A} = cow_cookie:parse_set_cookie(<<"secure_from_nonsecure_http=1; Secure; Path=/">>),
	{error, secure_scheme_only} = set_cookie(gun_cookies_list:init(), URIMap, N, V, A),
	ok.

%% WPT: secure/set-from-ws* (Anything special required?)
-endif.