cowboy_cookies.erl 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. %% Copyright 2007 Mochi Media, Inc.
  2. %% Copyright 2011 Thomas Burdick <thomas.burdick@gmail.com>
  3. %%
  4. %% Permission to use, copy, modify, and/or distribute this software for any
  5. %% purpose with or without fee is hereby granted, provided that the above
  6. %% copyright notice and this permission notice appear in all copies.
  7. %%
  8. %% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
  9. %% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
  10. %% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
  11. %% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
  12. %% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
  13. %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
  14. %% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  15. %% @doc HTTP Cookie parsing and generating (RFC 2965).
  16. -module(cowboy_cookies).
  17. %% API.
  18. -export([parse_cookie/1]).
  19. -export([cookie/3]).
  20. -export([cookie/2]).
  21. %% Types.
  22. -type kv() :: {Name::binary(), Value::binary()}.
  23. -type kvlist() :: [kv()].
  24. -type cookie_option() :: {max_age, integer()}
  25. | {local_time, calendar:datetime()}
  26. | {domain, binary()} | {path, binary()}
  27. | {secure, true | false} | {http_only, true | false}.
  28. -export_type([kv/0]).
  29. -export_type([kvlist/0]).
  30. -export_type([cookie_option/0]).
  31. -define(QUOTE, $\").
  32. -ifdef(TEST).
  33. -include_lib("eunit/include/eunit.hrl").
  34. -endif.
  35. %% API.
  36. %% @doc Parse the contents of a Cookie header field, ignoring cookie
  37. %% attributes, and return a simple property list.
  38. -spec parse_cookie(binary()) -> kvlist().
  39. parse_cookie(<<>>) ->
  40. [];
  41. parse_cookie(Cookie) when is_binary(Cookie) ->
  42. parse_cookie(Cookie, []).
  43. %% @equiv cookie(Key, Value, [])
  44. -spec cookie(binary(), binary()) -> kv().
  45. cookie(Key, Value) when is_binary(Key) andalso is_binary(Value) ->
  46. cookie(Key, Value, []).
  47. %% @doc Generate a Set-Cookie header field tuple.
  48. -spec cookie(binary(), binary(), [cookie_option()]) -> kv().
  49. cookie(Key, Value, Options) when is_binary(Key)
  50. andalso is_binary(Value) andalso is_list(Options) ->
  51. Cookie = <<(any_to_binary(Key))/binary, "=",
  52. (quote(Value))/binary, "; Version=1">>,
  53. %% Set-Cookie:
  54. %% Comment, Domain, Max-Age, Path, Secure, Version
  55. ExpiresPart =
  56. case proplists:get_value(max_age, Options) of
  57. undefined ->
  58. <<"">>;
  59. RawAge ->
  60. When = case proplists:get_value(local_time, Options) of
  61. undefined ->
  62. calendar:local_time();
  63. LocalTime ->
  64. LocalTime
  65. end,
  66. Age = case RawAge < 0 of
  67. true ->
  68. 0;
  69. false ->
  70. RawAge
  71. end,
  72. AgeBinary = quote(Age),
  73. CookieDate = age_to_cookie_date(Age, When),
  74. <<"; Expires=", CookieDate/binary,
  75. "; Max-Age=", AgeBinary/binary>>
  76. end,
  77. SecurePart =
  78. case proplists:get_value(secure, Options) of
  79. true ->
  80. <<"; Secure">>;
  81. _ ->
  82. <<"">>
  83. end,
  84. DomainPart =
  85. case proplists:get_value(domain, Options) of
  86. undefined ->
  87. <<"">>;
  88. Domain ->
  89. <<"; Domain=", (quote(Domain))/binary>>
  90. end,
  91. PathPart =
  92. case proplists:get_value(path, Options) of
  93. undefined ->
  94. <<"">>;
  95. Path ->
  96. <<"; Path=", (quote(Path, true))/binary>>
  97. end,
  98. HttpOnlyPart =
  99. case proplists:get_value(http_only, Options) of
  100. true ->
  101. <<"; HttpOnly">>;
  102. _ ->
  103. <<"">>
  104. end,
  105. CookieParts = <<Cookie/binary, ExpiresPart/binary, SecurePart/binary,
  106. DomainPart/binary, PathPart/binary, HttpOnlyPart/binary>>,
  107. {<<"Set-Cookie">>, CookieParts}.
  108. %% Internal.
  109. %% @doc Check if a character is a white space character.
  110. -spec is_whitespace(char()) -> boolean().
  111. is_whitespace($\s) -> true;
  112. is_whitespace($\t) -> true;
  113. is_whitespace($\r) -> true;
  114. is_whitespace($\n) -> true;
  115. is_whitespace(_) -> false.
  116. %% @doc Check if a character is a separator.
  117. -spec is_separator(char()) -> boolean().
  118. is_separator(C) when C < 32 -> true;
  119. is_separator($\s) -> true;
  120. is_separator($\t) -> true;
  121. is_separator($() -> true;
  122. is_separator($)) -> true;
  123. is_separator($<) -> true;
  124. is_separator($>) -> true;
  125. is_separator($@) -> true;
  126. is_separator($,) -> true;
  127. is_separator($;) -> true;
  128. is_separator($:) -> true;
  129. is_separator($\\) -> true;
  130. is_separator(?QUOTE) -> true;
  131. is_separator($/) -> true;
  132. is_separator($[) -> true;
  133. is_separator($]) -> true;
  134. is_separator($?) -> true;
  135. is_separator($=) -> true;
  136. is_separator(${) -> true;
  137. is_separator($}) -> true;
  138. is_separator(_) -> false.
  139. %% @doc Check if a binary has an ASCII separator character.
  140. -spec has_separator(binary(), boolean()) -> boolean().
  141. has_separator(<<>>, _) ->
  142. false;
  143. has_separator(<<$/, Rest/binary>>, true) ->
  144. has_separator(Rest, true);
  145. has_separator(<<C, Rest/binary>>, IgnoreSlash) ->
  146. case is_separator(C) of
  147. true ->
  148. true;
  149. false ->
  150. has_separator(Rest, IgnoreSlash)
  151. end.
  152. %% @doc Convert to a binary and raise an error if quoting is required. Quoting
  153. %% is broken in different ways for different browsers. Its better to simply
  154. %% avoiding doing it at all.
  155. %% @end
  156. -spec quote(term(), boolean()) -> binary().
  157. quote(V0, IgnoreSlash) ->
  158. V = any_to_binary(V0),
  159. case has_separator(V, IgnoreSlash) of
  160. true ->
  161. erlang:error({cookie_quoting_required, V});
  162. false ->
  163. V
  164. end.
  165. %% @equiv quote(Bin, false)
  166. -spec quote(term()) -> binary().
  167. quote(V0) ->
  168. quote(V0, false).
  169. -spec add_seconds(integer(), calendar:datetime()) -> calendar:datetime().
  170. add_seconds(Secs, LocalTime) ->
  171. Greg = calendar:datetime_to_gregorian_seconds(LocalTime),
  172. calendar:gregorian_seconds_to_datetime(Greg + Secs).
  173. -spec age_to_cookie_date(integer(), calendar:datetime()) -> binary().
  174. age_to_cookie_date(Age, LocalTime) ->
  175. cowboy_clock:rfc2109(add_seconds(Age, LocalTime)).
  176. -spec parse_cookie(binary(), kvlist()) -> kvlist().
  177. parse_cookie(<<>>, Acc) ->
  178. lists:reverse(Acc);
  179. parse_cookie(String, Acc) ->
  180. {{Token, Value}, Rest} = read_pair(String),
  181. Acc1 = case Token of
  182. <<"">> ->
  183. Acc;
  184. <<"$", _R/binary>> ->
  185. Acc;
  186. _ ->
  187. [{Token, Value} | Acc]
  188. end,
  189. parse_cookie(Rest, Acc1).
  190. -spec read_pair(binary()) -> {{binary(), binary()}, binary()}.
  191. read_pair(String) ->
  192. {Token, Rest} = read_token(skip_whitespace(String)),
  193. {Value, Rest1} = read_value(skip_whitespace(Rest)),
  194. {{Token, Value}, skip_past_separator(Rest1)}.
  195. -spec read_value(binary()) -> {binary(), binary()}.
  196. read_value(<<"=", Value/binary>>) ->
  197. Value1 = skip_whitespace(Value),
  198. case Value1 of
  199. <<?QUOTE, _R/binary>> ->
  200. read_quoted(Value1);
  201. _ ->
  202. read_token(Value1)
  203. end;
  204. read_value(String) ->
  205. {<<"">>, String}.
  206. -spec read_quoted(binary()) -> {binary(), binary()}.
  207. read_quoted(<<?QUOTE, String/binary>>) ->
  208. read_quoted(String, <<"">>).
  209. -spec read_quoted(binary(), binary()) -> {binary(), binary()}.
  210. read_quoted(<<"">>, Acc) ->
  211. {Acc, <<"">>};
  212. read_quoted(<<?QUOTE, Rest/binary>>, Acc) ->
  213. {Acc, Rest};
  214. read_quoted(<<$\\, Any, Rest/binary>>, Acc) ->
  215. read_quoted(Rest, <<Acc/binary, Any>>);
  216. read_quoted(<<C, Rest/binary>>, Acc) ->
  217. read_quoted(Rest, <<Acc/binary, C>>).
  218. %% @doc Drop characters while a function returns true.
  219. -spec binary_dropwhile(fun((char()) -> boolean()), binary()) -> binary().
  220. binary_dropwhile(_F, <<"">>) ->
  221. <<"">>;
  222. binary_dropwhile(F, String) ->
  223. <<C, Rest/binary>> = String,
  224. case F(C) of
  225. true ->
  226. binary_dropwhile(F, Rest);
  227. false ->
  228. String
  229. end.
  230. %% @doc Remove leading whitespace.
  231. -spec skip_whitespace(binary()) -> binary().
  232. skip_whitespace(String) ->
  233. binary_dropwhile(fun is_whitespace/1, String).
  234. %% @doc Split a binary when the current character causes F to return true.
  235. -spec binary_splitwith(fun((char()) -> boolean()), binary(), binary())
  236. -> {binary(), binary()}.
  237. binary_splitwith(_F, Head, <<>>) ->
  238. {Head, <<>>};
  239. binary_splitwith(F, Head, Tail) ->
  240. <<C, NTail/binary>> = Tail,
  241. case F(C) of
  242. true ->
  243. {Head, Tail};
  244. false ->
  245. binary_splitwith(F, <<Head/binary, C>>, NTail)
  246. end.
  247. %% @doc Split a binary with a function returning true or false on each char.
  248. -spec binary_splitwith(fun((char()) -> boolean()), binary())
  249. -> {binary(), binary()}.
  250. binary_splitwith(F, String) ->
  251. binary_splitwith(F, <<>>, String).
  252. %% @doc Split the binary when the next separator is found.
  253. -spec read_token(binary()) -> {binary(), binary()}.
  254. read_token(String) ->
  255. binary_splitwith(fun is_separator/1, String).
  256. %% @doc Return string after ; or , characters.
  257. -spec skip_past_separator(binary()) -> binary().
  258. skip_past_separator(<<"">>) ->
  259. <<"">>;
  260. skip_past_separator(<<";", Rest/binary>>) ->
  261. Rest;
  262. skip_past_separator(<<",", Rest/binary>>) ->
  263. Rest;
  264. skip_past_separator(<<_C, Rest/binary>>) ->
  265. skip_past_separator(Rest).
  266. -spec any_to_binary(binary() | string() | atom() | integer()) -> binary().
  267. any_to_binary(V) when is_binary(V) ->
  268. V;
  269. any_to_binary(V) when is_list(V) ->
  270. erlang:list_to_binary(V);
  271. any_to_binary(V) when is_atom(V) ->
  272. erlang:atom_to_binary(V, latin1);
  273. any_to_binary(V) when is_integer(V) ->
  274. list_to_binary(integer_to_list(V)).
  275. %% Tests.
  276. -ifdef(TEST).
  277. quote_test() ->
  278. %% ?assertError eunit macro is not compatible with coverage module
  279. _ = try quote(<<":wq">>)
  280. catch error:{cookie_quoting_required, <<":wq">>} -> ok
  281. end,
  282. ?assertEqual(<<"foo">>,quote(foo)),
  283. _ = try quote(<<"/test/slashes/">>)
  284. catch error:{cookie_quoting_required, <<"/test/slashes/">>} -> ok
  285. end,
  286. ok.
  287. parse_cookie_test() ->
  288. %% RFC example
  289. C1 = <<"$Version=\"1\"; Customer=\"WILE_E_COYOTE\"; $Path=\"/acme\";
  290. Part_Number=\"Rocket_Launcher_0001\"; $Path=\"/acme\";
  291. Shipping=\"FedEx\"; $Path=\"/acme\"">>,
  292. ?assertEqual(
  293. [{<<"Customer">>,<<"WILE_E_COYOTE">>},
  294. {<<"Part_Number">>,<<"Rocket_Launcher_0001">>},
  295. {<<"Shipping">>,<<"FedEx">>}],
  296. parse_cookie(C1)),
  297. %% Potential edge cases
  298. ?assertEqual(
  299. [{<<"foo">>, <<"x">>}],
  300. parse_cookie(<<"foo=\"\\x\"">>)),
  301. ?assertEqual(
  302. [],
  303. parse_cookie(<<"=">>)),
  304. ?assertEqual(
  305. [{<<"foo">>, <<"">>}, {<<"bar">>, <<"">>}],
  306. parse_cookie(<<" foo ; bar ">>)),
  307. ?assertEqual(
  308. [{<<"foo">>, <<"">>}, {<<"bar">>, <<"">>}],
  309. parse_cookie(<<"foo=;bar=">>)),
  310. ?assertEqual(
  311. [{<<"foo">>, <<"\";">>}, {<<"bar">>, <<"">>}],
  312. parse_cookie(<<"foo = \"\\\";\";bar ">>)),
  313. ?assertEqual(
  314. [{<<"foo">>, <<"\";bar">>}],
  315. parse_cookie(<<"foo=\"\\\";bar">>)),
  316. ?assertEqual(
  317. [],
  318. parse_cookie(<<"">>)),
  319. ?assertEqual(
  320. [{<<"foo">>, <<"bar">>}, {<<"baz">>, <<"wibble">>}],
  321. parse_cookie(<<"foo=bar , baz=wibble ">>)),
  322. ok.
  323. domain_test() ->
  324. ?assertEqual(
  325. {<<"Set-Cookie">>,
  326. <<"Customer=WILE_E_COYOTE; "
  327. "Version=1; "
  328. "Domain=acme.com; "
  329. "HttpOnly">>},
  330. cookie(<<"Customer">>, <<"WILE_E_COYOTE">>,
  331. [{http_only, true}, {domain, <<"acme.com">>}])),
  332. ok.
  333. local_time_test() ->
  334. {<<"Set-Cookie">>, B} = cookie(<<"Customer">>, <<"WILE_E_COYOTE">>,
  335. [{max_age, 111}, {secure, true}]),
  336. ?assertMatch(
  337. [<<"Customer=WILE_E_COYOTE">>,
  338. <<" Version=1">>,
  339. <<" Expires=", _R/binary>>,
  340. <<" Max-Age=111">>,
  341. <<" Secure">>],
  342. binary:split(B, <<";">>, [global])),
  343. ok.
  344. -spec cookie_test() -> no_return(). %% Not actually true, just a bad option.
  345. cookie_test() ->
  346. C1 = {<<"Set-Cookie">>,
  347. <<"Customer=WILE_E_COYOTE; "
  348. "Version=1; "
  349. "Path=/acme">>},
  350. C1 = cookie(<<"Customer">>, <<"WILE_E_COYOTE">>, [{path, <<"/acme">>}]),
  351. C1 = cookie(<<"Customer">>, <<"WILE_E_COYOTE">>,
  352. [{path, <<"/acme">>}, {badoption, <<"negatory">>}]),
  353. {<<"Set-Cookie">>,<<"=NoKey; Version=1">>}
  354. = cookie(<<"">>, <<"NoKey">>, []),
  355. {<<"Set-Cookie">>,<<"=NoKey; Version=1">>}
  356. = cookie(<<"">>, <<"NoKey">>),
  357. LocalTime = calendar:universal_time_to_local_time(
  358. {{2007, 5, 15}, {13, 45, 33}}),
  359. C2 = {<<"Set-Cookie">>,
  360. <<"Customer=WILE_E_COYOTE; "
  361. "Version=1; "
  362. "Expires=Tue, 15 May 2007 13:45:33 GMT; "
  363. "Max-Age=0">>},
  364. C2 = cookie(<<"Customer">>, <<"WILE_E_COYOTE">>,
  365. [{max_age, -111}, {local_time, LocalTime}]),
  366. C3 = {<<"Set-Cookie">>,
  367. <<"Customer=WILE_E_COYOTE; "
  368. "Version=1; "
  369. "Expires=Wed, 16 May 2007 13:45:50 GMT; "
  370. "Max-Age=86417">>},
  371. C3 = cookie(<<"Customer">>, <<"WILE_E_COYOTE">>,
  372. [{max_age, 86417}, {local_time, LocalTime}]),
  373. ok.
  374. -endif.