%% Copyright (c) 2013-2018, 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(cow_cookie). -export([parse_cookie/1]). -export([setcookie/3]). -type cookie_option() :: {max_age, non_neg_integer()} | {domain, binary()} | {path, binary()} | {secure, boolean()} | {http_only, boolean()} | {same_site, lax | strict}. -type cookie_opts() :: [cookie_option()]. -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 -spec setcookie(iodata(), iodata(), cookie_opts()) -> iodata(). 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>>]), MaxAgeBin = case lists:keyfind(max_age, 1, Opts) of false -> <<>>; {_, 0} -> %% MSIE requires an Expires date in the past to delete a cookie. <<"; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Max-Age=0">>; {_, MaxAge} when is_integer(MaxAge), MaxAge > 0 -> UTC = calendar:universal_time(), Secs = calendar:datetime_to_gregorian_seconds(UTC), Expires = calendar:gregorian_seconds_to_datetime(Secs + MaxAge), [<<"; Expires=">>, cow_date:rfc2109(Expires), <<"; Max-Age=">>, integer_to_list(MaxAge)] end, DomainBin = case lists:keyfind(domain, 1, Opts) of false -> <<>>; {_, Domain} -> [<<"; Domain=">>, Domain] end, PathBin = case lists:keyfind(path, 1, Opts) of false -> <<>>; {_, Path} -> [<<"; Path=">>, Path] end, SecureBin = case lists:keyfind(secure, 1, Opts) of false -> <<>>; {_, false} -> <<>>; {_, true} -> <<"; Secure">> end, HttpOnlyBin = case lists:keyfind(http_only, 1, Opts) of false -> <<>>; {_, false} -> <<>>; {_, true} -> <<"; HttpOnly">> end, SameSiteBin = case lists:keyfind(same_site, 1, Opts) of false -> <<>>; {_, lax} -> <<"; SameSite=Lax">>; {_, strict} -> <<"; SameSite=Strict">> end, [Name, <<"=">>, Value, <<"; Version=1">>, MaxAgeBin, DomainBin, PathBin, SecureBin, HttpOnlyBin, SameSiteBin]. -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', {{case_clause, {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.