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