Enabling CORS for nginx WebDAV and CalDAV reverse-proxy

:: linux, tricks

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

Essentially, a preflight request is the browser asking the server for permission to make a request of a certain METHOD and then share the data and certain headers with a third party. The preflight request is an HTTP OPTIONS method request with the following headers set:

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

To respond to a preflight request, the server is expected to reply with an empty content response, HTTP status code 204, and the following headers:
The 204 status code declares a success with no content. An HTTP request header is one that originates from the client and is part of a request from the client.

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.

Sme headers are part of the Access-Control-Allow-Headers by default, as they are considered safe.

Figuring out exactly which headers to list in Access-Control-Allow-Headers is a little annoying. For my WebDAV (nginx) and CalDAV (radicale) servers, the following list seemed sufficient for my uses:
This will depend on exactly what web app is communicating with the server and what it relies on, what the underlying server is. You may need to do a bunch of testing in the web developer’s console to figure it out.

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

After a preflight request, the browser will start sending cross-origin HTTP requests. These will be normal HTTP requests, but the browser will expect the following additional headers in the response:
An HTTP response header is one that originates from the server and is part of a response from the server.

For my WebDAV and CalDAV servers, I needed to expose via Access-Control-Expose-Headers the following for my uses:

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

To limit which domains can issue a cross-origin request, we create a safelist and set a variable based on the Origin header of the request. We use 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 maps 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.

Instead, we’re going to use 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";
}
We set the value of $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.

To actually detect and process a preflight request, we add the following code in 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;
}
Note that due to limitations on 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.
Note also that this 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.

Finally, we add the headers for other cross-origin requests. We add the following in any valid context, except the 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;
Note that the 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.

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.