Skip to content

Instantly share code, notes, and snippets.

@essen
Created February 13, 2020 16:41
Show Gist options
  • Save essen/5e0908a2ff230ed72ad7124dd3ff554c to your computer and use it in GitHub Desktop.
Save essen/5e0908a2ff230ed72ad7124dd3ff554c to your computer and use it in GitHub Desktop.
%% Copyright (c) 2020, Loïc Hoguin <[email protected]>
%%
%% Permission to use, copy, modify, and/or distribute this software for any
%% purpose with or without fee is hereby granted, provided that the above
%% copyright notice and this permission notice appear in all copies.
%%
%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
%% @todo A test suite can be created based on https://github.com/web-platform-tests/wpt/tree/master/cookies
-module(gun_cookies).
-export([domain_match/2]).
-export([gc/1]).
-export([path_match/2]).
-export([query/2]).
-export([session_gc/1]).
-export([set_cookie/5]).
-type store_state() :: any().
-type store() :: {module(), store_state()}.
-export_type([store/0]).
-type cookie() :: #{
name := binary(),
value := binary(),
domain := binary(),
path := binary(),
creation_time := calendar:datetime(),
last_access_time := calendar:datetime(),
expiry_time := calendar:datetime(),
persistent := boolean(),
host_only => boolean(),
secure_only := boolean(),
http_only := boolean(),
same_site := strict | lax | none
}.
-export_type([cookie/0]).
-callback set_cookie_secure_match(store_state(), #{
name := binary(),
% secure_only := true,
domain := binary(),
path := binary()
}) -> match | nomatch.
-callback set_cookie_exact_match(store_state(), #{
name := binary(),
domain := binary(),
host_only := boolean(),
path := binary()
}) -> {match, cookie()} | nomatch.
-callback store(State, cookie())
-> {ok, State} | {error, any()}
when State::store_state().
-spec domain_match(binary(), binary()) -> boolean().
domain_match(String, String) ->
true;
domain_match(String, DomainString) ->
SkipLen = byte_size(String) - byte_size(DomainString) - 1,
case String of
<<_:SkipLen/unit:8, $., DomainString/binary>> ->
case inet:parse_strict_address(binary_to_list(String)) of
{ok, _} ->
false;
{error, einval} ->
true
end;
_ ->
false
end.
-spec gc(Store) -> {ok, Store} when Store::store().
gc(Store) ->
%The user agent MUST evict all expired cookies from the cookie store if, at any time, an expired cookie exists in the cookie store.
%At any time, the user agent MAY “remove excess cookies” from the cookie store if the number of cookies sharing a domain field exceeds some implementation-defined upper bound (such as 50 cookies).
%At any time, the user agent MAY “remove excess cookies” from the cookie store if the cookie store exceeds some predetermined upper bound (such as 3000 cookies).
{todo, Store}.
-spec path_match(binary(), binary()) -> boolean().
path_match(Path, Path) ->
true;
path_match(ReqPath, CookiePath) ->
Len = byte_size(CookiePath),
CookieLast = binary:last(CookiePath),
case ReqPath of
<<CookiePath:Len/binary, _/bits>> when CookieLast =:= $/ ->
true;
<<CookiePath:Len/binary, $/, _/bits>> ->
true;
_ ->
false
end.
-spec query(Store, uri_string:uri_map())
-> {ok, [{binary(), binary()}], Store}
when Store::store().
query(Store, URI) ->
% https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html#cookie
{todo, Store, URI}.
-spec session_gc(Store) -> {ok, Store} when Store::store().
session_gc(Store) ->
%When “the current session is over” (as defined by the user agent), the user agent MUST remove from the cookie store all cookies with the persistent-flag set to false.
{todo, Store}.
-spec set_cookie(Store, uri_string:uri_map(), binary(), binary(), cow_cookie:cookie_opts())
-> {ok, Store} | {error, any()} when Store::store().
set_cookie(Store, URI, Name, Value, Attrs) ->
%% @todo This is where we would add a feature to block cookies (like a blacklist).
CurrentTime = erlang:universaltime(),
Cookie0 = #{
name => Name,
value => Value,
creation_time => CurrentTime,
last_access_time => CurrentTime
},
Cookie = case Attrs of
#{max_age := MaxAge} ->
Cookie0#{
persistent => true,
expiry_time => calendar:gregorian_seconds_to_datetime(
calendar:datetime_to_gregorian_seconds(CurrentTime) + MaxAge)
};
#{expires := Expires} ->
Cookie0#{
persistent => true,
expiry_time => cow_date:parse_date(Expires)
};
_ ->
Cookie0#{
persistent => false,
expiry_time => infinity
}
end,
Domain = maps:get(domain, Attrs, <<>>),
%% @todo This is where we would reject public suffixes. https://publicsuffix.org/
case Domain of
<<>> ->
set_cookie(Store, URI, Attrs, Cookie#{
host_only => true,
domain => maps:get(host, URI)
});
_ ->
%% @todo Domain must already be canonicalized here.
case domain_match(maps:get(host, URI), Domain) of
true ->
set_cookie(Store, URI, Attrs, Cookie#{
host_only => false,
domain => Domain
});
false ->
{error, domain_match_failed}
end
end.
set_cookie(Store, URI, Attrs, Cookie0) ->
%% @todo It's probably more complex, see 5.1.4.
Cookie1 = case Attrs of
#{path := Path} ->
Cookie0#{path => Path};
_ ->
Cookie0#{path => maps:get(path, URI)}
end,
SecureOnly = maps:get(secure, Attrs, false),
case {SecureOnly, maps:get(scheme, URI)} of
{true, <<"http">>} ->
{error, secure_scheme_only};
_ ->
Cookie = Cookie1#{
secure_only => SecureOnly,
http_only => maps:get(http_only, Attrs, false)
},
%% @todo This is where we would drop cookies from non-HTTP APIs.
set_cookie1(Store, URI, Attrs, Cookie)
end.
set_cookie1(Store, URI=#{scheme := <<"http">>}, Attrs, Cookie=#{secure_only := false}) ->
Match = maps:with([name, domain, path], Cookie),
case set_cookie_secure_match(Store, Match) of
match ->
{error, secure_cookie_matches};
nomatch ->
set_cookie2(Store, URI, Attrs, Cookie)
end;
set_cookie1(Store, URI, Attrs, Cookie) ->
set_cookie2(Store, URI, Attrs, Cookie).
set_cookie_secure_match({Mod, State}, Match) ->
%% @todo 2ways domain-match + path-match
Mod:set_cookie_secure_match(State, Match).
set_cookie2(Store, _URI, Attrs, Cookie0) ->
Cookie = Cookie0#{same_site => maps:get(same_site, Attrs, none)},
%% @todo This is where we would perform the same-site checks.
%%
%% It seems that an option would need to be added to Gun
%% in order to define the "site for cookies" value. It is
%% not the same as the site identified by the URI. Although
%% I do wonder if in the case of server push we may consider
%% the requested URI to be the "site for cookies", at least
%% by default.
%%
%% The URI argument will be used if/when the above gets
%% implemented.
set_cookie3(Store, Attrs, Cookie).
set_cookie3(Store, Attrs, Cookie=#{name := Name,
host_only := HostOnly, secure_only := SecureOnly}) ->
Path = maps:get(path, Attrs, undefined),
case Name of
<<"__Secure-",_/bits>> when not SecureOnly ->
{error, name_prefix_secure_requires_secure_only};
<<"__Host-",_/bits>> when not SecureOnly ->
{error, name_prefix_host_requires_secure_only};
<<"__Host-",_/bits>> when not HostOnly ->
{error, name_prefix_host_requires_host_only};
<<"__Host-",_/bits>> when Path =/= <<"/">> ->
{error, name_prefix_host_requires_top_level_path};
_ ->
set_cookie_store(Store, Cookie)
end.
set_cookie_store(Store, Cookie0) ->
Match = maps:with([name, domain, host_only, path], Cookie0),
Cookie = case set_cookie_exact_match(Store, Match) of
{match, #{creation_time := CreationTime}} ->
%% @todo This is where we would reject a new non-HTTP cookie
%% if the OldCookie has http_only set to true.
Cookie0#{creation_time => CreationTime};
nomatch ->
Cookie0
end,
store(Store, Cookie).
set_cookie_exact_match({Mod, State}, Match) ->
Mod:set_cookie_exact_match(State, Match).
store({Mod, State0}, Cookie) ->
case Mod:store(State0, Cookie) of
{ok, State} ->
{ok, {Mod, State}};
Error ->
Error
end.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment