|
@@ -1,4 +1,4 @@
|
|
|
-%% Copyright (c) 2013-2018, Loïc Hoguin <essen@ninenines.eu>
|
|
|
+%% Copyright (c) 2013-2020, Loïc Hoguin <essen@ninenines.eu>
|
|
|
%%
|
|
|
%% Permission to use, copy, modify, and/or distribute this software for any
|
|
|
%% purpose with or without fee is hereby granted, provided that the above
|
|
@@ -15,8 +15,20 @@
|
|
|
-module(cow_cookie).
|
|
|
|
|
|
-export([parse_cookie/1]).
|
|
|
+-export([parse_set_cookie/1]).
|
|
|
-export([setcookie/3]).
|
|
|
|
|
|
+-type cookie_attrs() :: #{
|
|
|
+ expires => calendar:datetime(),
|
|
|
+ max_age => calendar:datetime(),
|
|
|
+ domain => binary(),
|
|
|
+ path => binary(),
|
|
|
+ secure => true,
|
|
|
+ http_only => true,
|
|
|
+ same_site => strict | lax
|
|
|
+}.
|
|
|
+-export_type([cookie_attrs/0]).
|
|
|
+
|
|
|
-type cookie_opts() :: #{
|
|
|
domain => binary(),
|
|
|
http_only => boolean(),
|
|
@@ -27,7 +39,9 @@
|
|
|
}.
|
|
|
-export_type([cookie_opts/0]).
|
|
|
|
|
|
-%% @doc Parse a cookie header string and return a list of key/values.
|
|
|
+-include("cow_inline.hrl").
|
|
|
+
|
|
|
+%% Cookie header.
|
|
|
|
|
|
-spec parse_cookie(binary()) -> [{binary(), binary()}].
|
|
|
parse_cookie(Cookie) ->
|
|
@@ -43,22 +57,11 @@ 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]);
|
|
|
+ lists:reverse([{<<>>, parse_cookie_trim(Name)}|Acc]);
|
|
|
parse_cookie_name(<< $=, _/binary >>, _, <<>>) ->
|
|
|
error(badarg);
|
|
|
parse_cookie_name(<< $=, Rest/binary >>, Acc, Name) ->
|
|
@@ -66,9 +69,7 @@ parse_cookie_name(<< $=, Rest/binary >>, 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(Rest, [{<<>>, parse_cookie_trim(Name)}|Acc]);
|
|
|
parse_cookie_name(<< $\t, _/binary >>, _, _) ->
|
|
|
error(badarg);
|
|
|
parse_cookie_name(<< $\r, _/binary >>, _, _) ->
|
|
@@ -119,16 +120,6 @@ parse_cookie_test_() ->
|
|
|
{<<"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)">>}]},
|
|
@@ -160,23 +151,141 @@ parse_cookie_test_() ->
|
|
|
{<<>>, []}, %% 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">>}]}
|
|
|
+ {<<"foo">>, [{<<>>, <<"foo">>}]},
|
|
|
+ {<<"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
|
|
|
+%% Set-Cookie header.
|
|
|
+
|
|
|
+-spec parse_set_cookie(binary())
|
|
|
+ -> {ok, binary(), binary(), cookie_attrs()}
|
|
|
+ | ignore.
|
|
|
+parse_set_cookie(SetCookie) ->
|
|
|
+ {NameValuePair, UnparsedAttrs} = take_until_semicolon(SetCookie, <<>>),
|
|
|
+ {Name, Value} = case binary:split(NameValuePair, <<$=>>) of
|
|
|
+ [Value0] -> {<<>>, trim(Value0)};
|
|
|
+ [Name0, Value0] -> {trim(Name0), trim(Value0)}
|
|
|
+ end,
|
|
|
+ case {Name, Value} of
|
|
|
+ {<<>>, <<>>} ->
|
|
|
+ ignore;
|
|
|
+ _ ->
|
|
|
+ Attrs = parse_set_cookie_attrs(UnparsedAttrs, #{}),
|
|
|
+ {ok, Name, Value, Attrs}
|
|
|
+ end.
|
|
|
+
|
|
|
+parse_set_cookie_attrs(<<>>, Attrs) ->
|
|
|
+ Attrs;
|
|
|
+parse_set_cookie_attrs(<<$;,Rest0/bits>>, Attrs) ->
|
|
|
+ {Av, Rest} = take_until_semicolon(Rest0, <<>>),
|
|
|
+ {Name, Value} = case binary:split(Av, <<$=>>) of
|
|
|
+ [Name0] -> {trim(Name0), <<>>};
|
|
|
+ [Name0, Value0] -> {trim(Name0), trim(Value0)}
|
|
|
+ end,
|
|
|
+ case parse_set_cookie_attr(?LOWER(Name), Value) of
|
|
|
+ {ok, AttrName, AttrValue} ->
|
|
|
+ parse_set_cookie_attrs(Rest, Attrs#{AttrName => AttrValue});
|
|
|
+ {ignore, AttrName} ->
|
|
|
+ parse_set_cookie_attrs(Rest, maps:remove(AttrName, Attrs));
|
|
|
+ ignore ->
|
|
|
+ parse_set_cookie_attrs(Rest, Attrs)
|
|
|
+ end.
|
|
|
+
|
|
|
+take_until_semicolon(Rest = <<$;,_/bits>>, Acc) -> {Acc, Rest};
|
|
|
+take_until_semicolon(<<C,R/bits>>, Acc) -> take_until_semicolon(R, <<Acc/binary,C>>);
|
|
|
+take_until_semicolon(<<>>, Acc) -> {Acc, <<>>}.
|
|
|
+
|
|
|
+trim(String) ->
|
|
|
+ string:trim(String, both, [$\s, $\t]).
|
|
|
+
|
|
|
+parse_set_cookie_attr(<<"expires">>, Value) ->
|
|
|
+ try cow_date:parse_date(Value) of
|
|
|
+ DateTime ->
|
|
|
+ {ok, expires, DateTime}
|
|
|
+ catch _:_ ->
|
|
|
+ ignore
|
|
|
+ end;
|
|
|
+parse_set_cookie_attr(<<"max-age">>, Value) ->
|
|
|
+ try binary_to_integer(Value) of
|
|
|
+ MaxAge when MaxAge =< 0 ->
|
|
|
+ %% Year 0 corresponds to 1 BC.
|
|
|
+ {ok, max_age, {{0, 1, 1}, {0, 0, 0}}};
|
|
|
+ MaxAge ->
|
|
|
+ CurrentTime = erlang:universaltime(),
|
|
|
+ {ok, max_age, calendar:gregorian_seconds_to_datetime(
|
|
|
+ calendar:datetime_to_gregorian_seconds(CurrentTime) + MaxAge)}
|
|
|
+ catch _:_ ->
|
|
|
+ ignore
|
|
|
+ end;
|
|
|
+parse_set_cookie_attr(<<"domain">>, Value) ->
|
|
|
+ case Value of
|
|
|
+ <<>> ->
|
|
|
+ {ignore, domain};
|
|
|
+ <<".",Rest/bits>> ->
|
|
|
+ {ok, domain, ?LOWER(Rest)};
|
|
|
+ _ ->
|
|
|
+ {ok, domain, ?LOWER(Value)}
|
|
|
+ end;
|
|
|
+parse_set_cookie_attr(<<"path">>, Value) ->
|
|
|
+ case Value of
|
|
|
+ <<"/",_/bits>> ->
|
|
|
+ {ok, path, Value};
|
|
|
+ %% When the path is not absolute, or the path is empty, the default-path will be used.
|
|
|
+ %% Note that the default-path is also used when there are no path attributes,
|
|
|
+ %% so we are simply ignoring the attribute here.
|
|
|
+ _ ->
|
|
|
+ {ignore, path}
|
|
|
+ end;
|
|
|
+parse_set_cookie_attr(<<"secure">>, _) ->
|
|
|
+ {ok, secure, true};
|
|
|
+parse_set_cookie_attr(<<"httponly">>, _) ->
|
|
|
+ {ok, http_only, true};
|
|
|
+parse_set_cookie_attr(<<"samesite">>, Value) ->
|
|
|
+ case ?LOWER(Value) of
|
|
|
+ <<"strict">> ->
|
|
|
+ {ok, same_site, strict};
|
|
|
+ <<"lax">> ->
|
|
|
+ {ok, same_site, lax};
|
|
|
+ %% Value "none", unknown values and lack of value are equivalent.
|
|
|
+ _ ->
|
|
|
+ ignore
|
|
|
+ end;
|
|
|
+parse_set_cookie_attr(_, _) ->
|
|
|
+ ignore.
|
|
|
+
|
|
|
+-ifdef(TEST).
|
|
|
+parse_set_cookie_test_() ->
|
|
|
+ Tests = [
|
|
|
+ {<<"a=b">>, {ok, <<"a">>, <<"b">>, #{}}},
|
|
|
+ {<<"a=b; Secure">>, {ok, <<"a">>, <<"b">>, #{secure => true}}},
|
|
|
+ {<<"a=b; HttpOnly">>, {ok, <<"a">>, <<"b">>, #{http_only => true}}},
|
|
|
+ {<<"a=b; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Expires=Wed, 21 Oct 2015 07:29:00 GMT">>,
|
|
|
+ {ok, <<"a">>, <<"b">>, #{expires => {{2015,10,21},{7,29,0}}}}},
|
|
|
+ {<<"a=b; Max-Age=999; Max-Age=0">>,
|
|
|
+ {ok, <<"a">>, <<"b">>, #{max_age => {{0,1,1},{0,0,0}}}}},
|
|
|
+ {<<"a=b; Domain=example.org; Domain=foo.example.org">>,
|
|
|
+ {ok, <<"a">>, <<"b">>, #{domain => <<"foo.example.org">>}}},
|
|
|
+ {<<"a=b; Path=/path/to/resource; Path=/">>,
|
|
|
+ {ok, <<"a">>, <<"b">>, #{path => <<"/">>}}},
|
|
|
+ {<<"a=b; SameSite=Lax; SameSite=Strict">>,
|
|
|
+ {ok, <<"a">>, <<"b">>, #{same_site => strict}}}
|
|
|
+ ],
|
|
|
+ [{SetCookie, fun() -> Res = parse_set_cookie(SetCookie) end}
|
|
|
+ || {SetCookie, Res} <- Tests].
|
|
|
+-endif.
|
|
|
+
|
|
|
+%% Convert a cookie name, value and options to its iodata form.
|
|
|
%%
|
|
|
%% Initially from Mochiweb:
|
|
|
%% * Copyright 2007 Mochi Media, Inc.
|