Caddy Server, CORS, and Preflight Requests
Posted on April 13, 2023 • 4 minutes • 747 words • Other languages: Deutsch
I have been using the Caddy Server lately. I have been loyal to nginx for a long time, but I am thrilled about how easy and fast it is to set up websites with Caddy written in Go. it just saves a lot of boilerplate and work. Caddy takes care of creating certificates (e.g. Lets Encrypt ), automatic redirections from HTTP to HTTPs, supports HTTP3, and just needs a few lines of configuration.
Cross-Site-Scripting, CORS, Preflight-Requests
However, I encountered a problem when I wanted to use Caddy as an API gateway: I ran into cross-origin issues. These
problems occur whenever you want to access a foreign domain using JavaScript (e.g. calling api.auxnet.de
from
auxnet.de
). For security reasons, browsers send a so-called preflight request, which is a technically an HTTP request
employing the OPTIONS method. The server must respond correctly to this request. Otherwise, an error message will
appear in the console. Only if the preflight request has been correctly answered, the browser initiates the actual
request (e.g., an API request).
There are security reasons for this procedure. CORS (Cross-Origin Resource Sharing) is a mechanism designed to prevent Internet resources from being used by unauthorized parties. Details about the procedure can be found, among other places, on the Mozilla Developer Network or in blog posts .
Caddy and CORS
Caddy does a lot automatically, but when it comes to CORS, you have to do it yourself. This is not surprising, Caddy cannot know what settings are necessary in your specific use-case.
We need two components:
- One component handles the OPTIONS requests.
- Another component adds corresponding HTTP headers.
Let’s start with the first one. A browser expects a response without content, i.e., 204 No Content
. This can be
accomplished in Caddy as follows:
api.auxnet.de {
@cors_preflight {
method OPTIONS
}
respond @cors_preflight 204
}
So we create a named matcher that can respond to requests with the OPTIONS method. Second, we create the directive respond , which, well, responds to the matcher and simply returns 204.
So far, so good. Now we need the so-called CORS headers, of which there seems to be a whole zoo. However, usually only three to four are really relevant, and those should be set in any case:
Access-Control-Allow-Origin
: Specifies from which sources the resource can be requested.*
means from anywhere. If you do not run a completely public API, you should specify domains here. The correct syntax is really essential here! Specify the complete domain, including the protocol, but without path information (so no trailing/
). There must only be one domain (see below for multiple domains). Example:https://www.auxnet.de
.Access-Control-Allow-Methods
: Comma-separated list of allowed methods, which should be capitalized. Example:GET,POST,OPTIONS,HEAD,PATCH,PUT,DELETE
.Access-Control-Allow-Headers
: Allowed headers of the request. Sometimes, this needs to be determined through trial and error.Content-Type
should definitely be included because it is a header that all browsers send. The canonical spelling is also helpful to avoid errors (= all words start with a capital letter, the rest is lowercase). Example:User-Agent,Content-Type,X-Api-Key
.Access-Control-Max-Age
: Optional header indicating the time in seconds for which the response of the preflight request should be cached.
So let’s expand our hypothetical configuration from above:
api.auxnet.de {
@cors_preflight {
method OPTIONS
}
respond @cors_preflight 204
header {
Access-Control-Allow-Origin https://www.auxnet.de
Access-Control-Allow-Methods GET,POST,OPTIONS,HEAD,PATCH,PUT,DELETE
Access-Control-Allow-Headers User-Agent,Content-Type,X-Api-Key
Access-Control-Max-Age 86400
}
}
We can test our configuration using curl:
curl -I -XOPTIONS https://api.auxnet.de/
The output should contain the following headers (the order is changed because Go orders the keys of the map alphabetically):
Access-Control-Allow-Headers: User-Agent,Content-Type,X-Api-Key
Access-Control-Allow-Methods: GET,POST,OPTIONS,HEAD,PATCH,PUT,DELETE
Access-Control-Allow-Origin: https://www.auxnet.de
Access-Control-Max-Age: 86400
Notice that the headers are sent with every response including GET or POST requests. Browsers seem to require CORS headers for every type of request, not just for the OPTIONS request.
Multiple Origin Domains
Sometimes you might want to allow access from multiple domains. Unfortunately, Access-Control-Allow-Origin
allows
one domain only, or *
for the while Internet. You can use more named matchers to cope with this problem:
api.auxnet.de {
@cors_preflight {
method OPTIONS
}
respond @cors_preflight 204
@origin1 {
header Origin https://www.auxnet.de
}
header @origin1 {
Access-Control-Allow-Origin https://www.auxnet.de
}
@origin2 {
header Origin https://auxnet.de
}
header @origin2 {
Access-Control-Allow-Origin https://auxnet.de
}
header {
Access-Control-Allow-Methods GET,POST,OPTIONS,HEAD,PATCH,PUT,DELETE
Access-Control-Allow-Headers User-Agent,Content-Type,X-Api-Key
Access-Control-Max-Age 86400
}
}
Here we create the header depending on the origin domain. You can test the setting like this:
curl -I -H'Origin: https://www.auxnet.de' -XOPTIONS https://api.auxnet.de/
curl -I -H'Origin: https://auxnet.de' -XOPTIONS https://api.auxnet.de/
curl -I -H'Origin: https://otherdomain.com' -XOPTIONS https://api.auxnet.de/
The first two requests should return the correct header, the last one should not.