aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLoïc Hoguin <[email protected]>2020-03-02 14:31:02 +0100
committerLoïc Hoguin <[email protected]>2020-03-06 11:01:21 +0100
commit4b9da5965cd3c8fe93639eddcb8973407f86bbb7 (patch)
tree4adbc5e5b128591a25632eec074e6108fb12abcd
parentbbb6e57123ca12ad18e88f4a6e28b866afa3ed5a (diff)
downloadcowlib-4b9da5965cd3c8fe93639eddcb8973407f86bbb7.tar.gz
cowlib-4b9da5965cd3c8fe93639eddcb8973407f86bbb7.tar.bz2
cowlib-4b9da5965cd3c8fe93639eddcb8973407f86bbb7.zip
Add cow_cookie:parse_set_cookie/1
Also do minor fixes to cow_cookie:parse_cookie/1. There is a potential incompatibility from these changes, because now a header "Cookie: foo" will be translated to a cookie with an empty name and value "foo", instead of cookie name "foo" and empty value. Also cookie names starting with $ are no longer ignored. These fixes are necessary for the cookies test suite from Web platform tests to work, and match the upcoming cookie RFC.
-rw-r--r--doc/src/manual/cow_cookie.parse_cookie.asciidoc4
-rw-r--r--src/cow_cookie.erl177
2 files changed, 147 insertions, 34 deletions
diff --git a/doc/src/manual/cow_cookie.parse_cookie.asciidoc b/doc/src/manual/cow_cookie.parse_cookie.asciidoc
index 0b7393e..2975932 100644
--- a/doc/src/manual/cow_cookie.parse_cookie.asciidoc
+++ b/doc/src/manual/cow_cookie.parse_cookie.asciidoc
@@ -28,6 +28,10 @@ An exception is thrown in the event of a parse error.
== Changelog
+* *2.9*: Fixes to the parser may lead to potential incompatibilities.
+ A cookie name starting with `$` is no longer ignored.
+ A cookie without a `=` will be parsed as the value of
+ the cookie named `<<>>` (empty name).
* *1.0*: Function introduced.
== Examples
diff --git a/src/cow_cookie.erl b/src/cow_cookie.erl
index 6fd9ff3..d1ffbc3 100644
--- a/src/cow_cookie.erl
+++ b/src/cow_cookie.erl
@@ -1,4 +1,4 @@
-%% Copyright (c) 2013-2018, Loïc Hoguin <[email protected]>
+%% Copyright (c) 2013-2020, 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
@@ -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.