Enabling CORS for nginx WebDAV and CalDAV reverse-proxy
The past few weeks I’ve been learning to develop and deploy a Progress Web App (PWA) that can communicate with my WebDAV and CalDAV servers. Unfortunately, while these are on the same domain, they are on different sub-domains, and this causes the requests to be considered cross-origin requests. For security reasons, cross-origin requests are blocked by most browsers by default unless the server explicitly allows cross-origin resource sharing (CORS). This is pretty easy to set up for static resources or scripts, if they use default headers and GET and POST methods. However, it’s particularly complicated for WebDAV, CalDAV, and other protocols that use additional headers or methods.
Table of Contents
|
|
|
1 TLDR
Copy/paste/modify the below snippets into your nginx.conf in the correct places.
You’ll need to add the map
declarations to http
context, and merge the two server
declarations into your WebDAV and CalDAV server configuration blocks.
You’ll also need to customize the safelist that sets $cors_origin_header
, and possibly the $cors_expose_headers
and $cors_allow_headers
variables.
http {
# .. in http context ..
# Declare the safe cross-origin hosts
map $http_origin $cors_origin_header {
hostnames;
default "https://example.com";
"https://example.com" "$http_origin";
"https://www.example.com" "$http_origin";
}
# Declare CORS exposed response headers
map $host $std_response_headers {
default "Content-Type, Content-Range, Content-Language, Date, Content-Length, Content-Encoding";
}
map $host $cache_control_response_headers {
default "Etag, Last-Modified";
}
map $host $dav_response_headers {
default "Dav";
}
map $host $cors_expose_headers {
default "${dav_response_headers}, ${std_response_headers}, ${cache_control_response_headers}";
}
# Declare CORS allowed request headers
map $host $std_request_headers {
default "Authorization, Origin, X-Requested-With, Range, Accept-Encoding, Content-Length, Content-Type";
}
map $host $dav_request_headers {
default "If-Match, If-None-Match, If-Modified-Since, Depth";
}
map $host $cors_allow_headers {
default "${dav_request_headers}, ${std_request_headers}";
}
# Detect a preflight request
map $http_access_control_request_headers $preflight_h {
default "true";
"" "false";
}
map $http_access_control_request_method $preflight_m {
default "true";
"" "false";
}
map $request_method $preflight {
default "false";
"OPTIONS" "${preflight_h}${preflight_m}true";
}
# Configure WebDAV
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name webdav.example.com;
location /.well-known/ {
root /srv/http/www;
}
# Advertise CORS access controls.
add_header "Access-Control-Allow-Origin" "$cors_origin_header" always;
add_header "Access-Control-Allow-Credentials" "true" always;
add_header "Access-Control-Expose-Headers" "$cors_expose_headers" always;
location / {
# Handle preflight request
if ($preflight = "truetruetrue"){
add_header "Access-Control-Allow-Origin" "$cors_origin_header";
add_header "Access-Control-Allow-Headers" "$cors_allow_headers";
add_header "Access-Control-Allow-Methods" "PROPFIND, COPY, MOVE, MKCOL, CONNECT, DELETE, DONE, GET, HEAD, OPTIONS, PATCH, POST, PUT";
add_header "Access-Control-Max-Age" 1728000;
add_header "Content-Type" "text/plain charset=UTF-8";
add_header "Content-Length" 0;
return 204;
}
auth_basic "Not currently available";
auth_basic_user_file /etc/nginx/htpasswd;
root /srv/http/webdav/data;
client_body_temp_path /tmp/nginx-webdav;
client_max_body_size 0;
dav_methods PUT DELETE MKCOL COPY MOVE;
dav_ext_methods PROPFIND OPTIONS;
create_full_put_path on;
dav_access user:rw group:r;
autoindex on;
}
}
# CalDAV and CardDAV
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name caldav.example.com carddav.example.com;
location /.well-known/ {
root /srv/http/www;
}
location /.well-known/caldav {
return 301 https://caldav.example.com/;
}
location /.well-known/carddav {
return 301 https://carddav.example.com/;
}
add_header "Access-Control-Allow-Origin" "$cors_origin_header" always;
add_header "Access-Control-Allow-Credentials" "true" always;
add_header "Access-Control-Expose-Headers" "$cors_expose_headers" always;
location / {
if ($preflight = "truetruetrue"){
add_header "Access-Control-Allow-Origin" "$cors_origin_header";
add_header "Access-Control-Allow-Headers" "$cors_allow_headers";
add_header "Access-Control-Allow-Methods" "REPORT, PROPFIND, COPY, MOVE, MKCOL, CONNECT, DELETE, DONE, GET, HEAD, OPTIONS, PATCH, POST, PUT";
add_header "Access-Control-Max-Age" 1728000;
add_header "Content-Type" "text/plain charset=UTF-8";
add_header "Content-Length" 0;
return 204;
}
auth_basic "Not currently available";
auth_basic_user_file /etc/nginx/caldav/htpasswd;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_header Authorization;
proxy_pass http://127.0.0.1:5232/;
}
}
}
2 CORS Requests and Responses
2.1 Preflight
When a script running in a secure browser attempts to make a cross-origin request, the browser first send a preflight request (for non-trivial requests), and then sends the actual request if the server advertises that CORS is enabled for that request. A preflight request might be skipped for an HTTP GET method request, because this is considered harmless.
2.1.1 Preflight Request
-
Access-Control-Request-Headers, which declares the headers that the cross-origin script is requesting.
-
Access-Control-Request-Method, which declares the method of the request that the cross-origin script wants to send.
-
Origin, which declares the domain of the origin of the script that wants to make a cross-origin request
For HTTP servers serving static content or scripts that don’t use OPTIONS, it’s enough to detect an OPTIONS request, set the above headers, and return a 204 status code. For some HTTP servers, like WebDAV and CalDAV, the OPTIONS request has another use, and we really have to detect a preflight reuqest by detecting both an OPTIONS request and the preflight request headers.
2.1.2 Preflight Response
-
Access-Control-Allow-Origin, which declares the hostnames that are allowed to make cross-origin requests. This ought to include the Origin of preflight request for the preflight request to succeed, and can be a wildcard value *.
-
Access-Control-Allow-Headers, which declares which headers are allowed to be part of the cross-origin request. These are all be HTTP request headers.
-
Access-Control-Allow-Methods, which declares which HTTP methods are allowed as part of cross-origin request.
-
Access-Control-Max-Age, an optional header that declares how long this response to a preflight request can be cached;
Some browsers (such as Firefox, and Chromium) will consider the preflight request as succeeding if the above headers are present, even if the status code is not 204, and even if the request contains other data.
See https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header for more details.
-
The following standard headers: Authorization, Origin, X-Requested-With, Range, Accept-Encoding, Content-Length, Content-Type;
-
The following DAV headers: If-Match, If-None-Match, If-Modified-Since, Depth
Similarly, figuring out exactly which methods to list in Access-Control-Allow-Methods depends on the app and server (but not the browser). These methods are probably more well-specified. For WebDAV and CalDAV, the following were sufficient: REPORT, PROPFIND, COPY, MOVE, MKCOL, CONNECT, DELETE, DONE, GET, HEAD, OPTIONS, PATCH, POST, PUT.
2.2 Cross-Origin Requests
-
Access-Control-Allow-Origin, which declares the hostnames of cross-origin scripts that this response can be shared with.
-
Access-Control-Allow-Credentials, which is either "true" or "false", and declares whether the authorization information in this response can be shared with cross-origin scripts.
-
Access-Control-Expose-Headers, which declares which HTTP response headers can be exposed to the cross-origin script.
-
The following standard response headers: Content-Type, Content-Range, Content-Language, Date, Content-Length, Content-Encoding;
-
The following response headers that have to do with cache control: Etag, Last-Modified. You may want to add Pragma if you support HTTP 1.0, and Cache-Control and Expires if your server needs to direct your app about cache expiration. Etag and Last-Modified were sufficient for detecting changes between the local and remote versions of DAV files in my app.
-
The following DAV-specific headers: DAV.
3 Configuring nginx
Configuring nginx correctly is tricky due to the design of the nginx configuration language. It is a declarative language, but can look imperative and trip us up. We have to be careful in how we conditionally add headers and process requests.
nginx also doesn’t allow us to use set
to create variables in all contexts, so we have to be a little clever at times.
3.1 Configure Valid Cross-Origin Hosts
map
to declare the variable $cors_origin_header
to be the origin, if the origin is on the safelist.
map $http_origin $cors_origin_header {
hostnames;
default "https://example.com";
"https://example.com" "$http_origin";
"https://www.example.com" "$http_origin";
}
In this safelist, we allow cross-origin requests from https://examples.com
and https://www.examples.com
, but no other hosts.
We could use the wildcard "*"
to allow requests from anyone.
3.2 Configure CORS Headers
In http
context, I use the following map
s to declares the CORS request and response headers.
This is an abuse of map
to give us the ability do define variable in http
context, since set
doesn’t work in http
context.
You’re free to inline these header values later, but separating them out into these variables made them easier to reuse in both the WebDAV and CalDAV servers.
# Declare allowed CORS Expose Headers; each is an HTTP response header.
map $host $std_response_headers {
default "Content-Type, Content-Range, Content-Language, Date, Content-Length, Content-Encoding";
}
map $host $cache_control_response_headers {
default "Etag, Last-Modified";
}
map $host $dav_response_headers {
default "DAV";
}
map $host $cors_expose_headers {
default "${dav_response_headers}, ${std_response_headers}, ${cache_control_response_headers}";
}
# Declare allowed CORS Request Headers; each is an http request header.
map $host $std_request_headers {
default "Authorization, Origin, X-Requested-With, Range, Accept-Encoding, Content-Length, Content-Type";
}
map $host $dav_request_headers {
default "If-Match, If-None-Match, If-Modified-Since, Depth";
}
map $host $cors_allow_headers {
default "${dav_request_headers}, ${std_request_headers}";
}
3.3 Process CORS Requests
Next, we need to detect a preflight request.
We might be tempted to use if
, but remember: If is Evil, so we want to avoid it.
map
to create a variable that is equal to "truetruetrue"
if and only if we detect a preflight request.
This time, we’re using map
as intended, to conditionally define variables.
map $http_origin $cors_origin_header {
hostnames;
default "https://example.com";
"https://example.com" "$http_origin";
"https://www.example.com" "$http_origin";
}
map $http_access_control_request_headers $preflight_h {
default "true";
"" "false";
}
map $http_access_control_request_method $preflight_m {
default "true";
"" "false";
}
map $request_method $preflight {
default "false";
"OPTIONS" "${preflight_h}${preflight_m}true";
}
$preflight
to "truetruetrue"
when we detect a (non-empty) Access-Control-Request-Headers header, a (non-empty) Access-Control-Request-Method, and the request method is OPTIONS.
We set the variables through string concatination to emulate boolean and, since nginx does not support nested conditions or boolean arithmetic.location
context in the server
on which you want to enable CORS.
I add it in the location /
block of both my WebDAV and CalDAV server
blocks.
if ($preflight = "truetruetrue"){
add_header "Access-Control-Allow-Origin" "$cors_origin_header";
add_header "Access-Control-Allow-Headers" "$cors_allow_headers";
add_header "Access-Control-Allow-Methods" "REPORT, PROPFIND, COPY, MOVE, MKCOL, CONNECT, DELETE, DONE, GET, HEAD, OPTIONS, PATCH, POST, PUT";
add_header "Access-Control-Max-Age" 1728000;
add_header "Content-Type" "text/plain charset=UTF-8";
add_header "Content-Length" 0;
return 204;
}
add_header
, this if
block must appear in location
context.
Note that we also cannot move any add_header
command outside the if
.
The add_header
commands are not executed in a sequential order, but all of them are "executed" simultaneously as a block at the current level.
if
must end in return 204
.
This is part of the preflight request response (although some browsers will let you get away without it), and necessary for if
to behave correctly, since If is Evil.You can customize the Access-Control-Allow-Methods header depending on the server and your app to provide the least privilege.
if
body for the preflight request.
I added them in server
context.
add_header "Access-Control-Allow-Origin" "$cors_origin_header" always;
add_header "Access-Control-Allow-Credentials" "true" always;
add_header "Access-Control-Expose-Headers" "$cors_expose_headers" always;
always
argument is required for non-preflight requests, since the HTTP response codes for successful requests will be variously 207, 200, and 304 (maybe others), and the add_header
does not actually add a header for responses with some of these status codes.
See http://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header for more details.
4 Conclusion and Debugging
Now, if you look in the Network Monitor of your browser (Ctrl+Shift+E), and click "XHR", you should see some successful cross-origin requests from your web app. If you see they’re being rejected, try anaylzing the request, and changing the above configurations with additional headers or safelisted origins.