/src/mochiweb_cookies.erl

http://github.com/basho/mochiweb · Erlang · 349 lines · 251 code · 34 blank · 64 comment · 0 complexity · cab84deda7b753776c2feb8757d9d388 MD5 · raw file

  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. %% @doc HTTP Cookie parsing and generating (RFC 2109, RFC 2965).
  22. -module(mochiweb_cookies).
  23. -export([parse_cookie/1, cookie/3, cookie/2]).
  24. -define(QUOTE, $\").
  25. -define(IS_WHITESPACE(C),
  26. (C =:= $\s orelse C =:= $\t orelse C =:= $\r orelse C =:= $\n)).
  27. %% RFC 2616 separators (called tspecials in RFC 2068)
  28. -define(IS_SEPARATOR(C),
  29. (C < 32 orelse
  30. C =:= $\s orelse C =:= $\t orelse
  31. C =:= $( orelse C =:= $) orelse C =:= $< orelse C =:= $> orelse
  32. C =:= $@ orelse C =:= $, orelse C =:= $; orelse C =:= $: orelse
  33. C =:= $\\ orelse C =:= $\" orelse C =:= $/ orelse
  34. C =:= $[ orelse C =:= $] orelse C =:= $? orelse C =:= $= orelse
  35. C =:= ${ orelse C =:= $})).
  36. %% @type proplist() = [{Key::string(), Value::string()}].
  37. %% @type header() = {Name::string(), Value::string()}.
  38. %% @type int_seconds() = integer().
  39. %% @spec cookie(Key::string(), Value::string()) -> header()
  40. %% @doc Short-hand for <code>cookie(Key, Value, [])</code>.
  41. cookie(Key, Value) ->
  42. cookie(Key, Value, []).
  43. %% @spec cookie(Key::string(), Value::string(), Options::[Option]) -> header()
  44. %% where Option = {max_age, int_seconds()} | {local_time, {date(), time()}}
  45. %% | {domain, string()} | {path, string()}
  46. %% | {secure, true | false} | {http_only, true | false}
  47. %%
  48. %% @doc Generate a Set-Cookie header field tuple.
  49. cookie(Key, Value, Options) ->
  50. Cookie = [any_to_list(Key), "=", quote(Value), "; Version=1"],
  51. %% Set-Cookie:
  52. %% Comment, Domain, Max-Age, Path, Secure, Version
  53. %% Set-Cookie2:
  54. %% Comment, CommentURL, Discard, Domain, Max-Age, Path, Port, Secure,
  55. %% Version
  56. ExpiresPart =
  57. case proplists:get_value(max_age, Options) of
  58. undefined ->
  59. "";
  60. RawAge ->
  61. When = case proplists:get_value(local_time, Options) of
  62. undefined ->
  63. calendar:local_time();
  64. LocalTime ->
  65. LocalTime
  66. end,
  67. Age = case RawAge < 0 of
  68. true ->
  69. 0;
  70. false ->
  71. RawAge
  72. end,
  73. ["; Expires=", age_to_cookie_date(Age, When),
  74. "; Max-Age=", quote(Age)]
  75. end,
  76. SecurePart =
  77. case proplists:get_value(secure, Options) of
  78. true ->
  79. "; Secure";
  80. _ ->
  81. ""
  82. end,
  83. DomainPart =
  84. case proplists:get_value(domain, Options) of
  85. undefined ->
  86. "";
  87. Domain ->
  88. ["; Domain=", quote(Domain)]
  89. end,
  90. PathPart =
  91. case proplists:get_value(path, Options) of
  92. undefined ->
  93. "";
  94. Path ->
  95. ["; Path=", quote(Path)]
  96. end,
  97. HttpOnlyPart =
  98. case proplists:get_value(http_only, Options) of
  99. true ->
  100. "; HttpOnly";
  101. _ ->
  102. ""
  103. end,
  104. CookieParts = [Cookie, ExpiresPart, SecurePart, DomainPart, PathPart, HttpOnlyPart],
  105. {"Set-Cookie", lists:flatten(CookieParts)}.
  106. %% Every major browser incorrectly handles quoted strings in a
  107. %% different and (worse) incompatible manner. Instead of wasting time
  108. %% writing redundant code for each browser, we restrict cookies to
  109. %% only contain characters that browsers handle compatibly.
  110. %%
  111. %% By replacing the definition of quote with this, we generate
  112. %% RFC-compliant cookies:
  113. %%
  114. %% quote(V) ->
  115. %% Fun = fun(?QUOTE, Acc) -> [$\\, ?QUOTE | Acc];
  116. %% (Ch, Acc) -> [Ch | Acc]
  117. %% end,
  118. %% [?QUOTE | lists:foldr(Fun, [?QUOTE], V)].
  119. %% Convert to a string and raise an error if quoting is required.
  120. quote(V0) ->
  121. V = any_to_list(V0),
  122. lists:all(fun(Ch) -> Ch =:= $/ orelse not ?IS_SEPARATOR(Ch) end, V)
  123. orelse erlang:error({cookie_quoting_required, V}),
  124. V.
  125. %% Return a date in the form of: Wdy, DD-Mon-YYYY HH:MM:SS GMT
  126. %% See also: rfc2109: 10.1.2
  127. rfc2109_cookie_expires_date(LocalTime) ->
  128. {{YYYY,MM,DD},{Hour,Min,Sec}} =
  129. case calendar:local_time_to_universal_time_dst(LocalTime) of
  130. [] ->
  131. {Date, {Hour1, Min1, Sec1}} = LocalTime,
  132. LocalTime2 = {Date, {Hour1 + 1, Min1, Sec1}},
  133. case calendar:local_time_to_universal_time_dst(LocalTime2) of
  134. [Gmt] -> Gmt;
  135. [_,Gmt] -> Gmt
  136. end;
  137. [Gmt] -> Gmt;
  138. [_,Gmt] -> Gmt
  139. end,
  140. DayNumber = calendar:day_of_the_week({YYYY,MM,DD}),
  141. lists:flatten(
  142. io_lib:format("~s, ~2.2.0w-~3.s-~4.4.0w ~2.2.0w:~2.2.0w:~2.2.0w GMT",
  143. [httpd_util:day(DayNumber),DD,httpd_util:month(MM),YYYY,Hour,Min,Sec])).
  144. add_seconds(Secs, LocalTime) ->
  145. Greg = calendar:datetime_to_gregorian_seconds(LocalTime),
  146. calendar:gregorian_seconds_to_datetime(Greg + Secs).
  147. age_to_cookie_date(Age, LocalTime) ->
  148. rfc2109_cookie_expires_date(add_seconds(Age, LocalTime)).
  149. %% @spec parse_cookie(string()) -> [{K::string(), V::string()}]
  150. %% @doc Parse the contents of a Cookie header field, ignoring cookie
  151. %% attributes, and return a simple property list.
  152. parse_cookie("") ->
  153. [];
  154. parse_cookie(Cookie) ->
  155. parse_cookie(Cookie, []).
  156. %% Internal API
  157. parse_cookie([], Acc) ->
  158. lists:reverse(Acc);
  159. parse_cookie(String, Acc) ->
  160. {{Token, Value}, Rest} = read_pair(String),
  161. Acc1 = case Token of
  162. "" ->
  163. Acc;
  164. "$" ++ _ ->
  165. Acc;
  166. _ ->
  167. [{Token, Value} | Acc]
  168. end,
  169. parse_cookie(Rest, Acc1).
  170. read_pair(String) ->
  171. {Token, Rest} = read_token(skip_whitespace(String)),
  172. {Value, Rest1} = read_value(skip_whitespace(Rest)),
  173. {{Token, Value}, skip_past_separator(Rest1)}.
  174. read_value([$= | Value]) ->
  175. Value1 = skip_whitespace(Value),
  176. case Value1 of
  177. [?QUOTE | _] ->
  178. read_quoted(Value1);
  179. _ ->
  180. read_token(Value1)
  181. end;
  182. read_value(String) ->
  183. {"", String}.
  184. read_quoted([?QUOTE | String]) ->
  185. read_quoted(String, []).
  186. read_quoted([], Acc) ->
  187. {lists:reverse(Acc), []};
  188. read_quoted([?QUOTE | Rest], Acc) ->
  189. {lists:reverse(Acc), Rest};
  190. read_quoted([$\\, Any | Rest], Acc) ->
  191. read_quoted(Rest, [Any | Acc]);
  192. read_quoted([C | Rest], Acc) ->
  193. read_quoted(Rest, [C | Acc]).
  194. skip_whitespace(String) ->
  195. F = fun (C) -> ?IS_WHITESPACE(C) end,
  196. lists:dropwhile(F, String).
  197. read_token(String) ->
  198. F = fun (C) -> not ?IS_SEPARATOR(C) end,
  199. lists:splitwith(F, String).
  200. skip_past_separator([]) ->
  201. [];
  202. skip_past_separator([$; | Rest]) ->
  203. Rest;
  204. skip_past_separator([$, | Rest]) ->
  205. Rest;
  206. skip_past_separator([_ | Rest]) ->
  207. skip_past_separator(Rest).
  208. any_to_list(V) when is_list(V) ->
  209. V;
  210. any_to_list(V) when is_atom(V) ->
  211. atom_to_list(V);
  212. any_to_list(V) when is_binary(V) ->
  213. binary_to_list(V);
  214. any_to_list(V) when is_integer(V) ->
  215. integer_to_list(V).
  216. %%
  217. %% Tests
  218. %%
  219. -ifdef(TEST).
  220. -include_lib("eunit/include/eunit.hrl").
  221. quote_test() ->
  222. %% ?assertError eunit macro is not compatible with coverage module
  223. try quote(":wq")
  224. catch error:{cookie_quoting_required, ":wq"} -> ok
  225. end,
  226. ?assertEqual(
  227. "foo",
  228. quote(foo)),
  229. ok.
  230. parse_cookie_test() ->
  231. %% RFC example
  232. C1 = "$Version=\"1\"; Customer=\"WILE_E_COYOTE\"; $Path=\"/acme\";
  233. Part_Number=\"Rocket_Launcher_0001\"; $Path=\"/acme\";
  234. Shipping=\"FedEx\"; $Path=\"/acme\"",
  235. ?assertEqual(
  236. [{"Customer","WILE_E_COYOTE"},
  237. {"Part_Number","Rocket_Launcher_0001"},
  238. {"Shipping","FedEx"}],
  239. parse_cookie(C1)),
  240. %% Potential edge cases
  241. ?assertEqual(
  242. [{"foo", "x"}],
  243. parse_cookie("foo=\"\\x\"")),
  244. ?assertEqual(
  245. [],
  246. parse_cookie("=")),
  247. ?assertEqual(
  248. [{"foo", ""}, {"bar", ""}],
  249. parse_cookie(" foo ; bar ")),
  250. ?assertEqual(
  251. [{"foo", ""}, {"bar", ""}],
  252. parse_cookie("foo=;bar=")),
  253. ?assertEqual(
  254. [{"foo", "\";"}, {"bar", ""}],
  255. parse_cookie("foo = \"\\\";\";bar ")),
  256. ?assertEqual(
  257. [{"foo", "\";bar"}],
  258. parse_cookie("foo=\"\\\";bar")),
  259. ?assertEqual(
  260. [],
  261. parse_cookie([])),
  262. ?assertEqual(
  263. [{"foo", "bar"}, {"baz", "wibble"}],
  264. parse_cookie("foo=bar , baz=wibble ")),
  265. ok.
  266. domain_test() ->
  267. ?assertEqual(
  268. {"Set-Cookie",
  269. "Customer=WILE_E_COYOTE; "
  270. "Version=1; "
  271. "Domain=acme.com; "
  272. "HttpOnly"},
  273. cookie("Customer", "WILE_E_COYOTE",
  274. [{http_only, true}, {domain, "acme.com"}])),
  275. ok.
  276. local_time_test() ->
  277. {"Set-Cookie", S} = cookie("Customer", "WILE_E_COYOTE",
  278. [{max_age, 111}, {secure, true}]),
  279. ?assertMatch(
  280. ["Customer=WILE_E_COYOTE",
  281. " Version=1",
  282. " Expires=" ++ _,
  283. " Max-Age=111",
  284. " Secure"],
  285. string:tokens(S, ";")),
  286. ok.
  287. cookie_test() ->
  288. C1 = {"Set-Cookie",
  289. "Customer=WILE_E_COYOTE; "
  290. "Version=1; "
  291. "Path=/acme"},
  292. C1 = cookie("Customer", "WILE_E_COYOTE", [{path, "/acme"}]),
  293. C1 = cookie("Customer", "WILE_E_COYOTE",
  294. [{path, "/acme"}, {badoption, "negatory"}]),
  295. C1 = cookie('Customer', 'WILE_E_COYOTE', [{path, '/acme'}]),
  296. C1 = cookie(<<"Customer">>, <<"WILE_E_COYOTE">>, [{path, <<"/acme">>}]),
  297. {"Set-Cookie","=NoKey; Version=1"} = cookie("", "NoKey", []),
  298. {"Set-Cookie","=NoKey; Version=1"} = cookie("", "NoKey"),
  299. LocalTime = calendar:universal_time_to_local_time({{2007, 5, 15}, {13, 45, 33}}),
  300. C2 = {"Set-Cookie",
  301. "Customer=WILE_E_COYOTE; "
  302. "Version=1; "
  303. "Expires=Tue, 15-May-2007 13:45:33 GMT; "
  304. "Max-Age=0"},
  305. C2 = cookie("Customer", "WILE_E_COYOTE",
  306. [{max_age, -111}, {local_time, LocalTime}]),
  307. C3 = {"Set-Cookie",
  308. "Customer=WILE_E_COYOTE; "
  309. "Version=1; "
  310. "Expires=Wed, 16-May-2007 13:45:50 GMT; "
  311. "Max-Age=86417"},
  312. C3 = cookie("Customer", "WILE_E_COYOTE",
  313. [{max_age, 86417}, {local_time, LocalTime}]),
  314. ok.
  315. -endif.