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














                                                                           
                          

                       







                                     

                              

                                                                     
                                                       


























                                             

                                          
                                                 
                      


                                                      
                      

                                                      
                                               
                      
                                               
                      
                                               
                      
                                               
                      
                                                 
                      
                                                 
                      




                                                              


                                                                   
                      
                                                   
                      
                                                   
                      
                                                     
                      
                                                     
                      



































                                                                                      

















                                                                                                         

                                                                  
                                                                          

                                                                            

                                                                              
                                            




                                                                                     

                                                                   




                             
                          

                                                                                          

       







                                                                    
                                                               




                                                                                   





















                                                                                                





                                                     
                                                                       


                                                               
                                               

                                                                             
                                          

                                                                         
                                                               

                                                                 
                                            

                                                                               
                                               

                                                                                  
                                                                            














                                                                            


                                                                                




                                                                              
                                              



                             
                                           
















                                                                      
%% Copyright (c) 2013-2018, 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(cow_cookie).

-export([parse_cookie/1]).
-export([setcookie/3]).

-type cookie_opts() :: #{
	domain => binary(),
	http_only => boolean(),
	max_age => non_neg_integer(),
	path => binary(),
	same_site => lax | strict,
	secure => boolean()
}.
-export_type([cookie_opts/0]).

%% @doc Parse a cookie header string and return a list of key/values.

-spec parse_cookie(binary()) -> [{binary(), binary()}].
parse_cookie(Cookie) ->
	parse_cookie(Cookie, []).

parse_cookie(<<>>, Acc) ->
	lists:reverse(Acc);
parse_cookie(<< $\s, Rest/binary >>, Acc) ->
	parse_cookie(Rest, Acc);
parse_cookie(<< $\t, Rest/binary >>, Acc) ->
	parse_cookie(Rest, Acc);
parse_cookie(<< $,, Rest/binary >>, Acc) ->
	parse_cookie(Rest, Acc);
parse_cookie(<< $;, Rest/binary >>, Acc) ->
	parse_cookie(Rest, Acc);
parse_cookie(<< $$, Rest/binary >>, Acc) ->
	skip_cookie(Rest, Acc);
parse_cookie(Cookie, Acc) ->
	parse_cookie_name(Cookie, Acc, <<>>).

skip_cookie(<<>>, Acc) ->
	lists:reverse(Acc);
skip_cookie(<< $,, Rest/binary >>, Acc) ->
	parse_cookie(Rest, Acc);
skip_cookie(<< $;, Rest/binary >>, Acc) ->
	parse_cookie(Rest, Acc);
skip_cookie(<< _, Rest/binary >>, Acc) ->
	skip_cookie(Rest, Acc).

parse_cookie_name(<<>>, Acc, Name) ->
	lists:reverse([{Name, <<>>}|Acc]);
parse_cookie_name(<< $=, _/binary >>, _, <<>>) ->
	error(badarg);
parse_cookie_name(<< $=, Rest/binary >>, Acc, Name) ->
	parse_cookie_value(Rest, Acc, Name, <<>>);
parse_cookie_name(<< $,, _/binary >>, _, _) ->
	error(badarg);
parse_cookie_name(<< $;, Rest/binary >>, Acc, Name) ->
	parse_cookie(Rest, [{Name, <<>>}|Acc]);
parse_cookie_name(<< $\s, _/binary >>, _, _) ->
	error(badarg);
parse_cookie_name(<< $\t, _/binary >>, _, _) ->
	error(badarg);
parse_cookie_name(<< $\r, _/binary >>, _, _) ->
	error(badarg);
parse_cookie_name(<< $\n, _/binary >>, _, _) ->
	error(badarg);
parse_cookie_name(<< $\013, _/binary >>, _, _) ->
	error(badarg);
parse_cookie_name(<< $\014, _/binary >>, _, _) ->
	error(badarg);
parse_cookie_name(<< C, Rest/binary >>, Acc, Name) ->
	parse_cookie_name(Rest, Acc, << Name/binary, C >>).

parse_cookie_value(<<>>, Acc, Name, Value) ->
	lists:reverse([{Name, parse_cookie_trim(Value)}|Acc]);
parse_cookie_value(<< $;, Rest/binary >>, Acc, Name, Value) ->
	parse_cookie(Rest, [{Name, parse_cookie_trim(Value)}|Acc]);
parse_cookie_value(<< $\t, _/binary >>, _, _, _) ->
	error(badarg);
parse_cookie_value(<< $\r, _/binary >>, _, _, _) ->
	error(badarg);
parse_cookie_value(<< $\n, _/binary >>, _, _, _) ->
	error(badarg);
parse_cookie_value(<< $\013, _/binary >>, _, _, _) ->
	error(badarg);
parse_cookie_value(<< $\014, _/binary >>, _, _, _) ->
	error(badarg);
parse_cookie_value(<< C, Rest/binary >>, Acc, Name, Value) ->
	parse_cookie_value(Rest, Acc, Name, << Value/binary, C >>).

parse_cookie_trim(Value = <<>>) ->
	Value;
parse_cookie_trim(Value) ->
	case binary:last(Value) of
		$\s ->
			Size = byte_size(Value) - 1,
			<< Value2:Size/binary, _ >> = Value,
			parse_cookie_trim(Value2);
		_ ->
			Value
	end.

-ifdef(TEST).
parse_cookie_test_() ->
	%% {Value, Result}.
	Tests = [
		{<<"name=value; name2=value2">>, [
			{<<"name">>, <<"value">>},
			{<<"name2">>, <<"value2">>}
		]},
		{<<"$Version=1; Customer=WILE_E_COYOTE; $Path=/acme">>, [
			{<<"Customer">>, <<"WILE_E_COYOTE">>}
		]},
		{<<"$Version=1; Customer=WILE_E_COYOTE; $Path=/acme; "
			"Part_Number=Rocket_Launcher_0001; $Path=/acme; "
			"Shipping=FedEx; $Path=/acme">>, [
			{<<"Customer">>, <<"WILE_E_COYOTE">>},
			{<<"Part_Number">>, <<"Rocket_Launcher_0001">>},
			{<<"Shipping">>, <<"FedEx">>}
		]},
		%% Space in value.
		{<<"foo=Thu Jul 11 2013 15:38:43 GMT+0400 (MSK)">>,
			[{<<"foo">>, <<"Thu Jul 11 2013 15:38:43 GMT+0400 (MSK)">>}]},
		%% Comma in value. Google Analytics sets that kind of cookies.
		{<<"refk=sOUZDzq2w2; sk=B602064E0139D842D620C7569640DBB4C81C45080651"
			"9CC124EF794863E10E80; __utma=64249653.825741573.1380181332.1400"
			"015657.1400019557.703; __utmb=64249653.1.10.1400019557; __utmc="
			"64249653; __utmz=64249653.1400019557.703.13.utmcsr=bluesky.chic"
			"agotribune.com|utmccn=(referral)|utmcmd=referral|utmcct=/origin"
			"als/chi-12-indispensable-digital-tools-bsi,0,0.storygallery">>, [
				{<<"refk">>, <<"sOUZDzq2w2">>},
				{<<"sk">>, <<"B602064E0139D842D620C7569640DBB4C81C45080651"
					"9CC124EF794863E10E80">>},
				{<<"__utma">>, <<"64249653.825741573.1380181332.1400"
					"015657.1400019557.703">>},
				{<<"__utmb">>, <<"64249653.1.10.1400019557">>},
				{<<"__utmc">>, <<"64249653">>},
				{<<"__utmz">>, <<"64249653.1400019557.703.13.utmcsr=bluesky.chic"
					"agotribune.com|utmccn=(referral)|utmcmd=referral|utmcct=/origin"
					"als/chi-12-indispensable-digital-tools-bsi,0,0.storygallery">>}
		]},
		%% Potential edge cases (initially from Mochiweb).
		{<<"foo=\\x">>, [{<<"foo">>, <<"\\x">>}]},
		{<<"foo=;bar=">>, [{<<"foo">>, <<>>}, {<<"bar">>, <<>>}]},
		{<<"foo=\\\";;bar=good ">>,
			[{<<"foo">>, <<"\\\"">>}, {<<"bar">>, <<"good">>}]},
		{<<"foo=\"\\\";bar=good">>,
			[{<<"foo">>, <<"\"\\\"">>}, {<<"bar">>, <<"good">>}]},
		{<<>>, []}, %% Flash player.
		{<<"foo=bar , baz=wibble ">>, [{<<"foo">>, <<"bar , baz=wibble">>}]},
		%% Technically invalid, but seen in the wild
		{<<"foo">>, [{<<"foo">>, <<>>}]},
		{<<"foo;">>, [{<<"foo">>, <<>>}]},
		{<<"bar;foo=1">>, [{<<"bar">>, <<"">>}, {<<"foo">>, <<"1">>}]}
	],
	[{V, fun() -> R = parse_cookie(V) end} || {V, R} <- Tests].

parse_cookie_error_test_() ->
	%% Value.
	Tests = [
		<<"=">>,
		<<"foo ">>
	],
	[{V, fun() -> {'EXIT', {badarg, _}} = (catch parse_cookie(V)) end} || V <- Tests].
-endif.

%% @doc Convert a cookie name, value and options to its iodata form.
%% @end
%%
%% Initially from Mochiweb:
%%   * Copyright 2007 Mochi Media, Inc.
%% Initial binary implementation:
%%   * Copyright 2011 Thomas Burdick <[email protected]>

-spec setcookie(iodata(), iodata(), cookie_opts()) -> iolist().
setcookie(Name, Value, Opts) ->
	nomatch = binary:match(iolist_to_binary(Name), [<<$=>>, <<$,>>, <<$;>>,
			<<$\s>>, <<$\t>>, <<$\r>>, <<$\n>>, <<$\013>>, <<$\014>>]),
	nomatch = binary:match(iolist_to_binary(Value), [<<$,>>, <<$;>>,
			<<$\s>>, <<$\t>>, <<$\r>>, <<$\n>>, <<$\013>>, <<$\014>>]),
	[Name, <<"=">>, Value, <<"; Version=1">>, attributes(maps:to_list(Opts))].

attributes([]) -> [];
attributes([{domain, Domain}|Tail]) -> [<<"; Domain=">>, Domain|attributes(Tail)];
attributes([{http_only, false}|Tail]) -> attributes(Tail);
attributes([{http_only, true}|Tail]) -> [<<"; HttpOnly">>|attributes(Tail)];
%% MSIE requires an Expires date in the past to delete a cookie.
attributes([{max_age, 0}|Tail]) ->
	[<<"; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Max-Age=0">>|attributes(Tail)];
attributes([{max_age, MaxAge}|Tail]) when is_integer(MaxAge), MaxAge > 0 ->
	Secs = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
	Expires = cow_date:rfc2109(calendar:gregorian_seconds_to_datetime(Secs + MaxAge)),
	[<<"; Expires=">>, Expires, <<"; Max-Age=">>, integer_to_list(MaxAge)|attributes(Tail)];
attributes([Opt={max_age, _}|_]) ->
	error({badarg, Opt});
attributes([{path, Path}|Tail]) -> [<<"; Path=">>, Path|attributes(Tail)];
attributes([{secure, false}|Tail]) -> attributes(Tail);
attributes([{secure, true}|Tail]) -> [<<"; Secure">>|attributes(Tail)];
attributes([{same_site, lax}|Tail]) -> [<<"; SameSite=Lax">>|attributes(Tail)];
attributes([{same_site, strict}|Tail]) -> [<<"; SameSite=Strict">>|attributes(Tail)];
%% Skip unknown options.
attributes([_|Tail]) -> attributes(Tail).

-ifdef(TEST).
setcookie_test_() ->
	%% {Name, Value, Opts, Result}
	Tests = [
		{<<"Customer">>, <<"WILE_E_COYOTE">>,
			#{http_only => true, domain => <<"acme.com">>},
			<<"Customer=WILE_E_COYOTE; Version=1; "
				"Domain=acme.com; HttpOnly">>},
		{<<"Customer">>, <<"WILE_E_COYOTE">>,
			#{path => <<"/acme">>},
			<<"Customer=WILE_E_COYOTE; Version=1; Path=/acme">>},
		{<<"Customer">>, <<"WILE_E_COYOTE">>,
			#{secure => true},
			<<"Customer=WILE_E_COYOTE; Version=1; Secure">>},
		{<<"Customer">>, <<"WILE_E_COYOTE">>,
			#{secure => false, http_only => false},
			<<"Customer=WILE_E_COYOTE; Version=1">>},
		{<<"Customer">>, <<"WILE_E_COYOTE">>,
			#{same_site => lax},
			<<"Customer=WILE_E_COYOTE; Version=1; SameSite=Lax">>},
		{<<"Customer">>, <<"WILE_E_COYOTE">>,
			#{same_site => strict},
			<<"Customer=WILE_E_COYOTE; Version=1; SameSite=Strict">>},
		{<<"Customer">>, <<"WILE_E_COYOTE">>,
			#{path => <<"/acme">>, badoption => <<"negatory">>},
			<<"Customer=WILE_E_COYOTE; Version=1; Path=/acme">>}
	],
	[{R, fun() -> R = iolist_to_binary(setcookie(N, V, O)) end}
		|| {N, V, O, R} <- Tests].

setcookie_max_age_test() ->
	F = fun(N, V, O) ->
		binary:split(iolist_to_binary(
			setcookie(N, V, O)), <<";">>, [global])
	end,
	[<<"Customer=WILE_E_COYOTE">>,
		<<" Version=1">>,
		<<" Expires=", _/binary>>,
		<<" Max-Age=111">>,
		<<" Secure">>] = F(<<"Customer">>, <<"WILE_E_COYOTE">>,
			#{max_age => 111, secure => true}),
	case catch F(<<"Customer">>, <<"WILE_E_COYOTE">>, #{max_age => -111}) of
		{'EXIT', {{badarg, {max_age, -111}}, _}} -> ok
	end,
	[<<"Customer=WILE_E_COYOTE">>,
		<<" Version=1">>,
		<<" Expires=", _/binary>>,
		<<" Max-Age=86417">>] = F(<<"Customer">>, <<"WILE_E_COYOTE">>,
			 #{max_age => 86417}),
	ok.

setcookie_failures_test_() ->
	F = fun(N, V) ->
		try setcookie(N, V, #{}) of
			_ ->
				false
		catch _:_ ->
			true
		end
	end,
	Tests = [
		{<<"Na=me">>, <<"Value">>},
		{<<"Name;">>, <<"Value">>},
		{<<"\r\name">>, <<"Value">>},
		{<<"Name">>, <<"Value;">>},
		{<<"Name">>, <<"\value">>}
	],
	[{iolist_to_binary(io_lib:format("{~p, ~p} failure", [N, V])),
		fun() -> true = F(N, V) end}
		|| {N, V} <- Tests].
-endif.