From 4b9da5965cd3c8fe93639eddcb8973407f86bbb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hoguin?= Date: Mon, 2 Mar 2020 14:31:02 +0100 Subject: 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. --- doc/src/manual/cow_cookie.parse_cookie.asciidoc | 4 + src/cow_cookie.erl | 177 +++++++++++++++++++----- 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 +%% Copyright (c) 2013-2020, Loïc Hoguin %% %% 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(<>, Acc) -> take_until_semicolon(R, <>); +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. -- cgit v1.2.3