CORS (Cross-Origin Resource Sharing) plug for API endpoints.
This plug handles CORS headers for API requests, comparing the request's Origin with the origins
the current site allows. It supports both preflight (OPTIONS) requests and regular API requests,
automatically setting the appropriate CORS headers based on the site configuration.
What an origin is
An origin is a scheme, a host and a port, and all three have to be the same: https://site.com
does not allow http://site.com or https://site.com:9999, because a cookie is not kept apart by
scheme or port and the answer carries access-control-allow-credentials: true. The request's
Origin is read by MishkaCmsCore.Runtime.OriginValidator.request_origin/1 and must be nothing
but those three.
What a site allows
Worked out by MishkaCmsCore.Runtime.OriginValidator.site_origins/1 when the site is saved, and
looked up in MishkaCmsCore.Runtime.States.SiteState (allows_origin?/2) for each request, so a
site created or edited while the server runs is answered for what it allows now. A loopback host is
localhost, a name under .localhost, 127.0.0.1 or ::1.
| The site has | Allowed | Not allowed |
|---|---|---|
host: "site.com" | https://site.com | http://site.com, https://site.com:8443, https://sub.site.com |
host: "site-a.localhost" (loopback) | https://site-a.localhost, http://site-a.localhost | http://site-a.localhost:3000 |
frontend_domain: "app.example.com" | https://app.example.com | http://app.example.com, https://app.example.com:3000 |
frontend_domain: "app.example.com:3000" | https://app.example.com:3000 | http://app.example.com:3000, https://app.example.com |
frontend_domain: "localhost:5173" (loopback) | https://localhost:5173, http://localhost:5173 | http://localhost:5174 |
frontend_domain: "https://app.example.com:8443" | https://app.example.com:8443 | http://app.example.com:8443, https://app.example.com |
allowed_origins: ["https://partner.com"] | https://partner.com, https://partner.com:443 | http://partner.com, https://partner.com:8443 |
allowed_origins: ["http://other.com:8080/"] | http://other.com:8080 | https://other.com:8080, http://other.com |
Never allowed: a missing site, a phoenix_only site, more than one Origin header, null, and an
Origin with a path, a query, a fragment, credentials or a port that is not a number.
Development
Compiled only where the app's config sets :env_dev?, a loopback origin on any port, over http
or https, is allowed for any site the rules above let answer at all — so a frontend on
http://localhost:5173 reads the API of a site the database knows only by its public address. A
release has no such clause, and neither do the tests, which set :env_test? alone. Not
Mix.env/0: a dependency is compiled in :prod whatever the app runs in.
The answer
An allowed origin gets access-control-allow-origin with the origin as a browser writes it,
never the request's own text, and access-control-allow-credentials: true; a preflight from it
gets the methods, headers and max age too and is answered 204. A preflight from any other origin
is answered 204 with none of them. Every response that comes through here varies by Origin:
Origin is merged into the vary header already set or set later, once, whatever its case.
Usage
pipeline :api do
plug :accepts, ["json"]
plug MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug
plug MishkaCmsCore.Runtime.Plugs.ApiCorsPlug
endOptions
:site— the site to answer for, in place ofconn.assigns[:current_site].MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlugpasses the switched-off site it refuses, so that site's preflight is answered and its503carries the headers an allowed origin needs to read it.