12 |
7u83 |
1 |
%% @author Emad El-Haraty <emad@mochimedia.com>
|
|
|
2 |
%% @copyright 2007 Mochi Media, Inc.
|
|
|
3 |
%%
|
|
|
4 |
%% Permission is hereby granted, free of charge, to any person obtaining a
|
|
|
5 |
%% copy of this software and associated documentation files (the "Software"),
|
|
|
6 |
%% to deal in the Software without restriction, including without limitation
|
|
|
7 |
%% the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
|
8 |
%% and/or sell copies of the Software, and to permit persons to whom the
|
|
|
9 |
%% Software is furnished to do so, subject to the following conditions:
|
|
|
10 |
%%
|
|
|
11 |
%% The above copyright notice and this permission notice shall be included in
|
|
|
12 |
%% all copies or substantial portions of the Software.
|
|
|
13 |
%%
|
|
|
14 |
%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
|
15 |
%% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
|
16 |
%% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
|
17 |
%% THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
18 |
%% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
|
19 |
%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
|
20 |
%% DEALINGS IN THE SOFTWARE.
|
|
|
21 |
|
|
|
22 |
%% @doc HTTP Cookie parsing and generating (RFC 2109, RFC 2965).
|
|
|
23 |
|
|
|
24 |
-module(mochiweb_cookies).
|
|
|
25 |
-export([parse_cookie/1, cookie/3, cookie/2]).
|
|
|
26 |
|
|
|
27 |
-define(QUOTE, $\").
|
|
|
28 |
|
|
|
29 |
-define(IS_WHITESPACE(C),
|
|
|
30 |
(C =:= $\s orelse C =:= $\t orelse C =:= $\r orelse C =:= $\n)).
|
|
|
31 |
|
|
|
32 |
%% RFC 2616 separators (called tspecials in RFC 2068)
|
|
|
33 |
-define(IS_SEPARATOR(C),
|
|
|
34 |
(C < 32 orelse
|
|
|
35 |
C =:= $\s orelse C =:= $\t orelse
|
|
|
36 |
C =:= $( orelse C =:= $) orelse C =:= $< orelse C =:= $> orelse
|
|
|
37 |
C =:= $@ orelse C =:= $, orelse C =:= $; orelse C =:= $: orelse
|
|
|
38 |
C =:= $\\ orelse C =:= $\" orelse C =:= $/ orelse
|
|
|
39 |
C =:= $[ orelse C =:= $] orelse C =:= $? orelse C =:= $= orelse
|
|
|
40 |
C =:= ${ orelse C =:= $})).
|
|
|
41 |
|
|
|
42 |
%% RFC 6265 cookie value allowed characters
|
|
|
43 |
%% cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
|
|
|
44 |
%% ; US-ASCII characters excluding CTLs,
|
|
|
45 |
%% ; whitespace DQUOTE, comma, semicolon,
|
|
|
46 |
%% ; and backslash
|
|
|
47 |
-define(IS_COOKIE_OCTET(C),
|
|
|
48 |
(C =:= 16#21
|
|
|
49 |
orelse (C >= 16#23 andalso C =< 16#2B)
|
|
|
50 |
orelse (C >= 16#2D andalso C =< 16#3A)
|
|
|
51 |
orelse (C >= 16#3C andalso C =< 16#5B)
|
|
|
52 |
orelse (C >= 16#5D andalso C =< 16#7E)
|
|
|
53 |
)).
|
|
|
54 |
|
|
|
55 |
%% @type proplist() = [{Key::string(), Value::string()}].
|
|
|
56 |
%% @type header() = {Name::string(), Value::string()}.
|
|
|
57 |
%% @type int_seconds() = integer().
|
|
|
58 |
|
|
|
59 |
%% @spec cookie(Key::string(), Value::string()) -> header()
|
|
|
60 |
%% @doc Short-hand for <code>cookie(Key, Value, [])</code>.
|
|
|
61 |
cookie(Key, Value) ->
|
|
|
62 |
cookie(Key, Value, []).
|
|
|
63 |
|
|
|
64 |
%% @spec cookie(Key::string(), Value::string(), Options::[Option]) -> header()
|
|
|
65 |
%% where Option = {max_age, int_seconds()} | {local_time, {date(), time()}}
|
|
|
66 |
%% | {domain, string()} | {path, string()}
|
|
|
67 |
%% | {secure, true | false} | {http_only, true | false}
|
|
|
68 |
%% | {same_site, lax | strict | none}
|
|
|
69 |
%%
|
|
|
70 |
%% @doc Generate a Set-Cookie header field tuple.
|
|
|
71 |
cookie(Key, Value, Options) ->
|
|
|
72 |
Cookie = [any_to_list(Key), "=", quote(Value), "; Version=1"],
|
|
|
73 |
%% Set-Cookie:
|
|
|
74 |
%% Comment, Domain, Max-Age, Path, Secure, Version
|
|
|
75 |
%% Set-Cookie2:
|
|
|
76 |
%% Comment, CommentURL, Discard, Domain, Max-Age, Path, Port, Secure,
|
|
|
77 |
%% Version
|
|
|
78 |
ExpiresPart =
|
|
|
79 |
case proplists:get_value(max_age, Options) of
|
|
|
80 |
undefined ->
|
|
|
81 |
"";
|
|
|
82 |
RawAge ->
|
|
|
83 |
When = case proplists:get_value(local_time, Options) of
|
|
|
84 |
undefined ->
|
|
|
85 |
calendar:local_time();
|
|
|
86 |
LocalTime ->
|
|
|
87 |
LocalTime
|
|
|
88 |
end,
|
|
|
89 |
Age = case RawAge < 0 of
|
|
|
90 |
true ->
|
|
|
91 |
0;
|
|
|
92 |
false ->
|
|
|
93 |
RawAge
|
|
|
94 |
end,
|
|
|
95 |
["; Expires=", age_to_cookie_date(Age, When),
|
|
|
96 |
"; Max-Age=", quote(Age)]
|
|
|
97 |
end,
|
|
|
98 |
SecurePart =
|
|
|
99 |
case proplists:get_value(secure, Options) of
|
|
|
100 |
true ->
|
|
|
101 |
"; Secure";
|
|
|
102 |
_ ->
|
|
|
103 |
""
|
|
|
104 |
end,
|
|
|
105 |
DomainPart =
|
|
|
106 |
case proplists:get_value(domain, Options) of
|
|
|
107 |
undefined ->
|
|
|
108 |
"";
|
|
|
109 |
Domain ->
|
|
|
110 |
["; Domain=", quote(Domain)]
|
|
|
111 |
end,
|
|
|
112 |
PathPart =
|
|
|
113 |
case proplists:get_value(path, Options) of
|
|
|
114 |
undefined ->
|
|
|
115 |
"";
|
|
|
116 |
Path ->
|
|
|
117 |
["; Path=", quote(Path)]
|
|
|
118 |
end,
|
|
|
119 |
HttpOnlyPart =
|
|
|
120 |
case proplists:get_value(http_only, Options) of
|
|
|
121 |
true ->
|
|
|
122 |
"; HttpOnly";
|
|
|
123 |
_ ->
|
|
|
124 |
""
|
|
|
125 |
end,
|
|
|
126 |
SameSitePart =
|
|
|
127 |
case proplists:get_value(same_site, Options) of
|
|
|
128 |
undefined ->
|
|
|
129 |
"";
|
|
|
130 |
lax ->
|
|
|
131 |
"; SameSite=Lax";
|
|
|
132 |
strict ->
|
|
|
133 |
"; SameSite=Strict";
|
|
|
134 |
none ->
|
|
|
135 |
"; SameSite=None"
|
|
|
136 |
end,
|
|
|
137 |
CookieParts = [Cookie, ExpiresPart, SecurePart, DomainPart, PathPart,
|
|
|
138 |
HttpOnlyPart, SameSitePart],
|
|
|
139 |
{"Set-Cookie", lists:flatten(CookieParts)}.
|
|
|
140 |
|
|
|
141 |
|
|
|
142 |
%% Every major browser incorrectly handles quoted strings in a
|
|
|
143 |
%% different and (worse) incompatible manner. Instead of wasting time
|
|
|
144 |
%% writing redundant code for each browser, we restrict cookies to
|
|
|
145 |
%% only contain characters that browsers handle compatibly.
|
|
|
146 |
%%
|
|
|
147 |
%% By replacing the definition of quote with this, we generate
|
|
|
148 |
%% RFC-compliant cookies:
|
|
|
149 |
%%
|
|
|
150 |
%% quote(V) ->
|
|
|
151 |
%% Fun = fun(?QUOTE, Acc) -> [$\\, ?QUOTE | Acc];
|
|
|
152 |
%% (Ch, Acc) -> [Ch | Acc]
|
|
|
153 |
%% end,
|
|
|
154 |
%% [?QUOTE | lists:foldr(Fun, [?QUOTE], V)].
|
|
|
155 |
|
|
|
156 |
%% Convert to a string and raise an error if quoting is required.
|
|
|
157 |
quote(V0) ->
|
|
|
158 |
V = any_to_list(V0),
|
|
|
159 |
lists:all(fun(Ch) -> Ch =:= $/ orelse not ?IS_SEPARATOR(Ch) end, V)
|
|
|
160 |
orelse erlang:error({cookie_quoting_required, V}),
|
|
|
161 |
V.
|
|
|
162 |
|
|
|
163 |
|
|
|
164 |
%% Return a date in the form of: Wdy, DD-Mon-YYYY HH:MM:SS GMT
|
|
|
165 |
%% See also: rfc2109: 10.1.2
|
|
|
166 |
rfc2109_cookie_expires_date(LocalTime) ->
|
|
|
167 |
{{YYYY,MM,DD},{Hour,Min,Sec}} =
|
|
|
168 |
case calendar:local_time_to_universal_time_dst(LocalTime) of
|
|
|
169 |
[] ->
|
|
|
170 |
{Date, {Hour1, Min1, Sec1}} = LocalTime,
|
|
|
171 |
LocalTime2 = {Date, {Hour1 + 1, Min1, Sec1}},
|
|
|
172 |
case calendar:local_time_to_universal_time_dst(LocalTime2) of
|
|
|
173 |
[Gmt] -> Gmt;
|
|
|
174 |
[_,Gmt] -> Gmt
|
|
|
175 |
end;
|
|
|
176 |
[Gmt] -> Gmt;
|
|
|
177 |
[_,Gmt] -> Gmt
|
|
|
178 |
end,
|
|
|
179 |
DayNumber = calendar:day_of_the_week({YYYY,MM,DD}),
|
|
|
180 |
lists:flatten(
|
|
|
181 |
io_lib:format("~s, ~2.2.0w-~3.s-~4.4.0w ~2.2.0w:~2.2.0w:~2.2.0w GMT",
|
|
|
182 |
[httpd_util:day(DayNumber),DD,httpd_util:month(MM),YYYY,Hour,Min,Sec])).
|
|
|
183 |
|
|
|
184 |
add_seconds(Secs, LocalTime) ->
|
|
|
185 |
Greg = calendar:datetime_to_gregorian_seconds(LocalTime),
|
|
|
186 |
calendar:gregorian_seconds_to_datetime(Greg + Secs).
|
|
|
187 |
|
|
|
188 |
age_to_cookie_date(Age, LocalTime) ->
|
|
|
189 |
rfc2109_cookie_expires_date(add_seconds(Age, LocalTime)).
|
|
|
190 |
|
|
|
191 |
%% @spec parse_cookie(string()) -> [{K::string(), V::string()}]
|
|
|
192 |
%% @doc Parse the contents of a Cookie header field, ignoring cookie
|
|
|
193 |
%% attributes, and return a simple property list.
|
|
|
194 |
parse_cookie("") ->
|
|
|
195 |
[];
|
|
|
196 |
parse_cookie(Cookie) ->
|
|
|
197 |
parse_cookie(Cookie, []).
|
|
|
198 |
|
|
|
199 |
%% Internal API
|
|
|
200 |
|
|
|
201 |
parse_cookie([], Acc) ->
|
|
|
202 |
lists:reverse(Acc);
|
|
|
203 |
parse_cookie(String, Acc) ->
|
|
|
204 |
{{Token, Value}, Rest} = read_pair(String),
|
|
|
205 |
Acc1 = case Token of
|
|
|
206 |
"" ->
|
|
|
207 |
Acc;
|
|
|
208 |
"$" ++ _ ->
|
|
|
209 |
Acc;
|
|
|
210 |
_ ->
|
|
|
211 |
[{Token, Value} | Acc]
|
|
|
212 |
end,
|
|
|
213 |
parse_cookie(Rest, Acc1).
|
|
|
214 |
|
|
|
215 |
read_pair(String) ->
|
|
|
216 |
{Token, Rest} = read_token(skip_whitespace(String)),
|
|
|
217 |
{Value, Rest1} = read_value(skip_whitespace(Rest)),
|
|
|
218 |
{{Token, Value}, skip_past_separator(Rest1)}.
|
|
|
219 |
|
|
|
220 |
read_value([$= | Value]) ->
|
|
|
221 |
Value1 = skip_whitespace(Value),
|
|
|
222 |
case Value1 of
|
|
|
223 |
[?QUOTE | _] ->
|
|
|
224 |
read_quoted(Value1);
|
|
|
225 |
_ ->
|
|
|
226 |
read_value_(Value1)
|
|
|
227 |
end;
|
|
|
228 |
read_value(String) ->
|
|
|
229 |
{"", String}.
|
|
|
230 |
|
|
|
231 |
read_value_(String) ->
|
|
|
232 |
F = fun (C) -> ?IS_COOKIE_OCTET(C) end,
|
|
|
233 |
lists:splitwith(F, String).
|
|
|
234 |
|
|
|
235 |
read_quoted([?QUOTE | String]) ->
|
|
|
236 |
read_quoted(String, []).
|
|
|
237 |
|
|
|
238 |
read_quoted([], Acc) ->
|
|
|
239 |
{lists:reverse(Acc), []};
|
|
|
240 |
read_quoted([?QUOTE | Rest], Acc) ->
|
|
|
241 |
{lists:reverse(Acc), Rest};
|
|
|
242 |
read_quoted([$\\, Any | Rest], Acc) ->
|
|
|
243 |
read_quoted(Rest, [Any | Acc]);
|
|
|
244 |
read_quoted([C | Rest], Acc) ->
|
|
|
245 |
read_quoted(Rest, [C | Acc]).
|
|
|
246 |
|
|
|
247 |
skip_whitespace(String) ->
|
|
|
248 |
F = fun (C) -> ?IS_WHITESPACE(C) end,
|
|
|
249 |
lists:dropwhile(F, String).
|
|
|
250 |
|
|
|
251 |
read_token(String) ->
|
|
|
252 |
F = fun (C) -> not ?IS_SEPARATOR(C) end,
|
|
|
253 |
lists:splitwith(F, String).
|
|
|
254 |
|
|
|
255 |
skip_past_separator([]) ->
|
|
|
256 |
[];
|
|
|
257 |
skip_past_separator([$; | Rest]) ->
|
|
|
258 |
Rest;
|
|
|
259 |
skip_past_separator([$, | Rest]) ->
|
|
|
260 |
Rest;
|
|
|
261 |
skip_past_separator([_ | Rest]) ->
|
|
|
262 |
skip_past_separator(Rest).
|
|
|
263 |
|
|
|
264 |
any_to_list(V) when is_list(V) ->
|
|
|
265 |
V;
|
|
|
266 |
any_to_list(V) when is_atom(V) ->
|
|
|
267 |
atom_to_list(V);
|
|
|
268 |
any_to_list(V) when is_binary(V) ->
|
|
|
269 |
binary_to_list(V);
|
|
|
270 |
any_to_list(V) when is_integer(V) ->
|
|
|
271 |
integer_to_list(V).
|
|
|
272 |
|
|
|
273 |
%%
|
|
|
274 |
%% Tests
|
|
|
275 |
%%
|
|
|
276 |
-ifdef(TEST).
|
|
|
277 |
-include_lib("eunit/include/eunit.hrl").
|
|
|
278 |
|
|
|
279 |
quote_test() ->
|
|
|
280 |
%% ?assertError eunit macro is not compatible with coverage module
|
|
|
281 |
try quote(":wq")
|
|
|
282 |
catch error:{cookie_quoting_required, ":wq"} -> ok
|
|
|
283 |
end,
|
|
|
284 |
?assertEqual(
|
|
|
285 |
"foo",
|
|
|
286 |
quote(foo)),
|
|
|
287 |
ok.
|
|
|
288 |
|
|
|
289 |
parse_cookie_test() ->
|
|
|
290 |
%% RFC example
|
|
|
291 |
C1 = "$Version=\"1\"; Customer=\"WILE_E_COYOTE\"; $Path=\"/acme\";
|
|
|
292 |
Part_Number=\"Rocket_Launcher_0001\"; $Path=\"/acme\";
|
|
|
293 |
Shipping=\"FedEx\"; $Path=\"/acme\"",
|
|
|
294 |
?assertEqual(
|
|
|
295 |
[{"Customer","WILE_E_COYOTE"},
|
|
|
296 |
{"Part_Number","Rocket_Launcher_0001"},
|
|
|
297 |
{"Shipping","FedEx"}],
|
|
|
298 |
parse_cookie(C1)),
|
|
|
299 |
%% Potential edge cases
|
|
|
300 |
?assertEqual(
|
|
|
301 |
[{"foo", "x"}],
|
|
|
302 |
parse_cookie("foo=\"\\x\"")),
|
|
|
303 |
?assertEqual(
|
|
|
304 |
[],
|
|
|
305 |
parse_cookie("=")),
|
|
|
306 |
?assertEqual(
|
|
|
307 |
[{"foo", ""}, {"bar", ""}],
|
|
|
308 |
parse_cookie(" foo ; bar ")),
|
|
|
309 |
?assertEqual(
|
|
|
310 |
[{"foo", ""}, {"bar", ""}],
|
|
|
311 |
parse_cookie("foo=;bar=")),
|
|
|
312 |
?assertEqual(
|
|
|
313 |
[{"foo", "\";"}, {"bar", ""}],
|
|
|
314 |
parse_cookie("foo = \"\\\";\";bar ")),
|
|
|
315 |
?assertEqual(
|
|
|
316 |
[{"foo", "\";bar"}],
|
|
|
317 |
parse_cookie("foo=\"\\\";bar")),
|
|
|
318 |
?assertEqual(
|
|
|
319 |
[],
|
|
|
320 |
parse_cookie([])),
|
|
|
321 |
?assertEqual(
|
|
|
322 |
[{"foo", "bar"}, {"baz", "wibble"}],
|
|
|
323 |
parse_cookie("foo=bar , baz=wibble ")),
|
|
|
324 |
?assertEqual(
|
|
|
325 |
[{"foo", "base64=="}, {"bar", "base64="}],
|
|
|
326 |
parse_cookie("foo=\"base64==\";bar=\"base64=\"")),
|
|
|
327 |
?assertEqual(
|
|
|
328 |
[{"foo", "base64=="}, {"bar", "base64="}],
|
|
|
329 |
parse_cookie("foo=base64==;bar=base64=")),
|
|
|
330 |
ok.
|
|
|
331 |
|
|
|
332 |
domain_test() ->
|
|
|
333 |
?assertEqual(
|
|
|
334 |
{"Set-Cookie",
|
|
|
335 |
"Customer=WILE_E_COYOTE; "
|
|
|
336 |
"Version=1; "
|
|
|
337 |
"Domain=acme.com; "
|
|
|
338 |
"HttpOnly"},
|
|
|
339 |
cookie("Customer", "WILE_E_COYOTE",
|
|
|
340 |
[{http_only, true}, {domain, "acme.com"}])),
|
|
|
341 |
ok.
|
|
|
342 |
|
|
|
343 |
local_time_test() ->
|
|
|
344 |
{"Set-Cookie", S} = cookie("Customer", "WILE_E_COYOTE",
|
|
|
345 |
[{max_age, 111}, {secure, true}]),
|
|
|
346 |
?assertMatch(
|
|
|
347 |
["Customer=WILE_E_COYOTE",
|
|
|
348 |
" Version=1",
|
|
|
349 |
" Expires=" ++ _,
|
|
|
350 |
" Max-Age=111",
|
|
|
351 |
" Secure"],
|
|
|
352 |
string:tokens(S, ";")),
|
|
|
353 |
ok.
|
|
|
354 |
|
|
|
355 |
cookie_test() ->
|
|
|
356 |
C1 = {"Set-Cookie",
|
|
|
357 |
"Customer=WILE_E_COYOTE; "
|
|
|
358 |
"Version=1; "
|
|
|
359 |
"Path=/acme"},
|
|
|
360 |
C1 = cookie("Customer", "WILE_E_COYOTE", [{path, "/acme"}]),
|
|
|
361 |
C1 = cookie("Customer", "WILE_E_COYOTE",
|
|
|
362 |
[{path, "/acme"}, {badoption, "negatory"}]),
|
|
|
363 |
C1 = cookie('Customer', 'WILE_E_COYOTE', [{path, '/acme'}]),
|
|
|
364 |
C1 = cookie(<<"Customer">>, <<"WILE_E_COYOTE">>, [{path, <<"/acme">>}]),
|
|
|
365 |
|
|
|
366 |
{"Set-Cookie","=NoKey; Version=1"} = cookie("", "NoKey", []),
|
|
|
367 |
{"Set-Cookie","=NoKey; Version=1"} = cookie("", "NoKey"),
|
|
|
368 |
LocalTime = calendar:universal_time_to_local_time({{2007, 5, 15}, {13, 45, 33}}),
|
|
|
369 |
C2 = {"Set-Cookie",
|
|
|
370 |
"Customer=WILE_E_COYOTE; "
|
|
|
371 |
"Version=1; "
|
|
|
372 |
"Expires=Tue, 15-May-2007 13:45:33 GMT; "
|
|
|
373 |
"Max-Age=0"},
|
|
|
374 |
C2 = cookie("Customer", "WILE_E_COYOTE",
|
|
|
375 |
[{max_age, -111}, {local_time, LocalTime}]),
|
|
|
376 |
C3 = {"Set-Cookie",
|
|
|
377 |
"Customer=WILE_E_COYOTE; "
|
|
|
378 |
"Version=1; "
|
|
|
379 |
"Expires=Wed, 16-May-2007 13:45:50 GMT; "
|
|
|
380 |
"Max-Age=86417"},
|
|
|
381 |
C3 = cookie("Customer", "WILE_E_COYOTE",
|
|
|
382 |
[{max_age, 86417}, {local_time, LocalTime}]),
|
|
|
383 |
|
|
|
384 |
% test various values for SameSite
|
|
|
385 |
%
|
|
|
386 |
% unset default to nothing
|
|
|
387 |
C4 = {"Set-Cookie","i=test123; Version=1"},
|
|
|
388 |
C4 = cookie("i", "test123", []),
|
|
|
389 |
C5 = {"Set-Cookie","i=test123; Version=1; SameSite=Strict"},
|
|
|
390 |
C5 = cookie("i", "test123", [ {same_site, strict}]),
|
|
|
391 |
C6 = {"Set-Cookie","i=test123; Version=1; SameSite=Lax"},
|
|
|
392 |
C6 = cookie("i", "test123", [ {same_site, lax}]),
|
|
|
393 |
C7 = {"Set-Cookie","i=test123; Version=1; SameSite=None"},
|
|
|
394 |
C7 = cookie("i", "test123", [ {same_site, none}]),
|
|
|
395 |
ok.
|
|
|
396 |
|
|
|
397 |
-endif.
|