1%% Copyright (c) 2013-2020, Loïc Hoguin <essen@ninenines.eu> 2%% 3%% Permission to use, copy, modify, and/or distribute this software for any 4%% purpose with or without fee is hereby granted, provided that the above 5%% copyright notice and this permission notice appear in all copies. 6%% 7%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 15-module(cow_cookie). 16 17-export([parse_cookie/1]). 18-export([parse_set_cookie/1]). 19-export([cookie/1]). 20-export([setcookie/3]). 21 22-type cookie_attrs() :: #{ 23 expires => calendar:datetime(), 24 max_age => calendar:datetime(), 25 domain => binary(), 26 path => binary(), 27 secure => true, 28 http_only => true, 29 same_site => strict | lax 30}. 31-export_type([cookie_attrs/0]). 32 33-type cookie_opts() :: #{ 34 domain => binary(), 35 http_only => boolean(), 36 max_age => non_neg_integer(), 37 path => binary(), 38 same_site => lax | strict, 39 secure => boolean() 40}. 41-export_type([cookie_opts/0]). 42 43-include("cow_inline.hrl"). 44 45%% Cookie header. 46 47-spec parse_cookie(binary()) -> [{binary(), binary()}]. 48parse_cookie(Cookie) -> 49 parse_cookie(Cookie, []). 50 51parse_cookie(<<>>, Acc) -> 52 lists:reverse(Acc); 53parse_cookie(<< $\s, Rest/binary >>, Acc) -> 54 parse_cookie(Rest, Acc); 55parse_cookie(<< $\t, Rest/binary >>, Acc) -> 56 parse_cookie(Rest, Acc); 57parse_cookie(<< $,, Rest/binary >>, Acc) -> 58 parse_cookie(Rest, Acc); 59parse_cookie(<< $;, Rest/binary >>, Acc) -> 60 parse_cookie(Rest, Acc); 61parse_cookie(Cookie, Acc) -> 62 parse_cookie_name(Cookie, Acc, <<>>). 63 64parse_cookie_name(<<>>, Acc, Name) -> 65 lists:reverse([{<<>>, parse_cookie_trim(Name)}|Acc]); 66parse_cookie_name(<< $=, _/binary >>, _, <<>>) -> 67 error(badarg); 68parse_cookie_name(<< $=, Rest/binary >>, Acc, Name) -> 69 parse_cookie_value(Rest, Acc, Name, <<>>); 70parse_cookie_name(<< $,, _/binary >>, _, _) -> 71 error(badarg); 72parse_cookie_name(<< $;, Rest/binary >>, Acc, Name) -> 73 parse_cookie(Rest, [{<<>>, parse_cookie_trim(Name)}|Acc]); 74parse_cookie_name(<< $\t, _/binary >>, _, _) -> 75 error(badarg); 76parse_cookie_name(<< $\r, _/binary >>, _, _) -> 77 error(badarg); 78parse_cookie_name(<< $\n, _/binary >>, _, _) -> 79 error(badarg); 80parse_cookie_name(<< $\013, _/binary >>, _, _) -> 81 error(badarg); 82parse_cookie_name(<< $\014, _/binary >>, _, _) -> 83 error(badarg); 84parse_cookie_name(<< C, Rest/binary >>, Acc, Name) -> 85 parse_cookie_name(Rest, Acc, << Name/binary, C >>). 86 87parse_cookie_value(<<>>, Acc, Name, Value) -> 88 lists:reverse([{Name, parse_cookie_trim(Value)}|Acc]); 89parse_cookie_value(<< $;, Rest/binary >>, Acc, Name, Value) -> 90 parse_cookie(Rest, [{Name, parse_cookie_trim(Value)}|Acc]); 91parse_cookie_value(<< $\t, _/binary >>, _, _, _) -> 92 error(badarg); 93parse_cookie_value(<< $\r, _/binary >>, _, _, _) -> 94 error(badarg); 95parse_cookie_value(<< $\n, _/binary >>, _, _, _) -> 96 error(badarg); 97parse_cookie_value(<< $\013, _/binary >>, _, _, _) -> 98 error(badarg); 99parse_cookie_value(<< $\014, _/binary >>, _, _, _) -> 100 error(badarg); 101parse_cookie_value(<< C, Rest/binary >>, Acc, Name, Value) -> 102 parse_cookie_value(Rest, Acc, Name, << Value/binary, C >>). 103 104parse_cookie_trim(Value = <<>>) -> 105 Value; 106parse_cookie_trim(Value) -> 107 case binary:last(Value) of 108 $\s -> 109 Size = byte_size(Value) - 1, 110 << Value2:Size/binary, _ >> = Value, 111 parse_cookie_trim(Value2); 112 _ -> 113 Value 114 end. 115 116-ifdef(TEST). 117parse_cookie_test_() -> 118 %% {Value, Result}. 119 Tests = [ 120 {<<"name=value; name2=value2">>, [ 121 {<<"name">>, <<"value">>}, 122 {<<"name2">>, <<"value2">>} 123 ]}, 124 %% Space in value. 125 {<<"foo=Thu Jul 11 2013 15:38:43 GMT+0400 (MSK)">>, 126 [{<<"foo">>, <<"Thu Jul 11 2013 15:38:43 GMT+0400 (MSK)">>}]}, 127 %% Comma in value. Google Analytics sets that kind of cookies. 128 {<<"refk=sOUZDzq2w2; sk=B602064E0139D842D620C7569640DBB4C81C45080651" 129 "9CC124EF794863E10E80; __utma=64249653.825741573.1380181332.1400" 130 "015657.1400019557.703; __utmb=64249653.1.10.1400019557; __utmc=" 131 "64249653; __utmz=64249653.1400019557.703.13.utmcsr=bluesky.chic" 132 "agotribune.com|utmccn=(referral)|utmcmd=referral|utmcct=/origin" 133 "als/chi-12-indispensable-digital-tools-bsi,0,0.storygallery">>, [ 134 {<<"refk">>, <<"sOUZDzq2w2">>}, 135 {<<"sk">>, <<"B602064E0139D842D620C7569640DBB4C81C45080651" 136 "9CC124EF794863E10E80">>}, 137 {<<"__utma">>, <<"64249653.825741573.1380181332.1400" 138 "015657.1400019557.703">>}, 139 {<<"__utmb">>, <<"64249653.1.10.1400019557">>}, 140 {<<"__utmc">>, <<"64249653">>}, 141 {<<"__utmz">>, <<"64249653.1400019557.703.13.utmcsr=bluesky.chic" 142 "agotribune.com|utmccn=(referral)|utmcmd=referral|utmcct=/origin" 143 "als/chi-12-indispensable-digital-tools-bsi,0,0.storygallery">>} 144 ]}, 145 %% Potential edge cases (initially from Mochiweb). 146 {<<"foo=\\x">>, [{<<"foo">>, <<"\\x">>}]}, 147 {<<"foo=;bar=">>, [{<<"foo">>, <<>>}, {<<"bar">>, <<>>}]}, 148 {<<"foo=\\\";;bar=good ">>, 149 [{<<"foo">>, <<"\\\"">>}, {<<"bar">>, <<"good">>}]}, 150 {<<"foo=\"\\\";bar=good">>, 151 [{<<"foo">>, <<"\"\\\"">>}, {<<"bar">>, <<"good">>}]}, 152 {<<>>, []}, %% Flash player. 153 {<<"foo=bar , baz=wibble ">>, [{<<"foo">>, <<"bar , baz=wibble">>}]}, 154 %% Technically invalid, but seen in the wild 155 {<<"foo">>, [{<<>>, <<"foo">>}]}, 156 {<<"foo ">>, [{<<>>, <<"foo">>}]}, 157 {<<"foo;">>, [{<<>>, <<"foo">>}]}, 158 {<<"bar;foo=1">>, [{<<>>, <<"bar">>}, {<<"foo">>, <<"1">>}]} 159 ], 160 [{V, fun() -> R = parse_cookie(V) end} || {V, R} <- Tests]. 161 162parse_cookie_error_test_() -> 163 %% Value. 164 Tests = [ 165 <<"=">> 166 ], 167 [{V, fun() -> {'EXIT', {badarg, _}} = (catch parse_cookie(V)) end} || V <- Tests]. 168-endif. 169 170%% Set-Cookie header. 171 172-spec parse_set_cookie(binary()) 173 -> {ok, binary(), binary(), cookie_attrs()} 174 | ignore. 175parse_set_cookie(SetCookie) -> 176 {NameValuePair, UnparsedAttrs} = take_until_semicolon(SetCookie, <<>>), 177 {Name, Value} = case binary:split(NameValuePair, <<$=>>) of 178 [Value0] -> {<<>>, trim(Value0)}; 179 [Name0, Value0] -> {trim(Name0), trim(Value0)} 180 end, 181 case {Name, Value} of 182 {<<>>, <<>>} -> 183 ignore; 184 _ -> 185 Attrs = parse_set_cookie_attrs(UnparsedAttrs, #{}), 186 {ok, Name, Value, Attrs} 187 end. 188 189parse_set_cookie_attrs(<<>>, Attrs) -> 190 Attrs; 191parse_set_cookie_attrs(<<$;,Rest0/bits>>, Attrs) -> 192 {Av, Rest} = take_until_semicolon(Rest0, <<>>), 193 {Name, Value} = case binary:split(Av, <<$=>>) of 194 [Name0] -> {trim(Name0), <<>>}; 195 [Name0, Value0] -> {trim(Name0), trim(Value0)} 196 end, 197 case parse_set_cookie_attr(?LOWER(Name), Value) of 198 {ok, AttrName, AttrValue} -> 199 parse_set_cookie_attrs(Rest, Attrs#{AttrName => AttrValue}); 200 {ignore, AttrName} -> 201 parse_set_cookie_attrs(Rest, maps:remove(AttrName, Attrs)); 202 ignore -> 203 parse_set_cookie_attrs(Rest, Attrs) 204 end. 205 206take_until_semicolon(Rest = <<$;,_/bits>>, Acc) -> {Acc, Rest}; 207take_until_semicolon(<<C,R/bits>>, Acc) -> take_until_semicolon(R, <<Acc/binary,C>>); 208take_until_semicolon(<<>>, Acc) -> {Acc, <<>>}. 209 210trim(String) -> 211 string:trim(String, both, [$\s, $\t]). 212 213parse_set_cookie_attr(<<"expires">>, Value) -> 214 try cow_date:parse_date(Value) of 215 DateTime -> 216 {ok, expires, DateTime} 217 catch _:_ -> 218 ignore 219 end; 220parse_set_cookie_attr(<<"max-age">>, Value) -> 221 try binary_to_integer(Value) of 222 MaxAge when MaxAge =< 0 -> 223 %% Year 0 corresponds to 1 BC. 224 {ok, max_age, {{0, 1, 1}, {0, 0, 0}}}; 225 MaxAge -> 226 CurrentTime = erlang:universaltime(), 227 {ok, max_age, calendar:gregorian_seconds_to_datetime( 228 calendar:datetime_to_gregorian_seconds(CurrentTime) + MaxAge)} 229 catch _:_ -> 230 ignore 231 end; 232parse_set_cookie_attr(<<"domain">>, Value) -> 233 case Value of 234 <<>> -> 235 {ignore, domain}; 236 <<".",Rest/bits>> -> 237 {ok, domain, ?LOWER(Rest)}; 238 _ -> 239 {ok, domain, ?LOWER(Value)} 240 end; 241parse_set_cookie_attr(<<"path">>, Value) -> 242 case Value of 243 <<"/",_/bits>> -> 244 {ok, path, Value}; 245 %% When the path is not absolute, or the path is empty, the default-path will be used. 246 %% Note that the default-path is also used when there are no path attributes, 247 %% so we are simply ignoring the attribute here. 248 _ -> 249 {ignore, path} 250 end; 251parse_set_cookie_attr(<<"secure">>, _) -> 252 {ok, secure, true}; 253parse_set_cookie_attr(<<"httponly">>, _) -> 254 {ok, http_only, true}; 255parse_set_cookie_attr(<<"samesite">>, Value) -> 256 case ?LOWER(Value) of 257 <<"strict">> -> 258 {ok, same_site, strict}; 259 <<"lax">> -> 260 {ok, same_site, lax}; 261 %% Value "none", unknown values and lack of value are equivalent. 262 _ -> 263 ignore 264 end; 265parse_set_cookie_attr(_, _) -> 266 ignore. 267 268-ifdef(TEST). 269parse_set_cookie_test_() -> 270 Tests = [ 271 {<<"a=b">>, {ok, <<"a">>, <<"b">>, #{}}}, 272 {<<"a=b; Secure">>, {ok, <<"a">>, <<"b">>, #{secure => true}}}, 273 {<<"a=b; HttpOnly">>, {ok, <<"a">>, <<"b">>, #{http_only => true}}}, 274 {<<"a=b; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Expires=Wed, 21 Oct 2015 07:29:00 GMT">>, 275 {ok, <<"a">>, <<"b">>, #{expires => {{2015,10,21},{7,29,0}}}}}, 276 {<<"a=b; Max-Age=999; Max-Age=0">>, 277 {ok, <<"a">>, <<"b">>, #{max_age => {{0,1,1},{0,0,0}}}}}, 278 {<<"a=b; Domain=example.org; Domain=foo.example.org">>, 279 {ok, <<"a">>, <<"b">>, #{domain => <<"foo.example.org">>}}}, 280 {<<"a=b; Path=/path/to/resource; Path=/">>, 281 {ok, <<"a">>, <<"b">>, #{path => <<"/">>}}}, 282 {<<"a=b; SameSite=Lax; SameSite=Strict">>, 283 {ok, <<"a">>, <<"b">>, #{same_site => strict}}} 284 ], 285 [{SetCookie, fun() -> Res = parse_set_cookie(SetCookie) end} 286 || {SetCookie, Res} <- Tests]. 287-endif. 288 289%% Build a cookie header. 290 291-spec cookie([{iodata(), iodata()}]) -> iolist(). 292cookie([]) -> 293 []; 294cookie([{<<>>, Value}]) -> 295 [Value]; 296cookie([{Name, Value}]) -> 297 [Name, $=, Value]; 298cookie([{<<>>, Value}|Tail]) -> 299 [Value, $;, $\s|cookie(Tail)]; 300cookie([{Name, Value}|Tail]) -> 301 [Name, $=, Value, $;, $\s|cookie(Tail)]. 302 303-ifdef(TEST). 304cookie_test_() -> 305 Tests = [ 306 {[], <<>>}, 307 {[{<<"a">>, <<"b">>}], <<"a=b">>}, 308 {[{<<"a">>, <<"b">>}, {<<"c">>, <<"d">>}], <<"a=b; c=d">>}, 309 {[{<<>>, <<"b">>}, {<<"c">>, <<"d">>}], <<"b; c=d">>}, 310 {[{<<"a">>, <<"b">>}, {<<>>, <<"d">>}], <<"a=b; d">>} 311 ], 312 [{Res, fun() -> Res = iolist_to_binary(cookie(Cookies)) end} 313 || {Cookies, Res} <- Tests]. 314-endif. 315 316%% Convert a cookie name, value and options to its iodata form. 317%% 318%% Initially from Mochiweb: 319%% * Copyright 2007 Mochi Media, Inc. 320%% Initial binary implementation: 321%% * Copyright 2011 Thomas Burdick <thomas.burdick@gmail.com> 322%% 323%% @todo Rename the function to set_cookie eventually. 324 325-spec setcookie(iodata(), iodata(), cookie_opts()) -> iolist(). 326setcookie(Name, Value, Opts) -> 327 nomatch = binary:match(iolist_to_binary(Name), [<<$=>>, <<$,>>, <<$;>>, 328 <<$\s>>, <<$\t>>, <<$\r>>, <<$\n>>, <<$\013>>, <<$\014>>]), 329 nomatch = binary:match(iolist_to_binary(Value), [<<$,>>, <<$;>>, 330 <<$\s>>, <<$\t>>, <<$\r>>, <<$\n>>, <<$\013>>, <<$\014>>]), 331 [Name, <<"=">>, Value, <<"; Version=1">>, attributes(maps:to_list(Opts))]. 332 333attributes([]) -> []; 334attributes([{domain, Domain}|Tail]) -> [<<"; Domain=">>, Domain|attributes(Tail)]; 335attributes([{http_only, false}|Tail]) -> attributes(Tail); 336attributes([{http_only, true}|Tail]) -> [<<"; HttpOnly">>|attributes(Tail)]; 337%% MSIE requires an Expires date in the past to delete a cookie. 338attributes([{max_age, 0}|Tail]) -> 339 [<<"; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Max-Age=0">>|attributes(Tail)]; 340attributes([{max_age, MaxAge}|Tail]) when is_integer(MaxAge), MaxAge > 0 -> 341 Secs = calendar:datetime_to_gregorian_seconds(calendar:universal_time()), 342 Expires = cow_date:rfc2109(calendar:gregorian_seconds_to_datetime(Secs + MaxAge)), 343 [<<"; Expires=">>, Expires, <<"; Max-Age=">>, integer_to_list(MaxAge)|attributes(Tail)]; 344attributes([Opt={max_age, _}|_]) -> 345 error({badarg, Opt}); 346attributes([{path, Path}|Tail]) -> [<<"; Path=">>, Path|attributes(Tail)]; 347attributes([{secure, false}|Tail]) -> attributes(Tail); 348attributes([{secure, true}|Tail]) -> [<<"; Secure">>|attributes(Tail)]; 349attributes([{same_site, lax}|Tail]) -> [<<"; SameSite=Lax">>|attributes(Tail)]; 350attributes([{same_site, strict}|Tail]) -> [<<"; SameSite=Strict">>|attributes(Tail)]; 351%% Skip unknown options. 352attributes([_|Tail]) -> attributes(Tail). 353 354-ifdef(TEST). 355setcookie_test_() -> 356 %% {Name, Value, Opts, Result} 357 Tests = [ 358 {<<"Customer">>, <<"WILE_E_COYOTE">>, 359 #{http_only => true, domain => <<"acme.com">>}, 360 <<"Customer=WILE_E_COYOTE; Version=1; " 361 "Domain=acme.com; HttpOnly">>}, 362 {<<"Customer">>, <<"WILE_E_COYOTE">>, 363 #{path => <<"/acme">>}, 364 <<"Customer=WILE_E_COYOTE; Version=1; Path=/acme">>}, 365 {<<"Customer">>, <<"WILE_E_COYOTE">>, 366 #{secure => true}, 367 <<"Customer=WILE_E_COYOTE; Version=1; Secure">>}, 368 {<<"Customer">>, <<"WILE_E_COYOTE">>, 369 #{secure => false, http_only => false}, 370 <<"Customer=WILE_E_COYOTE; Version=1">>}, 371 {<<"Customer">>, <<"WILE_E_COYOTE">>, 372 #{same_site => lax}, 373 <<"Customer=WILE_E_COYOTE; Version=1; SameSite=Lax">>}, 374 {<<"Customer">>, <<"WILE_E_COYOTE">>, 375 #{same_site => strict}, 376 <<"Customer=WILE_E_COYOTE; Version=1; SameSite=Strict">>}, 377 {<<"Customer">>, <<"WILE_E_COYOTE">>, 378 #{path => <<"/acme">>, badoption => <<"negatory">>}, 379 <<"Customer=WILE_E_COYOTE; Version=1; Path=/acme">>} 380 ], 381 [{R, fun() -> R = iolist_to_binary(setcookie(N, V, O)) end} 382 || {N, V, O, R} <- Tests]. 383 384setcookie_max_age_test() -> 385 F = fun(N, V, O) -> 386 binary:split(iolist_to_binary( 387 setcookie(N, V, O)), <<";">>, [global]) 388 end, 389 [<<"Customer=WILE_E_COYOTE">>, 390 <<" Version=1">>, 391 <<" Expires=", _/binary>>, 392 <<" Max-Age=111">>, 393 <<" Secure">>] = F(<<"Customer">>, <<"WILE_E_COYOTE">>, 394 #{max_age => 111, secure => true}), 395 case catch F(<<"Customer">>, <<"WILE_E_COYOTE">>, #{max_age => -111}) of 396 {'EXIT', {{badarg, {max_age, -111}}, _}} -> ok 397 end, 398 [<<"Customer=WILE_E_COYOTE">>, 399 <<" Version=1">>, 400 <<" Expires=", _/binary>>, 401 <<" Max-Age=86417">>] = F(<<"Customer">>, <<"WILE_E_COYOTE">>, 402 #{max_age => 86417}), 403 ok. 404 405setcookie_failures_test_() -> 406 F = fun(N, V) -> 407 try setcookie(N, V, #{}) of 408 _ -> 409 false 410 catch _:_ -> 411 true 412 end 413 end, 414 Tests = [ 415 {<<"Na=me">>, <<"Value">>}, 416 {<<"Name;">>, <<"Value">>}, 417 {<<"\r\name">>, <<"Value">>}, 418 {<<"Name">>, <<"Value;">>}, 419 {<<"Name">>, <<"\value">>} 420 ], 421 [{iolist_to_binary(io_lib:format("{~p, ~p} failure", [N, V])), 422 fun() -> true = F(N, V) end} 423 || {N, V} <- Tests]. 424-endif. 425