823 lines
30 KiB
Elixir
823 lines
30 KiB
Elixir
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
|