Browse Source

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.
Loïc Hoguin 5 years ago
parent
commit
4b9da5965c
2 changed files with 147 additions and 34 deletions
  1. 4 0
      doc/src/manual/cow_cookie.parse_cookie.asciidoc
  2. 143 34
      src/cow_cookie.erl

+ 4 - 0
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

+ 143 - 34
src/cow_cookie.erl

@@ -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.