Current section

Files

Jump to
http_cookie lib http_cookie.ex
Raw

lib/http_cookie.ex

defmodule HttpCookie do
@moduledoc """
HTTP Cookie struct with parsing and related logic.
Implemented according to [RFC6265](https://datatracker.ietf.org/doc/html/rfc6265)
"""
import __MODULE__.Parser, only: [latest_expiry_time: 0]
import __MODULE__.Util, only: [pretty_module: 1]
alias __MODULE__.{Parser, URL}
@dialyzer {:nowarn_function, matches_url?: 2}
@derive {Inspect, except: [:value]}
@enforce_keys [:name, :value]
defstruct [
:name,
:value,
:domain,
:path,
:creation_time,
:expiry_time,
:last_access_time,
:persistent?,
:host_only?,
:secure_only?,
:http_only?
]
@type t :: %__MODULE__{
name: binary(),
value: binary(),
domain: binary(),
path: binary(),
creation_time: DateTime.t(),
expiry_time: DateTime.t(),
last_access_time: DateTime.t(),
persistent?: boolean(),
host_only?: boolean(),
secure_only?: boolean(),
http_only?: boolean()
}
@doc """
Creates an HttpCookie from a `Set-Cookie` header value.
## Options
- `:max_cookie_size` - maximum size of cookie string in bytes, positive integer or :infinity, default: 8_192
"""
@spec from_cookie_string(str :: String.t(), request_url :: URI.t()) ::
{:ok, t()} | {:error, atom()}
@spec from_cookie_string(str :: String.t(), request_url :: URI.t(), opts :: keyword()) ::
{:ok, t()} | {:error, atom()}
def from_cookie_string(str, %URI{} = request_url, opts \\ []) do
validate_opts!(opts)
max_size = Keyword.get(opts, :max_cookie_size, 8_192)
with :ok <- check_size(str, max_size),
{name, value, attributes} = Parser.parse_cookie_string(str, request_url),
attributes = Enum.reverse(attributes),
{:ok, cookie} <- new_cookie(name, value),
cookie = set_expiry_time(cookie, attributes),
cookie = set_domain(cookie, attributes),
{:ok, cookie} <- check_public_suffix(cookie, request_url),
{:ok, cookie} <- check_domain(cookie, request_url) do
cookie =
cookie
|> set_path(attributes, request_url)
|> set_secure_only(attributes)
|> set_http_only(attributes)
{:ok, cookie}
end
end
@doc """
Formats the cookie for sending in a "Cookie" header.
"""
@spec to_header_value(cookie :: t()) :: String.t()
def to_header_value(cookie) do
"#{cookie.name}=#{cookie.value}"
end
@doc """
Checks if the cookie has expired.
Uses the current time if no time is provided.
"""
@spec expired?(cookie :: t()) :: boolean()
@spec expired?(cookie :: t(), now :: DateTime.t()) :: boolean()
def expired?(cookie, now \\ DateTime.utc_now()) do
DateTime.after?(now, cookie.expiry_time)
end
# 5.4. The Cookie Header
# [...]
# 1. Let cookie-list be the set of cookies from the cookie store that
# meets all of the following requirements:
#
# * Either:
#
# The cookie's host-only-flag is true and the canonicalized
# request-host is identical to the cookie's domain.
#
# Or:
#
# The cookie's host-only-flag is false and the canonicalized
# request-host domain-matches the cookie's domain.
#
# * The request-uri's path path-matches the cookie's path.
#
# * If the cookie's secure-only-flag is true, then the request-
# uri's scheme must denote a "secure" protocol (as defined by
# the user agent).
#
# NOTE: The notion of a "secure" protocol is not defined by
# this document. Typically, user agents consider a protocol
# secure if the protocol makes use of transport-layer
#
# security, such as SSL or TLS. For example, most user
# agents consider "https" to be a scheme that denotes a
# secure protocol.
#
# * If the cookie's http-only-flag is true, then exclude the
# cookie if the cookie-string is being generated for a "non-
# HTTP" API (as defined by the user agent).
@doc """
Checks if the cookie matches the provided request url.
The check is done as specified in [RFC 6265](https://datatracker.ietf.org/doc/html/rfc6265#section-5.4).
"""
@spec matches_url?(cookie :: t(), request_url :: URI.t()) :: boolean()
def matches_url?(cookie, %URI{} = request_url) do
request_domain = URL.canonicalize_domain(request_url.host)
request_path = URL.normalize_path(request_url.path)
matches_domain?(cookie, request_domain) and
URL.path_match?(request_path, cookie.path) and
(!cookie.secure_only? or request_url.scheme == "https")
end
@doc """
Updates the cookie last access time.
Uses the current time if no time is provided.
"""
@spec update_last_access_time(cookie :: t()) :: t()
@spec update_last_access_time(cookie :: t(), DateTime.t()) :: t()
def update_last_access_time(%__MODULE__{} = cookie, now \\ DateTime.utc_now()) do
%{cookie | last_access_time: now}
end
@doc false
@spec validate_opts!(opts :: keyword()) :: :ok
def validate_opts!(opts) when is_list(opts) do
Enum.each(opts, fn {k, v} -> validate_opt!(k, v) end)
end
defp validate_opt!(:max_cookie_size, :infinity), do: :ok
defp validate_opt!(:max_cookie_size, cnt) when is_integer(cnt) and cnt >= 0, do: :ok
defp validate_opt!(:max_cookie_size = k, val) do
raise ArgumentError,
"[#{pretty_module(__MODULE__)}] invalid value for :#{k} option: #{inspect(val)}\n\n expected :infinity or an integer >= 0"
end
defp validate_opt!(k, _) do
raise ArgumentError, "[#{pretty_module(__MODULE__)}] invalid option #{inspect(k)}"
end
defp matches_domain?(%{domain: domain}, domain), do: true
defp matches_domain?(%{host_only?: true}, _domain), do: false
defp matches_domain?(%{host_only?: false} = cookie, domain) do
URL.domain_match?(domain, cookie.domain)
end
defp new_cookie("", _value), do: {:error, :cookie_missing_name}
defp new_cookie(_name, nil), do: {:error, :cookie_missing_value}
# 2. Create a new cookie with name cookie-name, value cookie-value.
# Set the creation-time and the last-access-time to the current
# date and time.
defp new_cookie(name, value) do
now = DateTime.utc_now()
cookie = %__MODULE__{
name: name,
value: value,
creation_time: now,
last_access_time: now
}
{:ok, cookie}
end
# 3. If the cookie-attribute-list contains an attribute with an
# attribute-name of "Max-Age":
#
# Set the cookie's persistent-flag to true.
#
# Set the cookie's expiry-time to attribute-value of the last
# attribute in the cookie-attribute-list with an attribute-name
# of "Max-Age".
#
# Otherwise, if the cookie-attribute-list contains an attribute
# with an attribute-name of "Expires" (and does not contain an
# attribute with an attribute-name of "Max-Age"):
#
# Set the cookie's persistent-flag to true.
#
# Set the cookie's expiry-time to attribute-value of the last
# attribute in the cookie-attribute-list with an attribute-name
# of "Expires".
#
# Otherwise:
#
# Set the cookie's persistent-flag to false.
#
# Set the cookie's expiry-time to the latest representable
# date.
defp set_expiry_time(cookie, attributes) do
with nil <- List.keyfind(attributes, "Max-Age", 0),
nil <- List.keyfind(attributes, "Expires", 0) do
%{cookie | expiry_time: latest_expiry_time(), persistent?: false}
else
{"Max-Age", expiry_time} ->
%{cookie | expiry_time: expiry_time, persistent?: true}
{"Expires", expiry_time} ->
%{cookie | expiry_time: expiry_time, persistent?: true}
end
end
# 4. If the cookie-attribute-list contains an attribute with an
# attribute-name of "Domain":
#
# Let the domain-attribute be the attribute-value of the last
# attribute in the cookie-attribute-list with an attribute-name
# of "Domain".
#
# Otherwise:
#
# Let the domain-attribute be the empty string.
defp set_domain(cookie, attributes) do
case List.keyfind(attributes, "Domain", 0) do
{"Domain", domain} -> %{cookie | domain: domain}
nil -> %{cookie | domain: ""}
end
end
# 5. If the user agent is configured to reject "public suffixes" and
# the domain-attribute is a public suffix:
#
# If the domain-attribute is identical to the canonicalized
# request-host:
#
# Let the domain-attribute be the empty string.
#
# Otherwise:
#
# Ignore the cookie entirely and abort these steps.
defp check_public_suffix(cookie, request_url) do
request_domain = URL.canonicalize_domain(request_url.host)
public_suffix? = URL.public_suffix?(cookie.domain)
cond do
public_suffix? and cookie.domain == request_domain ->
{:ok, %{cookie | domain: ""}}
public_suffix? ->
{:error, :cookie_domain_public_suffix}
true ->
{:ok, cookie}
end
end
# 6. If the domain-attribute is non-empty:
#
# If the canonicalized request-host does not domain-match the
# domain-attribute:
#
# Ignore the cookie entirely and abort these steps.
#
# Otherwise:
#
# Set the cookie's host-only-flag to false.
#
# Set the cookie's domain to the domain-attribute.
#
# Otherwise:
#
# Set the cookie's host-only-flag to true.
#
# Set the cookie's domain to the canonicalized request-host.
defp check_domain(%{domain: ""} = cookie, request_url) do
cookie = %{cookie | domain: request_url.host, host_only?: true}
{:ok, cookie}
end
defp check_domain(%{domain: cookie_domain} = cookie, request_url) do
request_domain = URL.canonicalize_domain(request_url.host)
if URL.domain_match?(request_domain, cookie_domain) do
cookie = %{cookie | host_only?: false}
{:ok, cookie}
else
{:error, :cookie_domain_mismatch}
end
end
# 7. If the cookie-attribute-list contains an attribute with an
# attribute-name of "Path", set the cookie's path to attribute-
# value of the last attribute in the cookie-attribute-list with an
# attribute-name of "Path". Otherwise, set the cookie's path to
# the default-path of the request-uri.
defp set_path(cookie, attributes, request_url) do
case List.keyfind(attributes, "Path", 0) do
{"Path", domain} -> %{cookie | path: domain}
nil -> %{cookie | path: URL.default_path(request_url)}
end
end
# 8. If the cookie-attribute-list contains an attribute with an
# attribute-name of "Secure", set the cookie's secure-only-flag to
# true. Otherwise, set the cookie's secure-only-flag to false.
defp set_secure_only(cookie, attributes) do
case List.keyfind(attributes, "Secure", 0) do
{"Secure", _} -> %{cookie | secure_only?: true}
nil -> %{cookie | secure_only?: false}
end
end
# 9. If the cookie-attribute-list contains an attribute with an
# attribute-name of "HttpOnly", set the cookie's http-only-flag to
# true. Otherwise, set the cookie's http-only-flag to false.
defp set_http_only(cookie, attributes) do
case List.keyfind(attributes, "HttpOnly", 0) do
{"HttpOnly", _} -> %{cookie | http_only?: true}
nil -> %{cookie | http_only?: false}
end
end
defp check_size(_str, :infinity), do: :ok
defp check_size(str, max_size) when byte_size(str) <= max_size, do: :ok
defp check_size(_str, _max_size), do: {:error, :cookie_exceeds_max_size}
end