lib/corsica.ex

defmodule Corsica do
  @moduledoc """
  [Plug](https://github.com/elixir-plug/plug)-based swiss-army knife for CORS requests.

  Corsica provides facilities for dealing with
  [CORS](http://en.wikipedia.org/wiki/Cross-origin_resource_sharing) requests
  and responses. It provides:

    * low-level functions that let you decide when and where to deal with CORS
      requests and CORS response headers;
    * a **plug** that handles CORS requests and responds to preflight requests;
    * a **router** that can be used in your modules in order to turn them into CORS
      handlers which provide fine control over dealing with CORS requests.

  ## How It Works

  Corsica is compliant with the [W3C CORS
  specification](http://www.w3.org/TR/cors/). As per this specification, Corsica
  **doesn't put any CORS response headers** in a connection that holds an invalid
  CORS request. To know what "invalid" CORS request means, have a look at the
  [*Validity of CORS Requests* section](#module-validity-of-cors-requests) below.

  > #### Headers or No Headers? {: .warning}
  >
  > When some options that are not mandatory and have no default value (such as
  > `:max_age`) are not passed to Corsica, the relative header will often **not be sent**
  > at all. This is compliant with the specification, and at the same time it reduces the size of
  > the response, even if just by a handful of bytes.

  The following is a list of all the *CORS response headers* supported by Corsica:

    * `Access-Control-Allow-Origin`
    * `Access-Control-Allow-Methods`
    * `Access-Control-Allow-Headers`
    * `Access-Control-Allow-Credentials`
    * `Access-Control-Allow-Private-Network`
    * `Access-Control-Expose-Headers`
    * `Access-Control-Max-Age`
    * `Vary` (see [the relevant section](#module-the-vary-header) below)

  ## Options

  Corsica supports the following options, in the `use` macro, in
  `Corsica.Router.resource/2`, and in the `Corsica` plug.

    * `:origins` (`t:origin/0`, list of `t:origin/0`, or the string `"*"`) This option is **required**. The origin of
      a request (specified by the `"origin"` request header) will be considered a valid origin
      if it "matches" at least one of the origins specified in `:origins`. What
      "matches" means depends on the type of origin. See `t:origin/0` for more information.

      The value `"*"` can also be used to match every origin and reply with `*` as
      the value of the `access-control-allow-origin` header. If `"*"` is used, it
      must be used as the only value of `:origins` (that is, it can't be used inside
      a list of accepted origins). For example:

          # Matches everything.
          plug Corsica, origins: "*"

          # Matches one of the given origins
          plug Corsica, origins: ["http://foo.com", "http://bar.com"]

          # Matches the given regex
          plug Corsica, origins: ~r{^https?://(.*\.?)foo\.com$}

      > #### The Origin Showed to Clients {: .info}
      >
      > This option directly influences the value of the
      > `access-control-allow-origin` response header. When `:origins` is `"*"`, the
      > `access-control-allow-origin` header is set to `*` as well. If the request's
      > origin is allowed and `:origins` is something different than `"*"`, then you
      > won't see that value as the value of the `access-control-allow-origin` header:
      > the value of this header will be the request's origin (which is *mirrored*).
      > This behaviour is intentional: it's compliant with the W3C CORS specification
      > and at the same time it provides the advantage of "hiding" all the allowed
      > origins from the client (which only sees its origin as an allowed origin).

    * `:allow_methods` (list of `t:String.t/0`, or `:all`) -
      This is the list
      of methods allowed in the `access-control-request-method` header of preflight
      requests. If the method requested by the preflight request is in this list or is
      a *simple method* (`HEAD`, `GET`, or `POST`), then that method is always allowed.
      The methods specified by this option are returned in the `access-control-allow-methods`
      response header. If the value of this option is `:all`, all
      request methods are allowed and only the method in `access-control-request-method` is
      returned as the value of the `access-control-allow-methods` header. Defaults to `["PUT", "PATCH", "DELETE"]` (which means these methods
      are allowed *alongside simple methods*).

    * `:allow_headers` (list of `t:String.t/0`, or `:all`) - This is the list
      of headers allowed in the `access-control-request-headers` header of preflight
      requests. If a header requested by the preflight request is in this list or is a
      *simple header*, then that
      header is always allowed. These are the simple headers defined in the spec:
        * `Accept`
        * `Accept-Language`
        * `Content-Language`

      The headers specified by this option are returned in the
      `access-control-allow-headers` response header. If the value of this option is `:all`, all request
      headers are allowed and only the headers in `access-control-request-headers` are
      returned as the value of the `access-control-allow-headers` header. Defaults to `[]` (which means only
      the simple headers are allowed)

    * `:allow_credentials` (`t:boolean/0`) - If `true`, sends the
      `access-control-allow-credentials` with value `true`. If `false`, prevents
      that header from being sent at all. Defaults to `false`.

      > #### `Access-Control-Allow-Origin` Header with Credentials {: .info}
      >
      > If `:origins` is set to `"*"` and
      > `:allow_credentials` is set to `true`, then the value of the
      > `access-control-allow-origin` header will always be the value of the
      > `origin` request header (as per the W3C CORS specification) and not `*`.

    * `:allow_private_network` (`t:boolean/0`0 - If `true`, sets the value of the
      `access-control-allow-private-network` header used with preflight requests, which
      indicates that a resource can be safely shared with external networks. If `false`,
      the `access-control-allow-private-network` is not sent at all. Defaults to `false`.

    * `:expose_headers` (list of `t:String.t/0`) Sets the value of
      the `access-control-expose-headers` response header. This option *does
      not* have a default value; if it's not provided, the
      `access-control-expose-headers` header is not sent at all.

    * `:max_age` (`t:String.t/0` or `t:non_neg_integer/0`) Sets the value of the
      `access-control-max-age` header used with preflight requests. This option
      *does not* have a default value; if it's not provided, the
      `access-control-max-age` header is not sent at all.

    * `:telemetry_metadata` (`t:map/0`) - *extra* telemetry metadata to be included in all emitted
      events. This can be useful for identifying which `plug Corsica` call is emitting
      the events. See `Corsica.Telemetry` for more information on Telemetry in Corsica.
      Available since v2.0.0.

    * `:passthrough_non_cors_requests` (`t:boolean/0`) - If `true`, allows
      non-CORS requests to pass through the plug. See `cors_req?/1` and
      `preflight_req?/1` to understand what constitutes a CORS request. What we
      mean by "allowing non-CORS requests" means that Corsica won't verify the
      `Origin` header and such, but will still add CORS headers to the response.
      Defaults to `false`. Available since v2.1.0.

  To recap which headers are sent based on options, here's a handy table:

  | Header                                 | Request Type      | Presence in the Response       |
  |----------------------------------------|-------------------|--------------------------------|
  | `access-control-allow-origin`          | simple, preflight | always                         |
  | `access-control-allow-headers`         | preflight         | always                         |
  | `access-control-allow-credentials`     | preflight         | `allow_credentials: true`      |
  | `access-control-allow-private-network` | preflight         | `allow_private_network: true`  |
  | `access-control-expose-headers`        | preflight         | `:expose_headers` is not empty |
  | `access-control-max-age`               | preflight         | `:max_age` is present          |

  ## Usage

  You can use Corsica as a plug or as a router.

  ### Using Corsica as a Plug

  When `Corsica` is used as a plug, it intercepts **all requests**. It only sets a
  bunch of CORS headers for regular CORS requests, but it responds (with a `200 OK`
  and the appropriate headers) to preflight requests.

  If you want to use `Corsica` as a plug, be sure to plug it in your plug
  pipeline **before** any router-like plug: routers like `Plug.Router` (or
  `Phoenix.Router`) respond to HTTP verbs as well as request URLs, so if
  `Corsica` is plugged after a router then preflight requests (which are
  `OPTIONS` requests), that will often result in 404 errors since no route responds to
  them. Router-like plugs also include plugs like `Plug.Static`, which
  respond to requests and halt the pipeline.

      defmodule MyApp.Endpoint do
        plug Head
        plug Corsica, max_age: 600, origins: "*", expose_headers: ~w(X-Foo)
        plug Plug.Static
        plug MyApp.Router
      end

  ### Using Corsica as a Router Generator

  When `Corsica` is used as a plug, it doesn't provide control over which urls
  are CORS-enabled or with which options. In order to do that, you can use
  `Corsica.Router`. See the documentation for `Corsica.Router` for more
  information.

  ## The `vary` Header

  When Corsica is configured such that the `access-control-allow-origin` response
  header will vary depending on the `origin` request header, then a `vary: origin`
  response header will be set.

  ## Responding to Preflight Requests

  When the request is a preflight request and a valid one (valid origin, valid
  request method, and valid request headers), Corsica directly sends a response
  to that request instead of just adding headers to the connection (so that a
  possible plug pipeline can continue). To do this, Corsica **halts the
  connection** (through `Plug.Conn.halt/1`) and **sends a response**.

  ## Validity of CORS Requests

  "Invalid CORS request" can mean that a request doesn't have an `Origin` header
  (so it's not a CORS request at all) or that it's a CORS request but:

    * the `Origin` request header doesn't match any of the allowed origins
    * the request is a preflight request but it requests to use a method or
      some headers that are not allowed (via the `Access-Control-Request-Method`
      and `Access-Control-Request-Headers` headers)

  ## Telemetry

  Corsica emits some [telemetry](https://github.com/beam-telemetry/telemetry) events.
  See `Corsica.Telemetry` for documentation.

  ## Logging

  Corsica used to support `Logger` logging through the `:log` option. This option
  has been removed in v2.0.0 in favor of Telemetry events. If you want to keep the
  logging behavior, see `Corsica.Telemetry.attach_default_handler/1`.
  """

  # Here are some nice (and apparently overlooked!) quotes from the W3C CORS
  # specification, along with some thoughts around them.
  #
  # http://www.w3.org/TR/cors/#access-control-allow-credentials-response-header
  # The syntax of the Access-Control-Allow-Credentials header only accepts the
  # value "true" (without quotes, case-sensitive). Any other value is not
  # conforming to the official CORS specification (many libraries tend to just
  # shove the value of a boolean in that header, so it happens to have the value
  # "false" as well).
  #
  # http://www.w3.org/TR/cors/#resource-requests, item 3.
  # > The string "*" cannot be used [as the value for the
  # > Access-Control-Allow-Origin] header for a resource that supports
  # > credentials.
  #
  # http://www.w3.org/TR/cors/#resource-implementation
  # > [...] [authors] should send a Vary: Origin HTTP header or provide other
  # > appropriate control directives to prevent caching of such responses, which
  # > may be inaccurate if re-used across-origins.
  #
  # http://www.w3.org/TR/cors/#resource-preflight-requests, item 9.
  # > If method is a simple method, [setting the Access-Control-Allow-Methods
  # > header] may be skipped (but it is not prohibited).
  # > Simply returning the method indicated by Access-Control-Request-Method (if
  # > supported) can be enough.
  # However, this behaviour can inhibit caching from the client side since the
  # client has to issue a preflight request for each method it wants to use,
  # while if all the allowed methods are returned every time then the cached
  # preflight request can be used more times.
  #
  # http://www.w3.org/TR/cors/#resource-preflight-requests, item 10.
  # > If each of the header field names is a simple header and none is
  # > Content-Type, [setting the Access-Control-Allow-Headers] may be
  # > skipped. Simply returning supported headers from
  # > Access-Control-Allow-Headers can be enough.
  # The same argument for Access-Control-Allow-Methods can be made here.

  import Plug.Conn

  alias Plug.Conn

  @behaviour Plug

  defmodule Options do
    @moduledoc false

    defstruct [
      :max_age,
      :expose_headers,
      :origins,
      allow_methods: ~w(PUT PATCH DELETE),
      allow_headers: [],
      allow_credentials: false,
      allow_private_network: false,
      passthrough_non_cors_requests: false,
      telemetry_metadata: %{}
    ]
  end

  @typedoc """
  An origin that can be specified in the `:origins` option.

  This is how each type of origin is used in order to check for "matching" origins:

    * strings - the actual origin and the allowed origin have to be identical

    * regexes - the actual origin has to match the allowed regex (as per `Regex.match?/2`)

    * `{module, function, args}` tuples - `module.function` is called with
      two extra arguments prepended to the given `args`: the current connection
      and the actual origin; if it returns `true` the origin is accepted,
      if it returns `false` the origin is not accepted.

  """
  @typedoc since: "2.0.0"
  @type origin() :: String.t() | Regex.t() | {module(), function :: atom(), args :: [term()]}

  @typedoc """
  Options accepted by most functions as well as the `Corsica` plug.

  The `%Options{}` struct is internal to Corsica and is used for performance.
  """
  @typedoc since: "2.1.0"
  @type options() :: keyword() | %Options{}

  @simple_methods ~w(GET HEAD POST)
  @simple_headers ~w(accept accept-language content-language)

  # Plug callbacks.

  @impl Plug
  def init(opts) do
    sanitize_opts(opts)
  end

  @impl Plug
  def call(%Conn{} = conn, %Options{} = opts) do
    cond do
      opts.passthrough_non_cors_requests and conn.method == "OPTIONS" ->
        send_preflight_resp(conn, opts)

      opts.passthrough_non_cors_requests ->
        put_cors_simple_resp_headers(conn, opts)

      not cors_req?(conn) ->
        conn

      not preflight_req?(conn) ->
        put_cors_simple_resp_headers(conn, opts)

      true ->
        send_preflight_resp(conn, opts)
    end
  end

  # Public so that it can be called from `Corsica.Router` (and for testing too).
  @doc false
  def sanitize_opts(opts) when is_list(opts) do
    opts
    |> require_origins_option()
    |> to_options_struct()
    |> Map.update!(:allow_methods, fn
      :all -> :all
      methods -> Enum.map(methods, &String.upcase/1)
    end)
    |> Map.update!(:allow_headers, fn
      :all -> :all
      headers -> Enum.map(headers, &String.downcase/1)
    end)
    |> maybe_update_option(:max_age, &to_string/1)
    |> maybe_update_option(:expose_headers, &Enum.join(&1, ","))
    |> maybe_warn_tuple_origins()
    |> maybe_warn_passthrough_non_cors_requests_option()
  end

  defp to_options_struct(opts), do: struct(Options, opts)

  defp require_origins_option(opts) do
    if not Keyword.has_key?(opts, :origins) do
      raise ArgumentError, "the :origins option is required"
    end

    opts
  end

  defp maybe_update_option(opts, option, update_fun) do
    if value = Map.get(opts, option) do
      Map.put(opts, option, update_fun.(value))
    else
      opts
    end
  end

  defp maybe_warn_tuple_origins(%{origins: origins} = opts) do
    for {_module, _function} = origin <- List.wrap(origins) do
      IO.warn(
        "passing #{inspect(origin)} as an allowed origin is deprecated, " <>
          "please see {module, function, args} for an alternative"
      )
    end

    opts
  end

  defp maybe_warn_passthrough_non_cors_requests_option(opts) do
    if opts.passthrough_non_cors_requests and opts.origins != "*" do
      IO.warn(
        "if the :passthrough_non_cors_requests option is set to true, " <>
          "then you need to set the :origins option to \"*\""
      )
    end

    opts
  end

  # Utilities

  @doc """
  Checks whether a given connection holds a CORS request.

  This function doesn't check if the CORS request is a *valid* CORS request: it
  just checks that it's a CORS request, that is, it has an `Origin` request
  header.
  """
  @spec cors_req?(Conn.t()) :: boolean
  def cors_req?(%Conn{} = conn), do: get_req_header(conn, "origin") != []

  @doc """
  Checks whether a given connection holds a preflight CORS request.

  This function doesn't check that the preflight request is a *valid* CORS
  request: it just checks that it's a preflight request. A request is considered
  to be a CORS preflight request if and only if its request method is `OPTIONS`
  and it has a `Access-Control-Request-Method` request header.

  Note that if a request is a valid preflight request, that makes it a valid
  CORS request as well. You can thus call just `preflight_req?/1` instead of
  `preflight_req?/1` and `cors_req?/1`.
  """
  @spec preflight_req?(Conn.t()) :: boolean
  def preflight_req?(%Conn{method: "OPTIONS"} = conn),
    do: cors_req?(conn) and get_req_header(conn, "access-control-request-method") != []

  def preflight_req?(%Conn{}), do: false

  # Request handling

  @doc """
  Sends a CORS preflight response regardless of the request being a valid CORS
  request or not.

  This function assumes nothing about `conn`. If it's a valid CORS preflight
  request with an allowed origin, CORS headers are set by calling
  `put_cors_preflight_resp_headers/2` and the response **is sent** with status
  `status` and body `body`. `conn` is **halted** before being sent.

  The response is always sent because if the request is not a valid CORS
  request, then no CORS headers will be added to the response. This behaviour
  will be interpreted by the browser as a non-allowed preflight request, as
  expected.

  For more information on what headers are sent with the response if the
  preflight request is valid, look at the documentation for
  `put_cors_preflight_resp_headers/2`.

  ## Options

  This function accepts the same options accepted by the `Corsica` plug
  (described in the documentation for the `Corsica` module).

  ## Examples

  This function could be used to manually build a plug that responds to
  preflight requests. For example:

      defmodule MyRouter do
        use Plug.Router
        plug :match
        plug :dispatch

        options "/foo",
          do: Corsica.send_preflight_resp(conn, origins: "*")
        get "/foo",
          do: send_resp(conn, 200, "ok")
      end

  """
  @spec send_preflight_resp(Conn.t(), 100..599, binary(), options()) :: Conn.t()
  def send_preflight_resp(conn, status \\ 200, body \\ "", opts)

  def send_preflight_resp(%Conn{} = conn, status, body, opts) when is_list(opts) do
    send_preflight_resp(conn, status, body, sanitize_opts(opts))
  end

  def send_preflight_resp(%Conn{} = conn, status, body, %Options{} = opts) do
    conn
    |> put_cors_preflight_resp_headers(opts)
    |> halt()
    |> send_resp(status, body)
  end

  @doc """
  Adds CORS response headers to a simple CORS request to `conn`.

  This function assumes nothing about `conn`. If `conn` holds an invalid CORS
  request or a request whose origin is not allowed, `conn` is returned
  unchanged; the absence of CORS headers will be interpreted as an invalid CORS
  response by the browser (according to the W3C spec).

  If the CORS request is valid, the following response headers are set:

    * `Access-Control-Allow-Origin`

  and the following headers are optionally set (if the corresponding option is
  present):

    * `Access-Control-Expose-Headers` (if the `:expose_headers` option is
      present)
    * `Access-Control-Allow-Credentials` (if the `:allow_credentials` option is
      `true`)

  ## Options

  This function accepts the same options accepted by the `Corsica` plug
  (described in the documentation for the `Corsica` module).

  ## Examples

      conn
      |> put_cors_simple_resp_headers(origins: "*", allow_credentials: true)
      |> send_resp(200, "Hello!")

  """
  @spec put_cors_simple_resp_headers(Conn.t(), options()) :: Conn.t()
  def put_cors_simple_resp_headers(conn, opts)

  def put_cors_simple_resp_headers(%Conn{} = conn, opts) when is_list(opts) do
    put_cors_simple_resp_headers(conn, sanitize_opts(opts))
  end

  def put_cors_simple_resp_headers(%Conn{} = conn, %Options{} = opts) do
    cond do
      opts.passthrough_non_cors_requests ->
        execute_telemetry(conn, opts, [:accepted_request], %{request_type: :simple})

        conn
        |> put_common_headers(opts)
        |> put_expose_headers_header(opts)

      not cors_req?(conn) ->
        execute_telemetry(conn, opts, [:invalid_request], %{request_type: :simple})
        conn

      not allowed_origin?(conn, opts) ->
        execute_telemetry(conn, opts, [:rejected_request], %{request_type: :simple})
        conn

      true ->
        execute_telemetry(conn, opts, [:accepted_request], %{request_type: :simple})

        conn
        |> put_common_headers(opts)
        |> put_expose_headers_header(opts)
    end
  end

  @doc """
  Adds CORS response headers to a preflight request to `conn`.

  This function assumes nothing about `conn`. If `conn` holds an invalid CORS
  request or an invalid preflight request, then `conn` is returned unchanged;
  the absence of CORS headers will be interpreted as an invalid CORS response by
  the browser (according to the W3C spec).

  If the request is a valid CORS request, the following headers will be added to
  the response:

    * `Access-Control-Allow-Origin`
    * `Access-Control-Allow-Methods`
    * `Access-Control-Allow-Headers`

  and the following headers will optionally be added (based on the value of the
  corresponding options):

    * `Access-Control-Allow-Credentials` (if the `:allow_credentials` option is
      `true`)
    * `Access-Control-Allow-Private-Network` (if the `:allow_private_network` option is
      `true`)
    * `Access-Control-Max-Age` (if the `:max_age` option is present)

  ## Options

  This function accepts the same options accepted by the `Corsica` plug
  (described in the documentation for the `Corsica` module).

  ## Examples

      put_cors_preflight_resp_headers conn, [
        max_age: 86400,
        allow_headers: ~w(X-Header),
        allow_private_network: true,
        origins: ~r/\w+\.foo\.com$/
      ]

  """
  @spec put_cors_preflight_resp_headers(Conn.t(), options()) :: Conn.t()
  def put_cors_preflight_resp_headers(conn, opts)

  def put_cors_preflight_resp_headers(%Conn{} = conn, opts) when is_list(opts) do
    put_cors_preflight_resp_headers(conn, sanitize_opts(opts))
  end

  def put_cors_preflight_resp_headers(%Conn{} = conn, %Options{} = opts) do
    cond do
      opts.passthrough_non_cors_requests ->
        execute_telemetry(conn, opts, [:accepted_request], %{request_type: :preflight})
        put_cors_preflight_resp_headers_no_check(conn, opts)

      not preflight_req?(conn) ->
        execute_telemetry(conn, opts, [:invalid_request], %{request_type: :preflight})
        conn

      not allowed_origin?(conn, opts) ->
        execute_telemetry(conn, opts, [:rejected_request], %{
          request_type: :preflight,
          reason: :origin_not_allowed
        })

        conn

      not allowed_preflight?(conn, opts) ->
        # More detailed info is emitted from allowed_preflight?/2.
        conn

      true ->
        execute_telemetry(conn, opts, [:accepted_request], %{request_type: :preflight})
        put_cors_preflight_resp_headers_no_check(conn, opts)
    end
  end

  defp put_cors_preflight_resp_headers_no_check(conn, opts) do
    conn
    |> put_common_headers(opts)
    |> put_allow_methods_header(opts)
    |> put_allow_headers_header(opts)
    |> put_allow_private_network_header(opts)
    |> put_max_age_header(opts)
  end

  defp put_common_headers(conn, %Options{} = opts) do
    conn
    |> put_allow_credentials_header(opts)
    |> put_allow_origin_header(opts)
    |> update_vary_header(opts)
  end

  defp put_allow_credentials_header(conn, %Options{allow_credentials: allow_credentials}) do
    if allow_credentials do
      put_resp_header(conn, "access-control-allow-credentials", "true")
    else
      conn
    end
  end

  defp put_allow_origin_header(conn, %Options{passthrough_non_cors_requests: true, origins: "*"}) do
    put_resp_header(conn, "access-control-allow-origin", "*")
  end

  defp put_allow_origin_header(conn, %Options{} = opts) do
    [actual_origin | _] = get_req_header(conn, "origin")

    value =
      if send_wildcard_origin?(opts) do
        "*"
      else
        actual_origin
      end

    put_resp_header(conn, "access-control-allow-origin", value)
  end

  # Add `vary: origin` response header if the `access-control-allow-origin` response header will
  # vary depending on the `origin` request header.
  defp update_vary_header(conn, %Options{origins: [origin]} = opts) do
    update_vary_header(conn, %{opts | origins: origin})
  end

  defp update_vary_header(conn, %Options{origins: origins} = opts) do
    cond do
      is_binary(origins) and origins != "*" -> conn
      send_wildcard_origin?(opts) -> conn
      true -> %{conn | resp_headers: [{"vary", "origin"} | conn.resp_headers]}
    end
  end

  defp send_wildcard_origin?(%Options{origins: origins, allow_credentials: allow_credentials}) do
    # '*' cannot be used as the value of the `Access-Control-Allow-Origins`
    # header if `Access-Control-Allow-Credentials` is true.
    origins == "*" and not allow_credentials
  end

  defp put_allow_methods_header(conn, %Options{allow_methods: allow_methods}) do
    value =
      if allow_methods == :all do
        hd(get_req_header(conn, "access-control-request-method"))
      else
        Enum.join(allow_methods, ",")
      end

    put_resp_header(conn, "access-control-allow-methods", value)
  end

  defp put_allow_headers_header(conn, %Options{allow_headers: allow_headers}) do
    allowed_headers =
      if allow_headers == :all do
        for req_headers <- get_req_header(conn, "access-control-request-headers"),
            req_headers = String.downcase(req_headers),
            req_header <- Plug.Conn.Utils.list(req_headers),
            do: req_header
      else
        allow_headers
      end

    put_resp_header(conn, "access-control-allow-headers", Enum.join(allowed_headers, ","))
  end

  defp put_allow_private_network_header(conn, %Options{allow_private_network: allow?}) do
    if allow? do
      put_resp_header(conn, "access-control-allow-private-network", "true")
    else
      conn
    end
  end

  defp put_max_age_header(conn, %Options{max_age: max_age}) do
    if max_age do
      put_resp_header(conn, "access-control-max-age", max_age)
    else
      conn
    end
  end

  defp put_expose_headers_header(conn, %Options{expose_headers: expose_headers}) do
    if expose_headers && expose_headers != "" do
      put_resp_header(conn, "access-control-expose-headers", expose_headers)
    else
      conn
    end
  end

  # Made public since this function is only called by macros as of now, and so
  # an 'unused function' warning is issued if the macros produce no code.
  @doc false
  def origin(conn) do
    conn |> get_req_header("origin") |> List.first()
  end

  # Made public for testing
  @doc false
  def allowed_origin?(_conn, %Options{origins: "*"}) do
    true
  end

  def allowed_origin?(conn, %Options{origins: origins}) do
    [origin | _] = get_req_header(conn, "origin")
    Enum.any?(List.wrap(origins), &matching_origin?(&1, origin, conn))
  end

  defp matching_origin?(origin, origin, _conn), do: true
  defp matching_origin?(allowed, _actual, _conn) when is_binary(allowed), do: false
  defp matching_origin?(%Regex{} = allowed, actual, _conn), do: Regex.match?(allowed, actual)

  defp matching_origin?({module, function, args}, actual, conn)
       when is_atom(module) and is_atom(function) and is_list(args) do
    apply(module, function, [conn, actual | args])
  end

  defp matching_origin?({module, function}, actual, _conn)
       when is_atom(module) and is_atom(function) do
    apply(module, function, [actual])
  end

  # Made public for testing.
  @doc false
  def allowed_preflight?(conn, %Options{} = opts) do
    allowed_request_method?(conn, opts) and allowed_request_headers?(conn, opts)
  end

  defp allowed_request_method?(_conn, %Options{allow_methods: :all}) do
    true
  end

  defp allowed_request_method?(conn, %Options{allow_methods: allow_methods} = opts) do
    # We can safely assume there's an Access-Control-Request-Method header
    # otherwise the request wouldn't have been identified as a preflight
    # request.
    [req_method | _] = get_req_header(conn, "access-control-request-method")

    if req_method in @simple_methods or req_method in allow_methods do
      true
    else
      execute_telemetry(conn, opts, [:rejected_request], %{
        request_type: :preflight,
        reason: {:req_method_not_allowed, req_method}
      })

      false
    end
  end

  defp allowed_request_headers?(_conn, %Options{allow_headers: :all}) do
    true
  end

  defp allowed_request_headers?(conn, %Options{allow_headers: allow_headers} = opts) do
    non_allowed_headers =
      for req_headers <- get_req_header(conn, "access-control-request-headers"),
          req_headers = String.downcase(req_headers),
          req_header <- Plug.Conn.Utils.list(req_headers),
          not (req_header in @simple_headers or req_header in allow_headers),
          do: req_header

    if non_allowed_headers == [] do
      true
    else
      execute_telemetry(conn, opts, [:rejected_request], %{
        request_type: :preflight,
        reason: {:req_headers_not_allowed, non_allowed_headers}
      })

      false
    end
  end

  defp execute_telemetry(conn, %Options{} = opts, event_name, extra_meta) do
    meta =
      %{conn: conn}
      |> Map.merge(extra_meta)
      |> Map.merge(opts.telemetry_metadata)

    :telemetry.execute([:corsica] ++ event_name, _measurements = %{}, meta)
  end
end