cow_cookie.erl (14693B)
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 | none 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 => strict | lax | none, 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()}]. 48 parse_cookie(Cookie) -> 49 parse_cookie(Cookie, []). 50 51 parse_cookie(<<>>, Acc) -> 52 lists:reverse(Acc); 53 parse_cookie(<< $\s, Rest/binary >>, Acc) -> 54 parse_cookie(Rest, Acc); 55 parse_cookie(<< $\t, Rest/binary >>, Acc) -> 56 parse_cookie(Rest, Acc); 57 parse_cookie(<< $,, Rest/binary >>, Acc) -> 58 parse_cookie(Rest, Acc); 59 parse_cookie(<< $;, Rest/binary >>, Acc) -> 60 parse_cookie(Rest, Acc); 61 parse_cookie(Cookie, Acc) -> 62 parse_cookie_name(Cookie, Acc, <<>>). 63 64 parse_cookie_name(<<>>, Acc, Name) -> 65 lists:reverse([{<<>>, parse_cookie_trim(Name)}|Acc]); 66 parse_cookie_name(<< $=, _/binary >>, _, <<>>) -> 67 error(badarg); 68 parse_cookie_name(<< $=, Rest/binary >>, Acc, Name) -> 69 parse_cookie_value(Rest, Acc, Name, <<>>); 70 parse_cookie_name(<< $,, _/binary >>, _, _) -> 71 error(badarg); 72 parse_cookie_name(<< $;, Rest/binary >>, Acc, Name) -> 73 parse_cookie(Rest, [{<<>>, parse_cookie_trim(Name)}|Acc]); 74 parse_cookie_name(<< $\t, _/binary >>, _, _) -> 75 error(badarg); 76 parse_cookie_name(<< $\r, _/binary >>, _, _) -> 77 error(badarg); 78 parse_cookie_name(<< $\n, _/binary >>, _, _) -> 79 error(badarg); 80 parse_cookie_name(<< $\013, _/binary >>, _, _) -> 81 error(badarg); 82 parse_cookie_name(<< $\014, _/binary >>, _, _) -> 83 error(badarg); 84 parse_cookie_name(<< C, Rest/binary >>, Acc, Name) -> 85 parse_cookie_name(Rest, Acc, << Name/binary, C >>). 86 87 parse_cookie_value(<<>>, Acc, Name, Value) -> 88 lists:reverse([{Name, parse_cookie_trim(Value)}|Acc]); 89 parse_cookie_value(<< $;, Rest/binary >>, Acc, Name, Value) -> 90 parse_cookie(Rest, [{Name, parse_cookie_trim(Value)}|Acc]); 91 parse_cookie_value(<< $\t, _/binary >>, _, _, _) -> 92 error(badarg); 93 parse_cookie_value(<< $\r, _/binary >>, _, _, _) -> 94 error(badarg); 95 parse_cookie_value(<< $\n, _/binary >>, _, _, _) -> 96 error(badarg); 97 parse_cookie_value(<< $\013, _/binary >>, _, _, _) -> 98 error(badarg); 99 parse_cookie_value(<< $\014, _/binary >>, _, _, _) -> 100 error(badarg); 101 parse_cookie_value(<< C, Rest/binary >>, Acc, Name, Value) -> 102 parse_cookie_value(Rest, Acc, Name, << Value/binary, C >>). 103 104 parse_cookie_trim(Value = <<>>) -> 105 Value; 106 parse_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). 117 parse_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 162 parse_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. 175 parse_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 189 parse_set_cookie_attrs(<<>>, Attrs) -> 190 Attrs; 191 parse_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 206 take_until_semicolon(Rest = <<$;,_/bits>>, Acc) -> {Acc, Rest}; 207 take_until_semicolon(<<C,R/bits>>, Acc) -> take_until_semicolon(R, <<Acc/binary,C>>); 208 take_until_semicolon(<<>>, Acc) -> {Acc, <<>>}. 209 210 trim(String) -> 211 string:trim(String, both, [$\s, $\t]). 212 213 parse_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; 220 parse_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; 232 parse_set_cookie_attr(<<"domain">>, Value) -> 233 case Value of 234 <<>> -> 235 ignore; 236 <<".",Rest/bits>> -> 237 {ok, domain, ?LOWER(Rest)}; 238 _ -> 239 {ok, domain, ?LOWER(Value)} 240 end; 241 parse_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; 251 parse_set_cookie_attr(<<"secure">>, _) -> 252 {ok, secure, true}; 253 parse_set_cookie_attr(<<"httponly">>, _) -> 254 {ok, http_only, true}; 255 parse_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 %% Clients may have different defaults than "None". 262 <<"none">> -> 263 {ok, same_site, none}; 264 %% Unknown values and lack of value are equivalent. 265 _ -> 266 ignore 267 end; 268 parse_set_cookie_attr(_, _) -> 269 ignore. 270 271 -ifdef(TEST). 272 parse_set_cookie_test_() -> 273 Tests = [ 274 {<<"a=b">>, {ok, <<"a">>, <<"b">>, #{}}}, 275 {<<"a=b; Secure">>, {ok, <<"a">>, <<"b">>, #{secure => true}}}, 276 {<<"a=b; HttpOnly">>, {ok, <<"a">>, <<"b">>, #{http_only => true}}}, 277 {<<"a=b; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Expires=Wed, 21 Oct 2015 07:29:00 GMT">>, 278 {ok, <<"a">>, <<"b">>, #{expires => {{2015,10,21},{7,29,0}}}}}, 279 {<<"a=b; Max-Age=999; Max-Age=0">>, 280 {ok, <<"a">>, <<"b">>, #{max_age => {{0,1,1},{0,0,0}}}}}, 281 {<<"a=b; Domain=example.org; Domain=foo.example.org">>, 282 {ok, <<"a">>, <<"b">>, #{domain => <<"foo.example.org">>}}}, 283 {<<"a=b; Path=/path/to/resource; Path=/">>, 284 {ok, <<"a">>, <<"b">>, #{path => <<"/">>}}}, 285 {<<"a=b; SameSite=Lax; SameSite=Strict">>, 286 {ok, <<"a">>, <<"b">>, #{same_site => strict}}} 287 ], 288 [{SetCookie, fun() -> Res = parse_set_cookie(SetCookie) end} 289 || {SetCookie, Res} <- Tests]. 290 -endif. 291 292 %% Build a cookie header. 293 294 -spec cookie([{iodata(), iodata()}]) -> iolist(). 295 cookie([]) -> 296 []; 297 cookie([{<<>>, Value}]) -> 298 [Value]; 299 cookie([{Name, Value}]) -> 300 [Name, $=, Value]; 301 cookie([{<<>>, Value}|Tail]) -> 302 [Value, $;, $\s|cookie(Tail)]; 303 cookie([{Name, Value}|Tail]) -> 304 [Name, $=, Value, $;, $\s|cookie(Tail)]. 305 306 -ifdef(TEST). 307 cookie_test_() -> 308 Tests = [ 309 {[], <<>>}, 310 {[{<<"a">>, <<"b">>}], <<"a=b">>}, 311 {[{<<"a">>, <<"b">>}, {<<"c">>, <<"d">>}], <<"a=b; c=d">>}, 312 {[{<<>>, <<"b">>}, {<<"c">>, <<"d">>}], <<"b; c=d">>}, 313 {[{<<"a">>, <<"b">>}, {<<>>, <<"d">>}], <<"a=b; d">>} 314 ], 315 [{Res, fun() -> Res = iolist_to_binary(cookie(Cookies)) end} 316 || {Cookies, Res} <- Tests]. 317 -endif. 318 319 %% Convert a cookie name, value and options to its iodata form. 320 %% 321 %% Initially from Mochiweb: 322 %% * Copyright 2007 Mochi Media, Inc. 323 %% Initial binary implementation: 324 %% * Copyright 2011 Thomas Burdick <thomas.burdick@gmail.com> 325 %% 326 %% @todo Rename the function to set_cookie eventually. 327 328 -spec setcookie(iodata(), iodata(), cookie_opts()) -> iolist(). 329 setcookie(Name, Value, Opts) -> 330 nomatch = binary:match(iolist_to_binary(Name), [<<$=>>, <<$,>>, <<$;>>, 331 <<$\s>>, <<$\t>>, <<$\r>>, <<$\n>>, <<$\013>>, <<$\014>>]), 332 nomatch = binary:match(iolist_to_binary(Value), [<<$,>>, <<$;>>, 333 <<$\s>>, <<$\t>>, <<$\r>>, <<$\n>>, <<$\013>>, <<$\014>>]), 334 [Name, <<"=">>, Value, <<"; Version=1">>, attributes(maps:to_list(Opts))]. 335 336 attributes([]) -> []; 337 attributes([{domain, Domain}|Tail]) -> [<<"; Domain=">>, Domain|attributes(Tail)]; 338 attributes([{http_only, false}|Tail]) -> attributes(Tail); 339 attributes([{http_only, true}|Tail]) -> [<<"; HttpOnly">>|attributes(Tail)]; 340 %% MSIE requires an Expires date in the past to delete a cookie. 341 attributes([{max_age, 0}|Tail]) -> 342 [<<"; Expires=Thu, 01-Jan-1970 00:00:01 GMT; Max-Age=0">>|attributes(Tail)]; 343 attributes([{max_age, MaxAge}|Tail]) when is_integer(MaxAge), MaxAge > 0 -> 344 Secs = calendar:datetime_to_gregorian_seconds(calendar:universal_time()), 345 Expires = cow_date:rfc2109(calendar:gregorian_seconds_to_datetime(Secs + MaxAge)), 346 [<<"; Expires=">>, Expires, <<"; Max-Age=">>, integer_to_list(MaxAge)|attributes(Tail)]; 347 attributes([Opt={max_age, _}|_]) -> 348 error({badarg, Opt}); 349 attributes([{path, Path}|Tail]) -> [<<"; Path=">>, Path|attributes(Tail)]; 350 attributes([{secure, false}|Tail]) -> attributes(Tail); 351 attributes([{secure, true}|Tail]) -> [<<"; Secure">>|attributes(Tail)]; 352 attributes([{same_site, lax}|Tail]) -> [<<"; SameSite=Lax">>|attributes(Tail)]; 353 attributes([{same_site, strict}|Tail]) -> [<<"; SameSite=Strict">>|attributes(Tail)]; 354 attributes([{same_site, none}|Tail]) -> [<<"; SameSite=None">>|attributes(Tail)]; 355 %% Skip unknown options. 356 attributes([_|Tail]) -> attributes(Tail). 357 358 -ifdef(TEST). 359 setcookie_test_() -> 360 %% {Name, Value, Opts, Result} 361 Tests = [ 362 {<<"Customer">>, <<"WILE_E_COYOTE">>, 363 #{http_only => true, domain => <<"acme.com">>}, 364 <<"Customer=WILE_E_COYOTE; Version=1; " 365 "Domain=acme.com; HttpOnly">>}, 366 {<<"Customer">>, <<"WILE_E_COYOTE">>, 367 #{path => <<"/acme">>}, 368 <<"Customer=WILE_E_COYOTE; Version=1; Path=/acme">>}, 369 {<<"Customer">>, <<"WILE_E_COYOTE">>, 370 #{secure => true}, 371 <<"Customer=WILE_E_COYOTE; Version=1; Secure">>}, 372 {<<"Customer">>, <<"WILE_E_COYOTE">>, 373 #{secure => false, http_only => false}, 374 <<"Customer=WILE_E_COYOTE; Version=1">>}, 375 {<<"Customer">>, <<"WILE_E_COYOTE">>, 376 #{same_site => lax}, 377 <<"Customer=WILE_E_COYOTE; Version=1; SameSite=Lax">>}, 378 {<<"Customer">>, <<"WILE_E_COYOTE">>, 379 #{same_site => strict}, 380 <<"Customer=WILE_E_COYOTE; Version=1; SameSite=Strict">>}, 381 {<<"Customer">>, <<"WILE_E_COYOTE">>, 382 #{path => <<"/acme">>, badoption => <<"negatory">>}, 383 <<"Customer=WILE_E_COYOTE; Version=1; Path=/acme">>} 384 ], 385 [{R, fun() -> R = iolist_to_binary(setcookie(N, V, O)) end} 386 || {N, V, O, R} <- Tests]. 387 388 setcookie_max_age_test() -> 389 F = fun(N, V, O) -> 390 binary:split(iolist_to_binary( 391 setcookie(N, V, O)), <<";">>, [global]) 392 end, 393 [<<"Customer=WILE_E_COYOTE">>, 394 <<" Version=1">>, 395 <<" Expires=", _/binary>>, 396 <<" Max-Age=111">>, 397 <<" Secure">>] = F(<<"Customer">>, <<"WILE_E_COYOTE">>, 398 #{max_age => 111, secure => true}), 399 case catch F(<<"Customer">>, <<"WILE_E_COYOTE">>, #{max_age => -111}) of 400 {'EXIT', {{badarg, {max_age, -111}}, _}} -> ok 401 end, 402 [<<"Customer=WILE_E_COYOTE">>, 403 <<" Version=1">>, 404 <<" Expires=", _/binary>>, 405 <<" Max-Age=86417">>] = F(<<"Customer">>, <<"WILE_E_COYOTE">>, 406 #{max_age => 86417}), 407 ok. 408 409 setcookie_failures_test_() -> 410 F = fun(N, V) -> 411 try setcookie(N, V, #{}) of 412 _ -> 413 false 414 catch _:_ -> 415 true 416 end 417 end, 418 Tests = [ 419 {<<"Na=me">>, <<"Value">>}, 420 {<<"Name;">>, <<"Value">>}, 421 {<<"\r\name">>, <<"Value">>}, 422 {<<"Name">>, <<"Value;">>}, 423 {<<"Name">>, <<"\value">>} 424 ], 425 [{iolist_to_binary(io_lib:format("{~p, ~p} failure", [N, V])), 426 fun() -> true = F(N, V) end} 427 || {N, V} <- Tests]. 428 -endif.